diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index a19b18a..1e42dce 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -55,6 +55,7 @@ Frontend Library (lib/src/) - **Save before kill.** Deactivate must save session state *before* killing PTYs. CWD and scrollback queries need live processes. See ordering in `extension.ts:deactivate()`. - **Alarm state is global.** A single `AlarmManager` instance in `message-router.ts` is shared across all routers and survives router disposal. PTY data feeds into it at module level, regardless of webview visibility. - **PTY ownership.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a reconnecting router from stealing PTYs owned by another webview. +- **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. - **mergeAlarmStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alarm states. Missing this causes alarm state to revert on restore. - **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. - **retainContextWhenHidden.** Set on `WebviewPanel` (editor tabs) but NOT on `WebviewView` (bottom panel). The view relies on reconnect/replay when it becomes visible again; the panel keeps its DOM alive. diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 9e1e606..03d1065 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -23,6 +23,17 @@ function resolveDefaultShell(platform = process.platform, env = process.env) { return env.SHELL || '/bin/sh'; } +const LOGIN_ARG_UNSUPPORTED_SHELLS = new Set(['csh', 'tcsh']); + +function resolveLoginArg(shell, platform = process.platform) { + if (platform === 'win32') { + return []; + } + + const shellName = path.posix.basename(shell || '').toLowerCase(); + return LOGIN_ARG_UNSUPPORTED_SHELLS.has(shellName) ? [] : ['-l']; +} + function resolveDefaultCwd(platform = process.platform, env = process.env, osModule = os) { const homedir = safeResolve(() => osModule.homedir()); const tmpdir = safeResolve(() => osModule.tmpdir()); @@ -51,6 +62,7 @@ function resolveSpawnConfig(options, runtime = {}) { const fsModule = runtime.fsModule || fs; const defaultCwd = resolveDefaultCwd(platform, env, osModule); const missingExplicitCwd = Boolean(cwd) && !directoryExists(cwd, fsModule); + const shell = resolveDefaultShell(platform, env); return { cols, @@ -58,7 +70,8 @@ function resolveSpawnConfig(options, runtime = {}) { cwd: missingExplicitCwd ? defaultCwd : (cwd || defaultCwd), cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null, env: { ...env, TERM_PROGRAM: 'MouseTerm' }, - shell: resolveDefaultShell(platform, env), + shell, + loginArg: resolveLoginArg(shell, platform), }; } @@ -160,7 +173,7 @@ module.exports.create = function create(send, ptyModule) { let p; try { - p = pty.spawn(config.shell, [], { + p = pty.spawn(config.shell, config.loginArg, { name: 'xterm-256color', cols: config.cols, rows: config.rows, diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 6632155..a642c1e 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -19,6 +19,7 @@ test('resolveSpawnConfig uses POSIX shell and home defaults', () => { assert.equal(config.cols, 80); assert.equal(config.rows, 30); assert.equal(config.env.TERM_PROGRAM, 'MouseTerm'); + assert.deepEqual(config.loginArg, ['-l']); }); test('resolveSpawnConfig uses Windows shell and profile defaults', () => { @@ -35,6 +36,7 @@ test('resolveSpawnConfig uses Windows shell and profile defaults', () => { assert.equal(config.cwd, 'C:\\Users\\tester'); assert.equal(config.cwdWarning, null); assert.equal(config.env.TERM_PROGRAM, 'MouseTerm'); + assert.deepEqual(config.loginArg, []); }); test('resolveSpawnConfig preserves explicit cwd', () => { @@ -58,6 +60,21 @@ test('resolveSpawnConfig preserves explicit cwd', () => { assert.equal(config.cwdWarning, null); assert.equal(config.cols, 120); assert.equal(config.rows, 40); + assert.deepEqual(config.loginArg, ['-l']); +}); + +test('resolveSpawnConfig skips -l for csh-style shells that reject it', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { SHELL: '/bin/tcsh' }, + osModule: { + homedir: () => '/home/tester', + tmpdir: () => '/tmp/fallback', + }, + }); + + assert.equal(config.shell, '/bin/tcsh'); + assert.deepEqual(config.loginArg, []); }); test('resolveSpawnConfig falls back to the default directory when explicit cwd is missing', () => { @@ -83,6 +100,20 @@ test('resolveSpawnConfig falls back to the default directory when explicit cwd i assert.equal(config.rows, 40); }); +test('resolveSpawnConfig skips -l for csh', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'darwin', + env: { SHELL: '/bin/csh' }, + osModule: { + homedir: () => '/Users/tester', + tmpdir: () => '/tmp/fallback', + }, + }); + + assert.equal(config.shell, '/bin/csh'); + assert.deepEqual(config.loginArg, []); +}); + test('create buffers scrollback for getScrollback requests', () => { const events = []; const listeners = {}; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index f985ae6..dad7541 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -142,10 +142,15 @@ export function attachRouter( // Route webview messages to the PTY manager const messageDisposable = webview.onDidReceiveMessage((msg: WebviewMessage) => { switch (msg.type) { - case 'pty:spawn': + case 'pty:spawn': { claim(msg.id); - ptyManager.spawn(msg.id, msg.options); + const spawnOptions = { ...msg.options }; + if (!spawnOptions.cwd) { + spawnOptions.cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + } + ptyManager.spawn(msg.id, spawnOptions); break; + } case 'pty:input': ptyManager.write(msg.id, msg.data); break;