Skip to content

Inject OSC 633 shell integration for zsh and bash#133

Merged
nedtwigg merged 4 commits into
mainfrom
osc-633
Jun 11, 2026
Merged

Inject OSC 633 shell integration for zsh and bash#133
nedtwigg merged 4 commits into
mainfrom
osc-633

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

What

Makes shells emit real OSC 633 shell-integration sequences instead of relying on the keystroke heuristic. The terminal parser already consumed the full OSC 633 family (prompt/command boundaries A/B/C/D, command line E, cwd P) — this wires up the emit side per shell, giving authoritative command boundaries, real exit codes, and reliable cwd. The keystroke heuristic stays as the automatic fallback when injection can't apply.

This lands zsh and bash from the 5-shell plan; fish and PowerShell are follow-ups, and cmd.exe stays on keystrokes (no per-command hook exists).

Shell Mechanism Channel Status
zsh ZDOTDIR → our .zshrc chains to the user's env ✅ this PR
bash --init-file; script replicates login profile shellArgs ✅ this PR
fish XDG_DATA_DIRS vendor conf.d env follow-up
PowerShell -NoExit -Command dot-source shellArgs follow-up
cmd.exe none keystroke fallback

How

  • zsh (shell-integration/zsh/): ZDOTDIR points at our dir; .zshrc hands ZDOTDIR back to the user before sourcing their rc (so .zcompdump/.zsh_history land in their dir, not our read-only shipped one), then installs precmd/preexec hooks. Our precmd runs first so $? is the command's real exit code before user hooks (e.g. oh-my-zsh) clobber it.
  • bash (shell-integration/bash/shellIntegration.bash): injected via --init-file. Since that conflicts with -l, the script replicates login-profile startup (/etc/profile + first of .bash_profile/.bash_login/.profile) then installs OSC 633 hooks. Written for bash 3.2 (macOS system bash): DEBUG trap for preexec, string PROMPT_COMMAND for precmd. Disarms across the prompt hook so the trap doesn't trip on itself or the user's PROMPT_COMMAND.
  • Injection point (pty-core.js): applyShellIntegration sets the env var (zsh) or shellArgs (bash); fail-safe to today's behavior if the scripts are missing, and skipped when explicit args are present. detectUnixShells surfaces $SHELL plus common shells on disk so the picker can offer bash even when the login shell is zsh.
  • Keystroke gating (terminal-state-store.ts): once a pane sees a real OSC 633 boundary it's promoted to OSC-driven and the keystroke-synthesized commandStart path is suppressed, so we never double-count.
  • Exit-status UI: a finished non-zero command shows a red ✗ after the idle title. The fail state flows as a structured DerivedHeader.lastCommandFailed flag (not re-parsed off the title string).
  • VS Code packaging: scripts copied into dist/shell-integration; the extension passes DORMOUSE_SHELL_INTEGRATION_DIR. Verified the .vsix ships the scripts at the flat path.

Testing

  • pnpm test — 545 lib tests + 68 sidecar tests green; lib typecheck clean.
  • Real-shell e2e for both zsh and bash: confirmed genuine OSC 633;C/;D;<exit> (not source:'user_input'), correct exit codes, user profile/rc loads intact (PATH/Homebrew/asdf).
  • .vsix packaged and inspected: dist/shell-integration/{bash/shellIntegration.bash,zsh/.zshrc,…} present at the flat path.

🤖 Generated with Claude Code

nedtwigg and others added 3 commits May 30, 2026 16:17
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;<exit>/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
(<idle> 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1b6350f
Status: ✅  Deploy successful!
Preview URL: https://e52efb7a.mouseterm.pages.dev
Branch Preview URL: https://osc-633.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The injection logic is carefully built — the per-pane OSC-driven promotion gating (and the keystrokeHeuristic flag that keeps the fallback's own synthesized prompt markers from self-retiring the path that emits them) is exactly right, and the zsh ZDOTDIR hand-back / bash login-profile replication both look correct. One spec-sync gap:

docs/specs/terminal-state.md is now out of date with the header changes. This PR adds the glyph and a lastCommandFailed field, but the Header Derivation section still documents the old shape:

  • The DerivedHeader type block declares only { primary; secondary? } — no lastCommandFailed.
  • The prose says exit codes are surfaced through the alert machinery and "the header itself stays peaceful", and lists "exit-code badges" among consumers that "read it from [pane.activity]". The new trailing on primary for a non-zero lastCommand is the header now surfacing exit status directly, which contradicts that text.

terminal-state.md is the canonical spec for header derivation (it's the one AGENTS.md points to for terminal-state.ts / terminal-state-store.ts), and AGENTS.md asks that the spec be updated in the same change. The terminal-escapes.md update covers the OSC-633 emit side and the keystroke fallback well; the header/DerivedHeader change just needs the matching edit here. Worth also noting the per-pane keystroke-retirement invariant in this spec's Command Input Fallback section, since that's the store behavior this spec owns.

Not a correctness issue — the code and tests look sound (sidecar suite green locally; the store gating matches the new tests). Flagging the doc drift rather than blocking.

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) <noreply@anthropic.com>
@nedtwigg nedtwigg merged commit 27083db into main Jun 11, 2026
9 checks passed
@nedtwigg nedtwigg deleted the osc-633 branch June 11, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants