From 1f92dc23ef2530f0c4e55acc316085c795b32075 Mon Sep 17 00:00:00 2001 From: LonestoneBot Date: Sat, 11 Jul 2026 16:46:38 +0200 Subject: [PATCH 1/2] Start shell in the selected project dir and use the login shell Two related backend fixes for the pty spawn: - Working directory: accept an `init` message carrying the CloudCLI project path and start the shell there (validated with statSync). Spawning is deferred until the client sends `init`; a short fallback timer starts the shell in $HOME if no `init` arrives (older clients), so behavior is unchanged when the path is absent or invalid. - Login shell: when SHELL is absent from the server's environment (common when the plugin server is launched from a GUI context), fall back to the user's login shell from /etc/passwd via os.userInfo().shell instead of a bare /bin/bash. This restores the user's prompt, colors and rc configuration. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server.ts | 146 +++++++++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 54 deletions(-) diff --git a/src/server.ts b/src/server.ts index 1fe8706..e3acba5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,7 @@ interface WsMessage { data?: string; cols?: number; rows?: number; + cwd?: string; } // ── Module finder ───────────────────────────────────────────────────────────── @@ -86,7 +87,15 @@ let sessionCounter = 0; function getShell(): string { if (process.platform === 'win32') return 'powershell.exe'; - return process.env.SHELL || '/bin/bash'; + if (process.env.SHELL) return process.env.SHELL; + // The plugin server is often spawned without SHELL in its env (e.g. from a GUI + // launch context), which would drop the user to a bare /bin/bash with none of + // their prompt/colors. Fall back to the login shell from /etc/passwd. + try { + const loginShell = os.userInfo().shell; + if (loginShell) return loginShell; + } catch { /* ignore */ } + return '/bin/bash'; } function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { @@ -152,76 +161,105 @@ const server = http.createServer((req, res) => { const wss = new WebSocketServer({ server, path: '/ws' }); +// Resolve the requested working directory to a real directory, falling back to +// the user's home when it's missing, invalid, or not a directory. +function resolveCwd(requested?: unknown): string { + const home = process.env.HOME || os.homedir(); + if (typeof requested === 'string' && requested.trim()) { + try { + const abs = path.resolve(requested); + if (fs.statSync(abs).isDirectory()) return abs; + } catch { /* fall through to home */ } + } + return home; +} + wss.on('connection', (ws: any) => { const sessionId = `s${++sessionCounter}`; - const cwd = process.env.HOME || os.homedir(); const shell = getShell(); - - let ptyProc: PtyProcess; - try { - ptyProc = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd, - - env: { - ...prioritizeUserNpmGlobalBin(process.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'web-terminal', - }, - encoding: null, - - }); - } catch (err) { - safeSend(ws, { type: 'error', message: `Failed to spawn shell: ${(err as Error).message}` }); - ws.close(); - return; - } - - sessions.set(sessionId, { pty: ptyProc, ws }); - safeSend(ws, { type: 'ready', sessionId, shell, cwd }); - const decoder = new TextDecoder('utf-8', { fatal: false }); - ptyProc.onData((chunk: string | Buffer) => { - const text = typeof chunk === 'string' - ? chunk - : decoder.decode(chunk, { stream: true }); - - if (!text) { + let ptyProc: PtyProcess | null = null; + // The client sends an `init` message with the selected project path right + // after connecting, so the shell can start there instead of $HOME. If it + // never arrives (older client), spawn in $HOME shortly after connecting. + let spawnTimer: ReturnType | null = setTimeout(() => startShell(), 400); + + function startShell(requestedCwd?: unknown): void { + if (ptyProc) return; + if (spawnTimer) { clearTimeout(spawnTimer); spawnTimer = null; } + const cwd = resolveCwd(requestedCwd); + + let proc: PtyProcess; + try { + proc = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd, + env: { + ...prioritizeUserNpmGlobalBin(process.env), + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'web-terminal', + }, + encoding: null, + }); + } catch (err) { + safeSend(ws, { type: 'error', message: `Failed to spawn shell: ${(err as Error).message}` }); + ws.close(); return; } - ptyProc.pause(); - if (ws.readyState === WebSocket.OPEN) { - ws.send(text, () => ptyProc.resume()); - } else { - ptyProc.resume(); - } - }); + ptyProc = proc; + sessions.set(sessionId, { pty: proc, ws }); + safeSend(ws, { type: 'ready', sessionId, shell, cwd }); + + proc.onData((chunk: string | Buffer) => { + const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + if (!text) return; + proc.pause(); + if (ws.readyState === WebSocket.OPEN) { + ws.send(text, () => proc.resume()); + } else { + proc.resume(); + } + }); - ptyProc.onExit(({ exitCode, signal }) => { - sessions.delete(sessionId); - safeSend(ws, { type: 'exit', sessionId, exitCode, signal }); - if (ws.readyState === WebSocket.OPEN) ws.close(1000, 'shell exited'); - }); + proc.onExit(({ exitCode, signal }) => { + sessions.delete(sessionId); + safeSend(ws, { type: 'exit', sessionId, exitCode, signal }); + if (ws.readyState === WebSocket.OPEN) ws.close(1000, 'shell exited'); + }); + } ws.on('message', (rawData: Buffer | string) => { const text = Buffer.isBuffer(rawData) ? rawData.toString('utf8') : String(rawData); + + let msg: WsMessage | null = null; if (text.charCodeAt(0) === 123) { - try { - const msg: WsMessage = JSON.parse(text); - if (msg.type === 'input' && typeof msg.data === 'string') { ptyProc.write(msg.data); return; } - if (msg.type === 'resize') { ptyProc.resize(Math.max(1, Math.min(Number(msg.cols) || 80, 500)), Math.max(1, Math.min(Number(msg.rows) || 24, 200))); return; } - if (msg.type === 'ping') { safeSend(ws, { type: 'pong', sessionId }); return; } - } catch { /* fall through */ } + try { msg = JSON.parse(text) as WsMessage; } catch { msg = null; } + } + + if (msg && msg.type === 'init') { startShell(msg.cwd); return; } + + // Any traffic before an init message starts the shell in $HOME. + if (!ptyProc) startShell(); + if (!ptyProc) return; // spawn failed + + if (msg) { + if (msg.type === 'input' && typeof msg.data === 'string') { ptyProc.write(msg.data); return; } + if (msg.type === 'resize') { ptyProc.resize(Math.max(1, Math.min(Number(msg.cols) || 80, 500)), Math.max(1, Math.min(Number(msg.rows) || 24, 200))); return; } + if (msg.type === 'ping') { safeSend(ws, { type: 'pong', sessionId }); return; } } ptyProc.write(text); }); - ws.on('close', () => { sessions.delete(sessionId); try { ptyProc.kill(); } catch { /* ignore */ } }); + ws.on('close', () => { + if (spawnTimer) { clearTimeout(spawnTimer); spawnTimer = null; } + sessions.delete(sessionId); + if (ptyProc) { try { ptyProc.kill(); } catch { /* ignore */ } } + }); ws.on('error', (err: Error) => { console.error(`[web-terminal] ${sessionId} error:`, err.message); }); }); From 072135c9730a25590d8e7727d966fdbb857f2508 Mon Sep 17 00:00:00 2001 From: LonestoneBot Date: Sat, 11 Jul 2026 16:46:38 +0200 Subject: [PATCH 2/2] Send project cwd on connect and polish terminal rendering - Send an `init` message with the selected project path (api.context.project) when each session's socket opens, kept in sync via onContextChange. Existing tabs keep their original directory. - Rendering polish (no new dependencies, keeps the WebGL renderer): roomier lineHeight, small letterSpacing, bar cursor, bolder bold weight, mild minimumContrastRatio, and a font stack that leads with Menlo / SF Mono on macOS while keeping the cross-platform and CJK/emoji fallbacks. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9257419..9845764 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,7 +115,7 @@ const THEMES: Record = { // ── Persistent prefs ────────────────────────────────────────────────────────── const PREFS_KEY = 'web-terminal-prefs'; const WEBGL_DISABLED_KEY = 'web-terminal-disable-webgl'; -const DEFAULT_FONT_FAMILY = '"Cascadia Mono", Consolas, "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans CJK JP", "Microsoft YaHei", "MS Gothic", Meiryo, "PingFang SC", "Hiragino Sans GB", "Noto Color Emoji", Menlo, Monaco, "Courier New", monospace'; +const DEFAULT_FONT_FAMILY = '"SF Mono", "SFMono-Regular", Menlo, "Cascadia Mono", "JetBrains Mono", Consolas, "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans CJK JP", "Microsoft YaHei", "MS Gothic", Meiryo, "PingFang SC", "Hiragino Sans GB", "Noto Color Emoji", Monaco, "Courier New", monospace'; function isWebglDisabled(): boolean { try { return localStorage.getItem(WEBGL_DISABLED_KEY) === 'true'; } catch { return false; } } function loadPrefs(): Partial { try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { return {}; } } function savePrefs(p: Prefs): void { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* ignore */ } } @@ -313,6 +313,7 @@ interface SessionOptions { Terminal: any; FitAddon: any; WebLinksAddon: any; WebglAddon: any; ClipboardAddon: any; Unicode11Addon: any; prefs: Prefs; + cwd?: string; onChange: (id: string, status: string) => void; } @@ -322,6 +323,7 @@ class TerminalSession { status: string; onChange: (id: string, status: string) => void; prefs: Prefs; + cwd: string; el: HTMLElement; overlayEl: HTMLElement; terminal: any; @@ -343,6 +345,7 @@ class TerminalSession { this.status = 'connecting'; this.onChange = opts.onChange; this.prefs = opts.prefs; + this.cwd = opts.cwd || ''; this._destroyed = false; this._reconnectTimer = null; this._reconnectAttempts = 0; @@ -355,8 +358,15 @@ class TerminalSession { this.terminal = new opts.Terminal({ cursorBlink: true, + cursorStyle: 'bar', fontSize: opts.prefs.fontSize || 14, fontFamily: opts.prefs.fontFamily || DEFAULT_FONT_FAMILY, + fontWeight: 400, + fontWeightBold: 600, + lineHeight: 1.2, + letterSpacing: 0.4, + minimumContrastRatio: 1.1, + drawBoldTextInBrightColors: true, allowProposedApi: true, convertEol: true, scrollback: 10000, tabStopWidth: 4, macOptionIsMeta: true, macOptionClickForcesSelection: true, theme: THEMES[opts.prefs.theme || 'VS Dark'], @@ -431,6 +441,11 @@ class TerminalSession { ws.binaryType = 'arraybuffer'; this.ws = ws; + ws.onopen = () => { + // Tell the server where to start the shell (selected project, else $HOME). + try { ws.send(JSON.stringify({ type: 'init', cwd: this.cwd || '' })); } catch { /* ignore */ } + }; + ws.onmessage = (ev: MessageEvent) => { let d = ev.data; // Decode binary frames to text (happens when behind reverse proxies) @@ -637,6 +652,11 @@ export async function mount(container: HTMLElement, api: PluginAPI): Promise prefs.theme === 'Light'; + // Working directory for new shells: the selected CloudCLI project, else $HOME + // (empty string lets the server pick the home directory). Kept in sync as the + // user switches projects; existing tabs keep their original directory. + let projectCwd = api.context?.project?.path || ''; + const root = el('div', 'wt-root' + (isLight() ? ' wt-light' : '')); container.appendChild(root); @@ -780,7 +800,7 @@ export async function mount(container: HTMLElement, api: PluginAPI): Promise {}) : null; + const unsubCtx = api.onContextChange + ? api.onContextChange((ctx) => { projectCwd = ctx?.project?.path || ''; }) + : null; (container as any)._wtCleanup = () => { document.removeEventListener('keydown', onKey);