Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/specs/terminal-escapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;<exit>` command finish, `E;<commandline>`, `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 <dot-source>` | 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.
Expand Down
8 changes: 6 additions & 2 deletions docs/specs/terminal-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<idle>`.
- 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:

Expand All @@ -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:

Expand All @@ -217,11 +219,13 @@ Header priority — first match wins:
3. After a command has finished (`currentCommand` is null and `lastCommand` is set): `<idle> ${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 — `<idle> ${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): `<idle>`.

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 `<idle>` or pollute `LAST_TITLE`.

`<idle> ${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. `<idle> ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain `<idle>`.
`<idle> ${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`). `<idle> ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain `<idle>`.

Disambiguation:

Expand Down
18 changes: 15 additions & 3 deletions lib/src/components/wall/TerminalPaneHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import {
buildAppTitleResolver,
createTerminalPaneState,
COMMAND_FAIL_GLYPH,
deriveHeader,
resolveDisplayPrimary,
titleCandidatesForDisplay,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -205,7 +214,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
<input
data-renaming-input-for={api.id}
className="bg-transparent outline-none border-none text-inherit font-medium font-mono w-full min-w-0 p-0 m-0"
defaultValue={displayTitle}
defaultValue={displayTitleBase}
autoFocus
ref={(el) => el?.select()}
onKeyDown={(e) => {
Expand All @@ -231,7 +240,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
setTitleCandidatesRect(e.currentTarget.getBoundingClientRect());
}}
>
<span className="min-w-0 shrink truncate">{displayTitle}</span>
<span className="min-w-0 shrink truncate">{displayTitleBase}</span>
{showsFailGlyph && (
<span className="ml-1 shrink-0 text-error" aria-label="last command failed">{COMMAND_FAIL_GLYPH}</span>
)}
{derivedHeader.secondary && (
<span className="ml-1 min-w-0 shrink truncate opacity-70">{derivedHeader.secondary}</span>
)}
Expand Down Expand Up @@ -382,7 +394,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
<TitleCandidatesPopover
anchorRect={titleCandidatesRect}
candidates={titleCandidates}
currentTitle={displayTitle}
currentTitle={displayTitleBase}
onClose={closeTitleCandidates}
/>
)}
Expand Down
30 changes: 30 additions & 0 deletions lib/src/lib/terminal-state-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 % ');
Expand Down
42 changes: 40 additions & 2 deletions lib/src/lib/terminal-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,29 @@ const paneStates = new Map<string, TerminalPaneState>();
const promptSubmitStates = new Map<string, PromptSubmitState>();
const promptShapes = new Map<string, PromptShape>();
const promptOutputBuffers = new Map<string, string>();
// 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<string>();
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<string, TerminalPaneState> | null = null;

export function subscribeToTerminalPaneState(listener: () => void): () => void {
Expand Down Expand Up @@ -54,6 +76,7 @@ export function resetTerminalPaneState(id: string, initial?: Partial<TerminalPan
promptSubmitStates.delete(id);
promptShapes.delete(id);
promptOutputBuffers.delete(id);
oscDrivenPanes.delete(id);
paneStates.set(id, createTerminalPaneState(initial));
notifyTerminalPaneStateListeners();
}
Expand All @@ -62,6 +85,7 @@ export function removeTerminalPaneState(id: string): void {
promptSubmitStates.delete(id);
promptShapes.delete(id);
promptOutputBuffers.delete(id);
oscDrivenPanes.delete(id);
if (!paneStates.delete(id)) return;
notifyTerminalPaneStateListeners();
}
Expand All @@ -71,8 +95,17 @@ export function applyTerminalSemanticEventsByPtyId(ptyId: string, events: Termin
applyTerminalSemanticEvents(id, events);
}

export function applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void {
export function applyTerminalSemanticEvents(
id: string,
events: TerminalSemanticEvent[],
options?: { keystrokeHeuristic?: boolean },
): void {
if (events.length === 0) return;
// Real OSC boundaries (not the heuristic's own synthesized prompt markers)
// promote the pane to OSC-driven, retiring the keystroke fallback for it.
if (!options?.keystrokeHeuristic && !oscDrivenPanes.has(id) && events.some(isOscDrivenBoundary)) {
oscDrivenPanes.add(id);
}
if (events.some((event) => event.type === 'promptStart' || event.type === 'promptEnd' || event.type === 'commandStart')) {
promptSubmitStates.delete(id);
promptOutputBuffers.delete(id);
Expand All @@ -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;

Expand Down Expand Up @@ -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 });
}
}

Expand Down
Loading
Loading