From ef18d8c2f2a368fe4881dd20b315899841d7a9d3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 30 May 2026 16:17:58 -0700 Subject: [PATCH 1/4] Inject zsh OSC 633 shell integration and show command exit status Make spawned zsh shells emit the OSC 633 command/prompt sequences the parser already consumes, so command tracking has real boundaries, exit codes, and cwd instead of the keystroke heuristic's best-effort guesses. Injection (standalone/sidecar/pty-core.js): for zsh, point ZDOTDIR at shipped integration dotfiles and pass the user's real ZDOTDIR through USER_ZDOTDIR. Our .zshenv/.zprofile/.zshrc chain to the user's dotfiles, then install precmd/preexec hooks that emit OSC 633 A/B/C/D;/E/P. ZDOTDIR is handed back before the user's rc runs so zsh writes .zcompdump/.zsh_history to the user's dir, our precmd runs first so $? is the command's real exit code, and missing scripts fail safe to today's behavior. Pure-env, as reliable as the DORMOUSE_CLI_BIN PATH prepend. Both distributions spawn through here; the VS Code build copies the scripts into dist/shell-integration and points DORMOUSE_SHELL_INTEGRATION_DIR at them. Keystroke gating (terminal-state-store.ts): the first real OSC boundary a pane sees retires the keystroke command heuristic for that pane, so the two never both synthesize a command start. The heuristic's own synthesized prompt markers are flagged so they don't trip the detection. Exit status glyph (terminal-state.ts, TerminalPaneHeader.tsx): the idle title gains a trailing "x" when the last command exited non-zero ( false x). It's a plain glyph in the title string so tab/OS titles carry it too; the pane header re-colors it red. Shown only with a real exit code, so it doubles as a live signal that integration is driving. bash/fish/PowerShell injection are stubbed in the docs table as follow-ups; cmd.exe has no per-command hook and stays on the keystroke fallback. Co-Authored-By: Claude Opus 4.8 --- docs/specs/terminal-escapes.md | 24 ++++++ .../components/wall/TerminalPaneHeader.tsx | 16 +++- lib/src/lib/terminal-state-store.test.ts | 30 +++++++ lib/src/lib/terminal-state-store.ts | 42 +++++++++- lib/src/lib/terminal-state.test.ts | 40 +++++++++ lib/src/lib/terminal-state.ts | 16 +++- standalone/sidecar/pty-core.js | 57 ++++++++++--- standalone/sidecar/pty-core.test.js | 81 +++++++++++++++++++ .../sidecar/shell-integration/zsh/.gitignore | 4 + .../sidecar/shell-integration/zsh/.zprofile | 8 ++ .../sidecar/shell-integration/zsh/.zshenv | 22 +++++ .../sidecar/shell-integration/zsh/.zshrc | 68 ++++++++++++++++ vscode-ext/package.json | 2 +- vscode-ext/src/pty-manager.ts | 3 + 14 files changed, 397 insertions(+), 16 deletions(-) create mode 100644 standalone/sidecar/shell-integration/zsh/.gitignore create mode 100644 standalone/sidecar/shell-integration/zsh/.zprofile create mode 100644 standalone/sidecar/shell-integration/zsh/.zshenv create mode 100644 standalone/sidecar/shell-integration/zsh/.zshrc diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index b5f3967f..a99e23f9 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -126,6 +126,30 @@ Device/version query: Because this identity can cause tools to emit more iTerm2 escape codes than Dormouse implements, **unsupported escape codes must fail inertly**: consume or ignore them without visible terminal garbage, privilege escalation, clipboard access, file access, or focus stealing. This rule applies to both OSC and CSI sequences (see [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) for OSCs and the [Pass-through and fail-inertly](#pass-through-and-fail-inertly) note under CSI). +## Shell-integration injection + +The iTerm2 identity above makes well-behaved tools emit OSC 633/133 *if their own shell integration is loaded* — but most shells don't emit prompt/command boundaries on their own. So Dormouse injects its own integration when it spawns a shell, making the shell emit the `OSC 633` family (`A`/`B` prompt boundaries, `C` command start, `D;` command finish, `E;`, `P;Cwd=`) that the parser above already consumes. This is the *emit* side of OSC 633; the parser is the *consume* side. + +A binary on `PATH` only has to be **found**, so it injects via one env var (`DORMOUSE_CLI_BIN` → `PATH`). OSC 633 is different: the shell must **run hook code on every prompt**, which no single env var enables. The reliable per-shell mechanism therefore differs by shell: + +| Shell | Mechanism | Channel | Notes | +|---|---|---|---| +| zsh | `ZDOTDIR` → our dotfiles chain to the user's, then install `precmd`/`preexec` hooks | env (as reliable as the `PATH` prepend) | User's real `ZDOTDIR` is passed through as `USER_ZDOTDIR`; our `.zshrc` hands `ZDOTDIR` back so `.zlogin` and child shells are unaffected. | +| bash | `--rcfile`/`--init-file`; the script re-sources the user's rc and replicates login-profile loading | shellArgs | `--rcfile` conflicts with login mode, so the script must replicate what `-l` would have sourced. (not yet implemented) | +| fish | `XDG_DATA_DIRS` → fish auto-sources `*/fish/vendor_conf.d/*.fish` | env | (not yet implemented) | +| PowerShell | `-NoExit -Command ` | shellArgs | (not yet implemented) | +| cmd.exe | no per-command hook exists | — | Never gets real OSC 633; always uses the keystroke fallback below. | + +Injection is wired in `resolveSpawnConfig` (`standalone/sidecar/pty-core.js`) and applies to both distributions (the standalone sidecar and the VS Code pty-host both spawn through it). The integration scripts are static files under `standalone/sidecar/shell-integration/`; the directory is resolved from `DORMOUSE_SHELL_INTEGRATION_DIR` (set by the host, mirroring `DORMOUSE_CLI_BIN`) and falls back to the sidecar's own directory. Standalone ships them via the tauri `../sidecar/**/*` resources glob; the VS Code build copies them into `dist/shell-integration`. If the scripts are missing, injection is skipped and the shell spawns exactly as before — injection is fail-safe. + +### Keystroke fallback + +When injection isn't possible (cmd.exe, an unknown shell, or scripts not present) or simply doesn't take, Dormouse falls back to its keystroke heuristic: it watches what the user types and synthesizes `commandStart{source:'user_input'}` (see `recordTerminalUserInput` in `lib/src/lib/terminal-state-store.ts` and [terminal-state.md](terminal-state.md)). This fallback has no real exit codes and only a best-effort idle transition. + +The two are mutually exclusive **per pane**: the first genuine OSC 633/133 boundary a pane sees (a real prompt-start lands before the first command is typed) flips that pane to "OSC-driven" and retires the keystroke heuristic for it, so the two never both report the same command. Prompt boundaries that the heuristic *itself* synthesizes are flagged so they don't trip this detection. This is what makes the fallback fire "only if injection fails." + +> Packaging caveat: the zsh scripts are dotfiles (`.zshrc`, `.zshenv`, `.zprofile`). Confirm the VS Code `.vsix` actually includes `dist/shell-integration/.z*` — if a packaging step strips dotfiles, VS Code silently degrades to the keystroke fallback. + ## Known-unimplemented iTerm2 and clipboard-capable sequences Dormouse intentionally does not implement the following sequences. They are mostly iTerm2-proprietary; `OSC 50` (font) and `OSC 52` (clipboard) are standard xterm extensions included here because the iTerm2 identity prompts tools to emit them and they have security implications. All of them must fail inertly per the rule above, which means they are consumed/ignored rather than forwarded to xterm.js. diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 90c614f6..6549c348 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -37,6 +37,7 @@ import { import { buildAppTitleResolver, createTerminalPaneState, + COMMAND_FAIL_GLYPH, deriveHeader, resolveDisplayPrimary, titleCandidatesForDisplay, @@ -102,6 +103,12 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { ); const derivedHeader = deriveHeader(paneState, visiblePaneStates, { appTitleForPane }); const displayTitle = resolveDisplayPrimary(derivedHeader.primary, api.title); + // The failure glyph rides at the end of the title string (so tabs/OS titles + // carry it too); split it off here to color it red, and strip it from the + // base used in editing/rename contexts. + const failGlyphSuffix = ` ${COMMAND_FAIL_GLYPH}`; + const showsFailGlyph = displayTitle.endsWith(failGlyphSuffix); + const displayTitleBase = showsFailGlyph ? displayTitle.slice(0, -failGlyphSuffix.length) : displayTitle; const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; const showMouseIcon = mouseState.mouseReporting !== 'none'; const inOverride = mouseState.override !== 'off'; @@ -205,7 +212,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { el?.select()} onKeyDown={(e) => { @@ -231,7 +238,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { setTitleCandidatesRect(e.currentTarget.getBoundingClientRect()); }} > - {displayTitle} + {displayTitleBase} + {showsFailGlyph && ( + {COMMAND_FAIL_GLYPH} + )} {derivedHeader.secondary && ( {derivedHeader.secondary} )} @@ -382,7 +392,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { )} diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index ede141fc..5d2a1127 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -61,6 +61,36 @@ describe('terminal semantic state store command input fallback', () => { expect(state.activity).toEqual({ kind: 'prompt' }); }); + it('retires the keystroke fallback once the shell emits real OSC command boundaries', () => { + // Shell integration draws its first prompt before any command is typed. + applyTerminalSemanticEvents('pane', [{ type: 'promptStart' }]); + // The user then runs a command; OSC drives it, the keystroke path must not. + submit('pane', 'lazygit'); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); + + it('lets the OSC command-start win instead of double-counting with keystrokes', () => { + applyTerminalSemanticEvents('pane', [{ type: 'commandStart', source: 'osc633_boundaries' }]); + submit('pane', 'lazygit'); + + // currentCommand stays the OSC-sourced one; no user_input command is layered on. + expect(getTerminalPaneState('pane').currentCommand?.source).toBe('osc633_boundaries'); + }); + + it('keeps the keystroke fallback alive across its own synthesized prompt markers', () => { + submit('pane', 'first'); + // The heuristic itself emits promptStart/promptEnd here — that must not be + // mistaken for shell integration and silence the fallback. + recordTerminalOutput('pane', '\r\nuser@host repo % '); + submit('pane', 'second'); + + expect(getTerminalPaneState('pane').currentCommand).toMatchObject({ + source: 'user_input', + rawCommandLine: 'second', + }); + }); + it('returns to idle when prompt-looking output follows a user-input command', () => { submit('pane', 'lazygit'); recordTerminalOutput('pane', '\x1b[?1049l\r\nuser@host repo % '); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index a06f5143..809a5dc5 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -21,7 +21,29 @@ const paneStates = new Map(); const promptSubmitStates = new Map(); const promptShapes = new Map(); const promptOutputBuffers = new Map(); +// Panes whose shell emits real OSC 633/133 command boundaries (i.e. shell +// integration injection took). Once a pane is here, the keystroke heuristic +// stands down so the two don't both synthesize command starts — the keystroke +// path is the fallback "only if injection fails". See docs/specs/terminal-escapes.md. +const oscDrivenPanes = new Set(); const listeners = new Set<() => void>(); + +// Events that prove the shell itself is reporting prompt/command boundaries via +// OSC, as opposed to boundaries the keystroke heuristic synthesizes. A real +// prompt-start (A) lands on the very first prompt — before any command is typed +// — so this flips a pane to OSC-driven ahead of the first keystroke command. +function isOscDrivenBoundary(event: TerminalSemanticEvent): boolean { + switch (event.type) { + case 'promptStart': + case 'promptEnd': + case 'commandFinish': + return true; + case 'commandStart': + return event.source === 'osc633_boundaries' || event.source === 'osc133_boundaries'; + default: + return false; + } +} let cachedSnapshot: Map | null = null; export function subscribeToTerminalPaneState(listener: () => void): () => void { @@ -54,6 +76,7 @@ export function resetTerminalPaneState(id: string, initial?: Partial event.type === 'promptStart' || event.type === 'promptEnd' || event.type === 'commandStart')) { promptSubmitStates.delete(id); promptOutputBuffers.delete(id); @@ -98,6 +131,9 @@ export interface PromptLineReader { export function recordTerminalUserInput(id: string, input: string, reader?: PromptLineReader): void { if (!input) return; + // Shell integration is authoritative once it's emitting OSC boundaries; don't + // also synthesize command starts from keystrokes (that would double-count). + if (oscDrivenPanes.has(id)) return; const state = paneStates.get(id) ?? createTerminalPaneState(); if (state.currentCommand || state.activity.kind === 'running' || state.activity.kind === 'finished') return; @@ -144,7 +180,9 @@ export function recordTerminalOutput(id: string, output: string): void { // running; OSC-tracked shells drive their own boundaries. const state = paneStates.get(id); if (state?.currentCommand?.source === 'user_input') { - applyTerminalSemanticEvents(id, [{ type: 'promptStart' }, { type: 'promptEnd' }]); + // Flagged as the heuristic's own synthesis so it doesn't mark the pane + // OSC-driven (which would then silence the very path emitting this). + applyTerminalSemanticEvents(id, [{ type: 'promptStart' }, { type: 'promptEnd' }], { keystrokeHeuristic: true }); } } diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index eed18974..b49966c6 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -6,6 +6,7 @@ import { cwdFromOsc7, cwdFromOsc9_9, cwdIdentity, + COMMAND_FAIL_GLYPH, DEFAULT_IDLE_TITLE, deriveHeader, buildAppTitleResolver, @@ -181,6 +182,45 @@ describe('terminal command state reducer', () => { }); }); + it('appends the fail glyph to the idle title when the last command exited non-zero', () => { + let state = runningPane('/repo/app', 'pnpm build'); + state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 1 }, { now: () => 2 }); + + expect(deriveHeader(state, [state])).toEqual({ + primary: `${DEFAULT_IDLE_TITLE} pnpm build ${COMMAND_FAIL_GLYPH}`, + }); + + // The marker persists across the next prompt, until a new command runs. + state = reduceTerminalState(state, { type: 'promptStart' }); + expect(deriveHeader(state, [state]).primary).toBe(`${DEFAULT_IDLE_TITLE} pnpm build ${COMMAND_FAIL_GLYPH}`); + }); + + it('shows no fail glyph for a successful command', () => { + let state = runningPane('/repo/app', 'pnpm build'); + state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 0 }, { now: () => 2 }); + + expect(deriveHeader(state, [state])).toEqual({ primary: `${DEFAULT_IDLE_TITLE} pnpm build` }); + }); + + it('shows no fail glyph when the exit code is unknown (keystroke fallback)', () => { + let state = runningPane('/repo/app', 'pnpm build'); + state = reduceTerminalState(state, { type: 'commandFinish' }, { now: () => 2 }); + + expect(deriveHeader(state, [state])).toEqual({ primary: `${DEFAULT_IDLE_TITLE} pnpm build` }); + }); + + it('drops the fail glyph once a new command starts running', () => { + let state = runningPane('/repo/app', 'pnpm build'); + state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 1 }, { now: () => 2 }); + state = reduceTerminalState(state, { type: 'commandStart', source: 'osc633_boundaries' }, { + now: () => 3, + createId: () => 'next', + }); + + // While running we show the live command, no glyph. + expect(deriveHeader(state, [state]).primary).not.toContain(COMMAND_FAIL_GLYPH); + }); + it('clears stale pending typed command lines on a fresh prompt', () => { let state = createTerminalPaneState({ pendingCommandLine: 'stale command' }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index abfea280..f117389c 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -124,6 +124,11 @@ export const DEFAULT_TERMINAL_PANE_STATE: TerminalPaneState = Object.freeze({ }); export const DEFAULT_IDLE_TITLE = ''; +// Appended to the idle title when the last command exited non-zero. Kept as a +// plain glyph in the title string so tab/OS-level titles carry it too; the pane +// header re-colors this trailing glyph red (see TerminalPaneHeader). Only shows +// when we have a real exit code — the keystroke fallback leaves exitCode unset. +export const COMMAND_FAIL_GLYPH = '✗'; export const DEFAULT_COMMAND_TITLE = 'shell'; export const UNNAMED_PANEL_TITLE = ''; const DEFAULT_DIRECTORY_LABEL = 'Unknown directory'; @@ -770,10 +775,19 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); if (userTitle) return userTitle; if (pane.currentCommand) return commandHeaderLabel(pane, pane.currentCommand, options); - if (pane.lastCommand) return `${DEFAULT_IDLE_TITLE} ${commandHeaderLabel(pane, pane.lastCommand, options)}`; + if (pane.lastCommand) { + const idle = `${DEFAULT_IDLE_TITLE} ${commandHeaderLabel(pane, pane.lastCommand, options)}`; + return lastCommandFailed(pane.lastCommand) ? `${idle} ${COMMAND_FAIL_GLYPH}` : idle; + } return DEFAULT_IDLE_TITLE; } +// A finished command "failed" only when we have a real non-zero exit code. The +// keystroke fallback never sets exitCode, so it shows no glyph either way. +function lastCommandFailed(command: CommandRun): boolean { + return typeof command.exitCode === 'number' && command.exitCode !== 0; +} + function commandHeaderLabel(pane: TerminalPaneState, command: CommandRun, options: HeaderOptions): string { const appTitle = options.appTitleForPane?.(pane)?.trim(); if (appTitle && isAppTitleFreshFor(pane, command)) return appTitle; diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index da3c9192..11e28a39 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -73,9 +73,43 @@ function withPrependedPath(env, dir, platform = process.platform) { function withoutInternalDormouseEnv(env) { const next = { ...env }; delete next.DORMOUSE_CLI_BIN; + delete next.DORMOUSE_SHELL_INTEGRATION_DIR; return next; } +// Directory holding the per-shell OSC 633 integration scripts. Shipped next to +// this file (standalone bundles it via the tauri `../sidecar/**/*` resources +// glob); `DORMOUSE_SHELL_INTEGRATION_DIR` overrides it for hosts that stage the +// sidecar elsewhere (e.g. the VS Code bundle) and for tests. +function resolveShellIntegrationDir(env, runtime = {}) { + return env.DORMOUSE_SHELL_INTEGRATION_DIR || path.join(runtime.dirname || __dirname, 'shell-integration'); +} + +// Enable OSC 633 shell integration for shells that support reliable injection, +// returning possibly-modified { env, shellArgs }. The keystroke-based command +// heuristic remains the fallback for shells we can't inject (cmd.exe, others) +// or when the scripts aren't present on disk. See docs/specs/terminal-escapes.md. +// +// zsh — injected purely via env (`ZDOTDIR`), as reliable as a PATH prepend. +// We point ZDOTDIR at our scripts and pass the user's real ZDOTDIR through +// `USER_ZDOTDIR`; our dotfiles chain to the user's then install the hooks. +function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = {}) { + const fsModule = runtime.fsModule || fs; + const shellName = path.posix.basename(shell || '').toLowerCase(); + + if (shellName === 'zsh') { + const zshDir = path.join(integrationDir, 'zsh'); + if (fileExists(path.join(zshDir, '.zshrc'), fsModule)) { + return { + env: { ...env, ZDOTDIR: zshDir, USER_ZDOTDIR: env.ZDOTDIR || env.HOME || '' }, + shellArgs, + }; + } + } + + return { env, shellArgs }; +} + function resolveSpawnConfig(options, runtime = {}) { const { cols = 80, rows = 30, cwd, shell: explicitShell, args: explicitArgs, surfaceId } = options || {}; const env = { @@ -94,23 +128,28 @@ function resolveSpawnConfig(options, runtime = {}) { ? explicitArgs : resolveLoginArg(shell, platform); + // Resolve the integration dir from the original env before the internal + // DORMOUSE_* vars are stripped below. + const integrationDir = resolveShellIntegrationDir(env, runtime); const envWithCliPath = withoutInternalDormouseEnv(withPrependedPath(env, env.DORMOUSE_CLI_BIN, platform)); + const childEnv = { + ...envWithCliPath, + TERM_PROGRAM: 'iTerm.app', + TERM_PROGRAM_VERSION: ITERM2_COMPAT_VERSION, + LC_TERMINAL: 'iTerm2', + LC_TERMINAL_VERSION: ITERM2_COMPAT_VERSION, + DORMOUSE_SURFACE_ID: surfaceId || options?.id || '', + }; + const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, runtime); return { cols, rows, cwd: missingExplicitCwd ? defaultCwd : (cwd || defaultCwd), cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null, - env: { - ...envWithCliPath, - TERM_PROGRAM: 'iTerm.app', - TERM_PROGRAM_VERSION: ITERM2_COMPAT_VERSION, - LC_TERMINAL: 'iTerm2', - LC_TERMINAL_VERSION: ITERM2_COMPAT_VERSION, - DORMOUSE_SURFACE_ID: surfaceId || options?.id || '', - }, + env: integrated.env, shell, - shellArgs, + shellArgs: integrated.shellArgs, }; } diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 1228bda9..9544ba21 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -351,6 +351,87 @@ test('resolveSpawnConfig honors non-empty explicit args (e.g. WSL distro flags)' assert.deepEqual(config.shellArgs, ['-d', 'Ubuntu']); }); +// ── OSC 633 shell-integration injection ───────────────────────────────── + +// Pretend the shipped integration scripts exist on disk. +const integrationFsModule = { + statSync(filePath) { + if (String(filePath).endsWith('.zshrc')) return { isFile: () => true }; + throw new Error(`ENOENT: ${filePath}`); + }, +}; + +test('resolveSpawnConfig injects zsh integration via ZDOTDIR and preserves the user ZDOTDIR', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { + SHELL: '/bin/zsh', + HOME: '/home/tester', + ZDOTDIR: '/home/tester/.config/zsh', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: integrationFsModule, + }); + + assert.equal(config.env.ZDOTDIR, '/opt/dormouse/shell-integration/zsh'); + assert.equal(config.env.USER_ZDOTDIR, '/home/tester/.config/zsh'); + // Login flag is unaffected — integration is env-only for zsh. + assert.deepEqual(config.shellArgs, ['-l']); + // The internal pointer is not leaked to the shell. + assert.equal(config.env.DORMOUSE_SHELL_INTEGRATION_DIR, undefined); +}); + +test('resolveSpawnConfig zsh integration falls back to HOME when the user has no ZDOTDIR', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { + SHELL: '/bin/zsh', + HOME: '/home/tester', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: integrationFsModule, + }); + + assert.equal(config.env.ZDOTDIR, '/opt/dormouse/shell-integration/zsh'); + assert.equal(config.env.USER_ZDOTDIR, '/home/tester'); +}); + +test('resolveSpawnConfig leaves non-zsh shells untouched (keystroke fallback)', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { + SHELL: '/bin/bash', + HOME: '/home/tester', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: integrationFsModule, + }); + + assert.equal(config.env.ZDOTDIR, undefined); + assert.equal(config.env.USER_ZDOTDIR, undefined); +}); + +test('resolveSpawnConfig skips zsh integration when the scripts are not present', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { + SHELL: '/bin/zsh', + HOME: '/home/tester', + ZDOTDIR: '/home/tester/.config/zsh', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: { statSync() { throw new Error('ENOENT'); } }, + }); + + // ZDOTDIR is left exactly as the user had it; no injection occurred. + assert.equal(config.env.ZDOTDIR, '/home/tester/.config/zsh'); + assert.equal(config.env.USER_ZDOTDIR, undefined); +}); + // ── detectAvailableShells ─────────────────────────────────────────────── test('detectAvailableShells returns $SHELL on non-Windows', () => { diff --git a/standalone/sidecar/shell-integration/zsh/.gitignore b/standalone/sidecar/shell-integration/zsh/.gitignore new file mode 100644 index 00000000..3da98ec4 --- /dev/null +++ b/standalone/sidecar/shell-integration/zsh/.gitignore @@ -0,0 +1,4 @@ +# Runtime artifacts zsh may write into ZDOTDIR; never commit or ship these. +.zcompdump* +.zsh_history +.zsh_sessions/ diff --git a/standalone/sidecar/shell-integration/zsh/.zprofile b/standalone/sidecar/shell-integration/zsh/.zprofile new file mode 100644 index 00000000..013c8a6c --- /dev/null +++ b/standalone/sidecar/shell-integration/zsh/.zprofile @@ -0,0 +1,8 @@ +# Dormouse zsh shell integration — login profile (.zprofile). +# Only read for login shells; chain to the user's and re-pin ZDOTDIR to ours so +# our .zshrc still loads. +: ${USER_ZDOTDIR:=$HOME} +if [[ -f ${USER_ZDOTDIR}/.zprofile ]]; then + builtin source ${USER_ZDOTDIR}/.zprofile +fi +ZDOTDIR=${DORMOUSE_ZDOTDIR} diff --git a/standalone/sidecar/shell-integration/zsh/.zshenv b/standalone/sidecar/shell-integration/zsh/.zshenv new file mode 100644 index 00000000..63f9a5a9 --- /dev/null +++ b/standalone/sidecar/shell-integration/zsh/.zshenv @@ -0,0 +1,22 @@ +# Dormouse zsh shell integration — bootstrap (.zshenv). +# +# Dormouse spawns zsh with ZDOTDIR pointed at this directory and USER_ZDOTDIR set +# to the user's real ZDOTDIR (or $HOME). zsh sources $ZDOTDIR/.zshenv first, then +# .zprofile/.zshrc/.zlogin from whatever ZDOTDIR holds at the time it reads each +# one. We keep ZDOTDIR pointed here through .zshenv/.zprofile/.zshrc so our files +# load, chaining to the user's real dotfiles, and only hand ZDOTDIR back to the +# user at the end of .zshrc (see that file for the handoff and why .zlogin then +# loads straight from the user's directory). + +# Remember our own directory so we can re-pin after sourcing user files that may +# themselves reassign ZDOTDIR. +DORMOUSE_ZDOTDIR=${ZDOTDIR:-$HOME} +: ${USER_ZDOTDIR:=$HOME} + +if [[ -f ${USER_ZDOTDIR}/.zshenv ]]; then + builtin source ${USER_ZDOTDIR}/.zshenv +fi + +# A user .zshenv that sets ZDOTDIR would otherwise divert zsh away from our +# .zprofile/.zshrc; re-pin so the rest of our startup still runs. +ZDOTDIR=${DORMOUSE_ZDOTDIR} diff --git a/standalone/sidecar/shell-integration/zsh/.zshrc b/standalone/sidecar/shell-integration/zsh/.zshrc new file mode 100644 index 00000000..e76df6c2 --- /dev/null +++ b/standalone/sidecar/shell-integration/zsh/.zshrc @@ -0,0 +1,68 @@ +# Dormouse zsh shell integration — interactive rc (.zshrc). +# +# Hands ZDOTDIR back to the user, sources their real .zshrc, then installs the +# OSC 633 prompt/command hooks. We restore ZDOTDIR *before* running the user's rc +# so that anything zsh writes relative to ZDOTDIR — .zcompdump, .zsh_history — +# lands in the user's directory, not ours (which is read-only when shipped). It +# also means login shells read $USER_ZDOTDIR/.zlogin next (the user's, directly) +# and child shells behave normally, so this directory needs no .zlogin of its own. + +: ${USER_ZDOTDIR:=$HOME} +ZDOTDIR=${USER_ZDOTDIR} +if [[ -f ${USER_ZDOTDIR}/.zshrc ]]; then + builtin source ${USER_ZDOTDIR}/.zshrc +fi + +# Guard against a re-sourced .zshrc installing the hooks twice. +if [[ -z ${DORMOUSE_SHELL_INTEGRATION} ]]; then + DORMOUSE_SHELL_INTEGRATION=1 + + autoload -Uz add-zsh-hook + + # Escape a value for OSC 633 transport. The parser splits the E command field + # on the first raw ';' then decodes \\ and \xNN, so backslash and semicolon + # must be escaped; newlines/CR are escaped to keep the sequence single-line. + __dormouse_633_escape() { + local value=$1 + value=${value//\\/\\\\} + value=${value//;/\\x3b} + value=${value//$'\n'/\\x0a} + value=${value//$'\r'/\\x0d} + builtin print -rn -- "$value" + } + + # First precmd has no preceding command, so it must not emit a D (finished). + __dormouse_633_first_prompt=1 + + # preexec: the user submitted a command line. Report it (E) and mark the start + # of command output (C). + __dormouse_633_preexec() { + builtin printf '\e]633;E;%s\a' "$(__dormouse_633_escape "$1")" + builtin printf '\e]633;C\a' + } + + # precmd: a command just finished (D, with its exit code) and a new prompt is + # about to render. Emit cwd (P) and the prompt-start marker (A). Emitting A + # here rather than from PS1 keeps it working under prompt frameworks that + # rebuild PS1 on every prompt. + __dormouse_633_precmd() { + local exit_code=$? + if [[ -z ${__dormouse_633_first_prompt} ]]; then + builtin printf '\e]633;D;%s\a' "$exit_code" + fi + __dormouse_633_first_prompt= + builtin printf '\e]633;P;Cwd=%s\a' "$PWD" + builtin printf '\e]633;A\a' + } + + add-zsh-hook preexec __dormouse_633_preexec + add-zsh-hook precmd __dormouse_633_precmd + # Our precmd must run before any user precmd hook (e.g. oh-my-zsh), otherwise + # $? would be the previous hook's status instead of the command's exit code. + precmd_functions=(__dormouse_633_precmd ${precmd_functions:#__dormouse_633_precmd}) + + # Mark prompt end / input start (B) at the tail of the prompt. Wrapped in %{%} + # so zsh counts it as zero width. Best-effort: a prompt that fully rebuilds PS1 + # without re-running this loses B, but A/C/D/E/P still come from the hooks. + PS1="${PS1}%{"$'\e]633;B\a'"%}" +fi diff --git a/vscode-ext/package.json b/vscode-ext/package.json index b0ddf010..e28b39c3 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -101,7 +101,7 @@ "scripts": { "postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true", "build:frontend": "vite build --config vite.config.ts", - "build": "pnpm stage:dor-cli && esbuild src/extension.ts --bundle --outdir=dist --external:vscode --external:node-pty --format=cjs --platform=node && esbuild src/pty-host.js --bundle --outfile=dist/pty-host.js --external:node-pty --format=cjs --platform=node && cp -RL node_modules/node-pty dist/node-pty", + "build": "pnpm stage:dor-cli && esbuild src/extension.ts --bundle --outdir=dist --external:vscode --external:node-pty --format=cjs --platform=node && esbuild src/pty-host.js --bundle --outfile=dist/pty-host.js --external:node-pty --format=cjs --platform=node && cp -RL node_modules/node-pty dist/node-pty && cp -RL ../standalone/sidecar/shell-integration dist/shell-integration", "stage:dor-cli": "pnpm --filter dor build && node ../scripts/stage-dor-cli.mjs vscode-ext/dor-cli", "watch": "pnpm build --watch", "package": "vsce package --no-dependencies --out dormouse.vsix", diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index ae3f49c9..4506ec8b 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -140,6 +140,9 @@ function getDorRuntimeEnv(extensionPath: string): Record { DORMOUSE_NODE: resolveNodeBinary(), DORMOUSE_CLI_BIN: path.join(dorCliRoot, 'bin'), DORMOUSE_CLI_JS: path.join(dorCliRoot, 'dist', 'dor.js'), + // OSC 633 shell-integration scripts, copied next to the bundled pty-host by + // the build (see package.json `build`). Mirrors how DORMOUSE_CLI_BIN is set. + DORMOUSE_SHELL_INTEGRATION_DIR: path.join(extensionPath, 'dist', 'shell-integration'), DORMOUSE_CONTROL_SOCKET: dorControlSocket, DORMOUSE_CONTROL_TOKEN: dorControlToken, }; From b72e8cb581cc5390677412dc38665895ac31d8b8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 11 Jun 2026 10:37:22 -0700 Subject: [PATCH 2/4] Inject bash OSC 633 shell integration via --init-file Bash gets the same authoritative command tracking as zsh: real exit codes, command boundaries, and cwd, with the keystroke heuristic as the automatic fallback when injection can't apply. - shell-integration/bash/shellIntegration.bash: bash 3.2-safe hooks (DEBUG trap for preexec, string PROMPT_COMMAND for precmd). Since --init-file conflicts with -l, the script replicates login-profile startup (/etc/profile + first of .bash_profile/.bash_login/.profile) before installing OSC 633 hooks. Disarms across PROMPT_COMMAND so the trap doesn't trip on the prompt itself or the user's PROMPT_COMMAND. - pty-core.js: applyShellIntegration injects ['--init-file', script] for bash when no explicit args are present, dropping -l; fail-safe to today's behavior if the script is missing. - pty-core.js: detectUnixShells surfaces $SHELL plus common shells that exist on disk (de-duped by basename) so the macOS picker can offer bash even when the login shell is zsh. - vscode-ext/package.json: rm -rf dist/shell-integration before cp to avoid the cp-nesting bug on rebuild. - docs + tests updated; vsix packaging verified at the flat path. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/terminal-escapes.md | 2 +- standalone/sidecar/pty-core.js | 61 +++++++++++--- standalone/sidecar/pty-core.test.js | 81 ++++++++++++++++++- .../bash/shellIntegration.bash | 76 +++++++++++++++++ vscode-ext/package.json | 2 +- 5 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 standalone/sidecar/shell-integration/bash/shellIntegration.bash diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index a99e23f9..5a950875 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -135,7 +135,7 @@ A binary on `PATH` only has to be **found**, so it injects via one env var (`DOR | Shell | Mechanism | Channel | Notes | |---|---|---|---| | zsh | `ZDOTDIR` → our dotfiles chain to the user's, then install `precmd`/`preexec` hooks | env (as reliable as the `PATH` prepend) | User's real `ZDOTDIR` is passed through as `USER_ZDOTDIR`; our `.zshrc` hands `ZDOTDIR` back so `.zlogin` and child shells are unaffected. | -| bash | `--rcfile`/`--init-file`; the script re-sources the user's rc and replicates login-profile loading | shellArgs | `--rcfile` conflicts with login mode, so the script must replicate what `-l` would have sourced. (not yet implemented) | +| bash | `--init-file` → our script replicates login-profile sourcing, then installs a `DEBUG`-trap / `PROMPT_COMMAND` hook | shellArgs | `--init-file` and login mode are mutually exclusive, so Dormouse drops `-l` and the script sources `/etc/profile` + the user's profile itself. Written for bash 3.2 (macOS system bash): no `PS0`, no array `PROMPT_COMMAND`. The `E` command line is the first simple command of a pipeline (a `DEBUG`-trap limitation); boundaries and exit codes stay exact. | | fish | `XDG_DATA_DIRS` → fish auto-sources `*/fish/vendor_conf.d/*.fish` | env | (not yet implemented) | | PowerShell | `-NoExit -Command ` | shellArgs | (not yet implemented) | | cmd.exe | no per-command hook exists | — | Never gets real OSC 633; always uses the keystroke fallback below. | diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 11e28a39..d7a73179 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -90,10 +90,14 @@ function resolveShellIntegrationDir(env, runtime = {}) { // heuristic remains the fallback for shells we can't inject (cmd.exe, others) // or when the scripts aren't present on disk. See docs/specs/terminal-escapes.md. // -// zsh — injected purely via env (`ZDOTDIR`), as reliable as a PATH prepend. -// We point ZDOTDIR at our scripts and pass the user's real ZDOTDIR through -// `USER_ZDOTDIR`; our dotfiles chain to the user's then install the hooks. -function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = {}) { +// zsh — injected purely via env (`ZDOTDIR`), as reliable as a PATH prepend. We +// point ZDOTDIR at our scripts and pass the user's real ZDOTDIR through +// `USER_ZDOTDIR`; our dotfiles chain to the user's then install the hooks. +// bash — injected via `--init-file`, which has no env equivalent. Because that +// flag and login mode are mutually exclusive, we drop the login flag and +// the script replicates login-profile sourcing itself. Skipped when the +// caller passed explicit args, since we'd be replacing them. +function applyShellIntegration(shell, env, shellArgs, integrationDir, hasExplicitArgs, runtime = {}) { const fsModule = runtime.fsModule || fs; const shellName = path.posix.basename(shell || '').toLowerCase(); @@ -107,6 +111,13 @@ function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = } } + if (shellName === 'bash' && !hasExplicitArgs) { + const script = path.join(integrationDir, 'bash', 'shellIntegration.bash'); + if (fileExists(script, fsModule)) { + return { env, shellArgs: ['--init-file', script] }; + } + } + return { env, shellArgs }; } @@ -140,7 +151,8 @@ function resolveSpawnConfig(options, runtime = {}) { LC_TERMINAL_VERSION: ITERM2_COMPAT_VERSION, DORMOUSE_SURFACE_ID: surfaceId || options?.id || '', }; - const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, runtime); + const hasExplicitArgs = Boolean(explicitArgs && explicitArgs.length > 0); + const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, hasExplicitArgs, runtime); return { cols, @@ -275,17 +287,44 @@ function detectWindowsShells(runtime = {}) { return shells; } +// Well-known interactive shells we offer in the picker on macOS/Linux when they +// exist on disk, in addition to the user's $SHELL. Listed by preference; the +// first entry of each basename wins (so $SHELL, added first, keeps its slot). +const COMMON_UNIX_SHELLS = [ + '/bin/zsh', + '/bin/bash', + '/opt/homebrew/bin/bash', '/usr/local/bin/bash', + '/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish', + '/opt/homebrew/bin/zsh', '/usr/local/bin/zsh', + '/bin/sh', +]; + +function detectUnixShells(runtime = {}) { + const env = runtime.env || process.env; + const fsModule = runtime.fsModule || fs; + const seenByName = new Set(); + const shells = []; + const add = (shellPath, trusted) => { + if (!shellPath) return; + const name = path.posix.basename(shellPath); + // De-dupe by name so the picker shows one entry per shell, $SHELL winning. + if (seenByName.has(name)) return; + if (!trusted && !fileExists(shellPath, fsModule)) return; + seenByName.add(name); + shells.push({ name, path: shellPath, args: [] }); + }; + + add(env.SHELL || '/bin/sh', true); // user's default, always first + for (const candidate of COMMON_UNIX_SHELLS) add(candidate, false); + return shells; +} + function detectAvailableShells(runtime = {}) { const platform = runtime.platform || process.platform; if (platform === 'win32') { return detectWindowsShells(runtime); } - - // macOS / Linux: return $SHELL or /bin/sh - const env = runtime.env || process.env; - const shellPath = env.SHELL || '/bin/sh'; - const name = path.posix.basename(shellPath); - return [{ name, path: shellPath, args: [] }]; + return detectUnixShells(runtime); } module.exports.detectAvailableShells = detectAvailableShells; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 9544ba21..4430783d 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -356,7 +356,8 @@ test('resolveSpawnConfig honors non-empty explicit args (e.g. WSL distro flags)' // Pretend the shipped integration scripts exist on disk. const integrationFsModule = { statSync(filePath) { - if (String(filePath).endsWith('.zshrc')) return { isFile: () => true }; + const p = String(filePath); + if (p.endsWith('.zshrc') || p.endsWith('shellIntegration.bash')) return { isFile: () => true }; throw new Error(`ENOENT: ${filePath}`); }, }; @@ -398,7 +399,7 @@ test('resolveSpawnConfig zsh integration falls back to HOME when the user has no assert.equal(config.env.USER_ZDOTDIR, '/home/tester'); }); -test('resolveSpawnConfig leaves non-zsh shells untouched (keystroke fallback)', () => { +test('resolveSpawnConfig injects bash integration via --init-file and drops the login flag', () => { const config = resolveSpawnConfig(undefined, { platform: 'linux', env: { @@ -410,8 +411,61 @@ test('resolveSpawnConfig leaves non-zsh shells untouched (keystroke fallback)', fsModule: integrationFsModule, }); + assert.deepEqual(config.shellArgs, [ + '--init-file', + '/opt/dormouse/shell-integration/bash/shellIntegration.bash', + ]); + // bash injection is args-only; no zsh env leaks in. assert.equal(config.env.ZDOTDIR, undefined); - assert.equal(config.env.USER_ZDOTDIR, undefined); +}); + +test('resolveSpawnConfig leaves bash login args alone when the caller passed explicit args', () => { + const config = resolveSpawnConfig( + { args: ['-c', 'echo hi'] }, + { + platform: 'linux', + env: { + SHELL: '/bin/bash', + HOME: '/home/tester', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: integrationFsModule, + }, + ); + + assert.deepEqual(config.shellArgs, ['-c', 'echo hi']); +}); + +test('resolveSpawnConfig falls back to the bash login flag when the script is not present', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { + SHELL: '/bin/bash', + HOME: '/home/tester', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: { statSync() { throw new Error('ENOENT'); } }, + }); + + assert.deepEqual(config.shellArgs, ['-l']); +}); + +test('resolveSpawnConfig leaves other shells untouched (keystroke fallback)', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { + SHELL: '/bin/fish', + HOME: '/home/tester', + DORMOUSE_SHELL_INTEGRATION_DIR: '/opt/dormouse/shell-integration', + }, + osModule: { homedir: () => '/home/tester', tmpdir: () => '/tmp/fallback' }, + fsModule: integrationFsModule, + }); + + assert.equal(config.env.ZDOTDIR, undefined); + assert.deepEqual(config.shellArgs, ['-l']); }); test('resolveSpawnConfig skips zsh integration when the scripts are not present', () => { @@ -434,10 +488,14 @@ test('resolveSpawnConfig skips zsh integration when the scripts are not present' // ── detectAvailableShells ─────────────────────────────────────────────── +// No other common shells exist on disk → just $SHELL. +const noOtherShellsFsModule = { statSync() { throw new Error('ENOENT'); } }; + test('detectAvailableShells returns $SHELL on non-Windows', () => { const shells = detectAvailableShells({ platform: 'linux', env: { SHELL: '/bin/zsh' }, + fsModule: noOtherShellsFsModule, }); assert.deepEqual(shells, [{ name: 'zsh', path: '/bin/zsh', args: [] }]); @@ -447,11 +505,28 @@ test('detectAvailableShells falls back to /bin/sh when $SHELL is unset', () => { const shells = detectAvailableShells({ platform: 'darwin', env: {}, + fsModule: noOtherShellsFsModule, }); assert.deepEqual(shells, [{ name: 'sh', path: '/bin/sh', args: [] }]); }); +test('detectAvailableShells also offers common shells that exist on disk, $SHELL first', () => { + const present = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']); + const shells = detectAvailableShells({ + platform: 'darwin', + env: { SHELL: '/bin/zsh' }, + fsModule: { statSync(p) { if (present.has(String(p))) return { isFile: () => true }; throw new Error('ENOENT'); } }, + }); + + // $SHELL (zsh) leads; bash and sh follow; one entry per shell name. + assert.deepEqual(shells, [ + { name: 'zsh', path: '/bin/zsh', args: [] }, + { name: 'bash', path: '/bin/bash', args: [] }, + { name: 'sh', path: '/bin/sh', args: [] }, + ]); +}); + test('detectAvailableShells detects PowerShell and cmd on Windows', () => { const existingFiles = new Set([ 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', diff --git a/standalone/sidecar/shell-integration/bash/shellIntegration.bash b/standalone/sidecar/shell-integration/bash/shellIntegration.bash new file mode 100644 index 00000000..0abca619 --- /dev/null +++ b/standalone/sidecar/shell-integration/bash/shellIntegration.bash @@ -0,0 +1,76 @@ +# Dormouse bash shell integration (OSC 633). +# +# Delivered via `bash --init-file `, which bash reads — in place of +# ~/.bashrc — for an interactive NON-login shell. Dormouse normally spawns bash +# as a login shell (-l) so the user's profile (PATH, Homebrew/asdf) loads, but +# --init-file and login mode are mutually exclusive, so when injecting Dormouse +# drops -l and this script replicates login-profile startup first, then installs +# the OSC 633 prompt/command hooks. +# +# Written for bash 3.2 (the macOS system bash) and newer: a DEBUG trap for +# command-start and a string PROMPT_COMMAND for the prompt — no PS0 (4.4+) and no +# array PROMPT_COMMAND (5.1+). + +# --- Replicate login-shell startup (we are spawned without --login) ---------- +if [ -r /etc/profile ]; then . /etc/profile; fi +for __dormouse_profile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + if [ -r "$__dormouse_profile" ]; then . "$__dormouse_profile"; break; fi +done +unset __dormouse_profile + +# Only wire up hooks for an interactive shell, and only once. +case "$-" in *i*) ;; *) return 0 2>/dev/null || exit 0 ;; esac +if [ -n "${__dormouse_633_installed:-}" ]; then return 0 2>/dev/null || exit 0; fi +__dormouse_633_installed=1 + +# Escape a value for OSC 633 transport: the parser splits the E command field on +# the first raw ';' then decodes \\ and \xNN, so backslash and semicolon must be +# escaped; newlines/CR are escaped to keep the sequence single-line. +__dormouse_633_escape() { + local value=$1 + value=${value//\\/\\\\} + value=${value//;/\\x3b} + value=${value//$'\n'/\\x0a} + value=${value//$'\r'/\\x0d} + printf '%s' "$value" +} + +__dormouse_633_armed= # set at the END of the prompt hook: "the next command is the user's" +__dormouse_633_ran= # a command actually executed since the last prompt +__dormouse_633_user_pc="$PROMPT_COMMAND" # preserve the user's PROMPT_COMMAND + +# precmd: runs via PROMPT_COMMAND just before each prompt. Reports the previous +# command's exit (D), the cwd (P), and the prompt start (A). Disarms first so its +# own commands — and the user's PROMPT_COMMAND — don't trip the preexec trap, and +# re-arms last so the trap fires for the next interactive command. +__dormouse_633_prompt() { + local exit_code=$? + __dormouse_633_armed= + if [ -n "$__dormouse_633_ran" ]; then printf '\033]633;D;%s\007' "$exit_code"; fi + __dormouse_633_ran= + printf '\033]633;P;Cwd=%s\007' "$PWD" + printf '\033]633;A\007' + if [ -n "$__dormouse_633_user_pc" ]; then + ( exit "$exit_code" ) # restore $? for the user's PROMPT_COMMAND + eval "$__dormouse_633_user_pc" + fi + __dormouse_633_armed=1 +} + +# preexec: the DEBUG trap fires before every command; emit E/C once per line. +__dormouse_633_preexec() { + [ "$BASH_COMMAND" = "__dormouse_633_prompt" ] && return # the PROMPT_COMMAND invocation itself + [ -z "$__dormouse_633_armed" ] && return # inside PROMPT_COMMAND, or already fired this line + [ -n "${COMP_LINE:-}" ] && return # tab-completion, not a submitted command + __dormouse_633_armed= + __dormouse_633_ran=1 + printf '\033]633;E;%s\007' "$(__dormouse_633_escape "$BASH_COMMAND")" + printf '\033]633;C\007' +} + +trap '__dormouse_633_preexec' DEBUG +PROMPT_COMMAND='__dormouse_633_prompt' +# Prompt-end / input-start (B) at the tail of PS1, wrapped in \[ \] so bash counts +# it as zero width. Best-effort: a prompt rebuilt every render loses B, but +# A/C/D/E/P still come from the hooks. +PS1="${PS1}\[\033]633;B\007\]" diff --git a/vscode-ext/package.json b/vscode-ext/package.json index e28b39c3..e9bb77f8 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -101,7 +101,7 @@ "scripts": { "postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true", "build:frontend": "vite build --config vite.config.ts", - "build": "pnpm stage:dor-cli && esbuild src/extension.ts --bundle --outdir=dist --external:vscode --external:node-pty --format=cjs --platform=node && esbuild src/pty-host.js --bundle --outfile=dist/pty-host.js --external:node-pty --format=cjs --platform=node && cp -RL node_modules/node-pty dist/node-pty && cp -RL ../standalone/sidecar/shell-integration dist/shell-integration", + "build": "pnpm stage:dor-cli && esbuild src/extension.ts --bundle --outdir=dist --external:vscode --external:node-pty --format=cjs --platform=node && esbuild src/pty-host.js --bundle --outfile=dist/pty-host.js --external:node-pty --format=cjs --platform=node && cp -RL node_modules/node-pty dist/node-pty && rm -rf dist/shell-integration && cp -RL ../standalone/sidecar/shell-integration dist/shell-integration", "stage:dor-cli": "pnpm --filter dor build && node ../scripts/stage-dor-cli.mjs vscode-ext/dor-cli", "watch": "pnpm build --watch", "package": "vsce package --no-dependencies --out dormouse.vsix", From cdf563d616242e1dee4f3130c2fcfccf13cdb8d0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 11 Jun 2026 10:49:38 -0700 Subject: [PATCH 3/4] Carry last-command-failed as structured header state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane header recovered "did the last command fail?" by string-matching the fail glyph back off the rendered title (displayTitle.endsWith(' ✗')), undoing a transform terminal-state.ts had just applied. That round-trip is fragile: a user-renamed title ending in ✗ would be mis-detected and have its glyph stripped. deriveHeader now returns lastCommandFailed as a structured flag (headerPrimary returns { text, failed }), and the header colors/strips the glyph off the flag instead of inferring it from the string. primary still carries the glyph, so the other title consumers (Baseboard/Wall/MobileWall) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/wall/TerminalPaneHeader.tsx | 12 ++++++----- lib/src/lib/terminal-state.test.ts | 1 + lib/src/lib/terminal-state.ts | 21 ++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 6549c348..554404e0 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -104,11 +104,13 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const derivedHeader = deriveHeader(paneState, visiblePaneStates, { appTitleForPane }); const displayTitle = resolveDisplayPrimary(derivedHeader.primary, api.title); // The failure glyph rides at the end of the title string (so tabs/OS titles - // carry it too); split it off here to color it red, and strip it from the - // base used in editing/rename contexts. - const failGlyphSuffix = ` ${COMMAND_FAIL_GLYPH}`; - const showsFailGlyph = displayTitle.endsWith(failGlyphSuffix); - const displayTitleBase = showsFailGlyph ? displayTitle.slice(0, -failGlyphSuffix.length) : displayTitle; + // carry it too). `lastCommandFailed` tells us authoritatively that it's there, + // so we can color it red and strip it from the editing/rename base without + // guessing from the string (a user title ending in "✗" would fool a match). + const showsFailGlyph = derivedHeader.lastCommandFailed === true; + const displayTitleBase = showsFailGlyph + ? displayTitle.slice(0, -` ${COMMAND_FAIL_GLYPH}`.length) + : displayTitle; const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; const showMouseIcon = mouseState.mouseReporting !== 'none'; const inOverride = mouseState.override !== 'off'; diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index b49966c6..583eb7b1 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -188,6 +188,7 @@ describe('terminal command state reducer', () => { expect(deriveHeader(state, [state])).toEqual({ primary: `${DEFAULT_IDLE_TITLE} pnpm build ${COMMAND_FAIL_GLYPH}`, + lastCommandFailed: true, }); // The marker persists across the next prompt, until a new command runs. diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index f117389c..b15cefa3 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -97,6 +97,10 @@ export interface HeaderOptions extends DirectoryDisplayOptions { export interface DerivedHeader { primary: string; secondary?: string; + // True when `primary` ends with the fail glyph because the last command + // exited non-zero. The header uses this to color the glyph red without having + // to re-parse it back out of the title string. + lastCommandFailed?: boolean; } export type TerminalGroupingMode = 'none' | 'directory' | 'command' | 'status'; @@ -398,7 +402,7 @@ export function deriveHeader( options: HeaderOptions = {}, ): DerivedHeader { const primary = headerPrimary(pane, options); - const samePrimary = visiblePanes.filter((candidate) => headerPrimary(candidate, options) === primary); + const samePrimary = visiblePanes.filter((candidate) => headerPrimary(candidate, options).text === primary.text); const cwd = cwdForHeader(pane); let secondary: string | undefined; @@ -411,7 +415,7 @@ export function deriveHeader( } } - return { primary, secondary }; + return { primary: primary.text, secondary, lastCommandFailed: primary.failed || undefined }; } export function notificationDisplayTitle( @@ -771,15 +775,18 @@ function truncateCommandTitle(title: string): string { return `${Array.from(title).slice(0, COMMAND_TITLE_LIMIT - 3).join('').trimEnd()}...`; } -function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string { +// Returns the title text plus whether it carries the fail glyph, so callers can +// color the glyph without inferring its presence by matching the string. +function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): { text: string; failed: boolean } { const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); - if (userTitle) return userTitle; - if (pane.currentCommand) return commandHeaderLabel(pane, pane.currentCommand, options); + if (userTitle) return { text: userTitle, failed: false }; + if (pane.currentCommand) return { text: commandHeaderLabel(pane, pane.currentCommand, options), failed: false }; if (pane.lastCommand) { const idle = `${DEFAULT_IDLE_TITLE} ${commandHeaderLabel(pane, pane.lastCommand, options)}`; - return lastCommandFailed(pane.lastCommand) ? `${idle} ${COMMAND_FAIL_GLYPH}` : idle; + const failed = lastCommandFailed(pane.lastCommand); + return { text: failed ? `${idle} ${COMMAND_FAIL_GLYPH}` : idle, failed }; } - return DEFAULT_IDLE_TITLE; + return { text: DEFAULT_IDLE_TITLE, failed: false }; } // A finished command "failed" only when we have a real non-zero exit code. The From 1b6350f404af8771d67f92d3519109393a1809ce Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 11 Jun 2026 11:16:34 -0700 Subject: [PATCH 4/4] Sync terminal-state spec with header fail-glyph and keystroke gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header Derivation was still documenting the pre-OSC-633 shape: - DerivedHeader now lists lastCommandFailed; prose describes the trailing ✗ glyph appended for a non-zero last exit code and why the header reads the structured flag rather than re-parsing the title string. - Replaced the "header stays peaceful / exit-code badges read pane.activity" text, which the ✗ glyph now contradicts. - Documented the per-pane keystroke-retirement invariant in Command Input Fallback: first real OSC boundary promotes the pane to OSC-driven, the keystrokeHeuristic flag keeps the fallback's own synthesized markers from self-retiring the emitting path. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/terminal-state.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 7386b81a..2b950de0 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -187,6 +187,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification channel candidates from other sources. - A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to ``. - Visible output that looks like a returned shell prompt always refreshes the learned prompt shape, but only synthesizes the idle prompt transition when `currentCommand.source === "user_input"`. This keeps shape learning available for all shells while scoping the finish/start synthesis to shells that do not emit command finish/start OSCs (OSC-tracked shells drive their own boundaries). +- **Per-pane keystroke retirement.** The keystroke fallback and real OSC 633/133 integration are mutually exclusive per pane. The first authentic OSC boundary a pane emits (`promptStart`/`promptEnd`/`commandFinish` always, or a `commandStart` whose source is an OSC boundary — not `user_input`) promotes the pane to **OSC-driven**, after which the keystroke path stops recording: `recordTerminalUserInput` early-returns and no further `user_input` `commandStart`/`commandLine` is synthesized, so injected shells never double-count. The synthesized prompt markers the fallback itself emits are passed with a `keystrokeHeuristic` flag so they do **not** trigger promotion — otherwise the fallback would retire the very path that emits them. The flag is per-pane runtime state, seeded fresh and cleared on pane reset/removal; it is not persisted. CWD precedence: @@ -203,10 +204,11 @@ Asynchronous process CWD query results are applied through PTY-id resolution, so type DerivedHeader = { primary: string; secondary?: string; + lastCommandFailed?: boolean; }; ``` -The header carries only the primary label and an optional secondary disambiguator. Activity state lives on `pane.activity` directly; consumers that need it (status grouping, exit-code badges) read it from there. +The header carries the primary label, an optional secondary disambiguator, and `lastCommandFailed` — a structured flag set when `primary` ends with the fail glyph (see below). Richer activity state still lives on `pane.activity` directly; consumers that need it (status grouping) read it from there. Header priority — first match wins: @@ -217,11 +219,13 @@ Header priority — first match wins: 3. After a command has finished (`currentCommand` is null and `lastCommand` is set): ` ${LAST_TITLE}`, where `LAST_TITLE` follows the same priority as the running case applied to `lastCommand`: - App-sent title override that was emitted between `lastCommand.startedAt` and `lastCommand.finishedAt`. The candidate is taken from `lastCommand.finalTerminalTitle` (snapshotted at finish) so a post-finish title event cannot overwrite it. - `lastCommand.displayCommand`. + + When the finished command exited non-zero, a trailing fail glyph (`✗`) is appended — ` ${LAST_TITLE} ✗` — and `lastCommandFailed` is set on the result. "Failed" requires a real non-zero `exitCode`: the keystroke fallback never records one, so it shows no glyph either way. The glyph rides in `primary` so plain-text title consumers (OS/tab titles) carry it, while the pane header reads `lastCommandFailed` to color it red without re-parsing the string. 4. Otherwise (no running command and no last command): ``. Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for the diagnostic popup but never become header/door labels. Older shell titles (terminal titles emitted before the current command started, or after the last command finished) remain fallback-only and do not replace `` or pollute `LAST_TITLE`. -` ${LAST_TITLE}` keeps the just-finished context visible so the user can see at a glance which program just exited. Exit code, output, and TODO notification are still surfaced via the alert/TODO machinery (`docs/specs/alert.md`); the header itself stays peaceful but informative. ` ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain ``. +` ${LAST_TITLE}` keeps the just-finished context visible so the user can see at a glance which program just exited. The header surfaces failure minimally — the trailing `✗` glyph for a non-zero exit, nothing more; output and TODO notification are still surfaced via the alert/TODO machinery (`docs/specs/alert.md`). ` ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain ``. Disambiguation: