diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index b5f3967..5a95087 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 | `--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. | + +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/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 7386b81..2b950de 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: diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 90c614f..554404e 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,14 @@ 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). `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'; @@ -205,7 +214,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { el?.select()} onKeyDown={(e) => { @@ -231,7 +240,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { setTitleCandidatesRect(e.currentTarget.getBoundingClientRect()); }} > - {displayTitle} + {displayTitleBase} + {showsFailGlyph && ( + {COMMAND_FAIL_GLYPH} + )} {derivedHeader.secondary && ( {derivedHeader.secondary} )} @@ -382,7 +394,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 ede141f..5d2a112 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 a06f514..809a5dc 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 eed1897..583eb7b 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,46 @@ 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}`, + lastCommandFailed: true, + }); + + // 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 abfea28..b15cefa 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'; @@ -124,6 +128,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'; @@ -393,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; @@ -406,7 +415,7 @@ export function deriveHeader( } } - return { primary, secondary }; + return { primary: primary.text, secondary, lastCommandFailed: primary.failed || undefined }; } export function notificationDisplayTitle( @@ -766,12 +775,24 @@ 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 (pane.lastCommand) return `${DEFAULT_IDLE_TITLE} ${commandHeaderLabel(pane, pane.lastCommand, options)}`; - return DEFAULT_IDLE_TITLE; + 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)}`; + const failed = lastCommandFailed(pane.lastCommand); + return { text: failed ? `${idle} ${COMMAND_FAIL_GLYPH}` : idle, failed }; + } + return { text: DEFAULT_IDLE_TITLE, failed: false }; +} + +// 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 { diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index da3c919..d7a7317 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -73,9 +73,54 @@ 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. +// 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(); + + 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, + }; + } + } + + 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 }; +} + function resolveSpawnConfig(options, runtime = {}) { const { cols = 80, rows = 30, cwd, shell: explicitShell, args: explicitArgs, surfaceId } = options || {}; const env = { @@ -94,23 +139,29 @@ 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 hasExplicitArgs = Boolean(explicitArgs && explicitArgs.length > 0); + const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, hasExplicitArgs, 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, }; } @@ -236,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 1228bda..4430783 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -351,12 +351,151 @@ 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) { + const p = String(filePath); + if (p.endsWith('.zshrc') || p.endsWith('shellIntegration.bash')) 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 injects bash integration via --init-file and drops the login flag', () => { + 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.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); +}); + +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', () => { + 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 ─────────────────────────────────────────────── +// 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: [] }]); @@ -366,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 0000000..0abca61 --- /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/standalone/sidecar/shell-integration/zsh/.gitignore b/standalone/sidecar/shell-integration/zsh/.gitignore new file mode 100644 index 0000000..3da98ec --- /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 0000000..013c8a6 --- /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 0000000..63f9a5a --- /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 0000000..e76df6c --- /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 b0ddf01..e9bb77f 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 && 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", diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index ae3f49c..4506ec8 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, };