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); 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); }); });