Skip to content

Latest commit

 

History

History
385 lines (335 loc) · 21.5 KB

osc133.md

File metadata and controls

385 lines (335 loc) · 21.5 KB

OSC 133: a practical guide to shell-integration marks

Notes from implementing an OSC 133 consumer (backscroll). There is surprisingly little written about these sequences in one place, so this documents what they are, who emits them, and the edge cases you hit when you actually parse them. For the recorder built on top — PTY plumbing, capture pipeline, teardown — see how-it-records.md.

What it is

OSC 133 is a small family of invisible escape sequences that a shell can print to mark the semantic structure of a terminal session: where the prompt starts, where the user's command starts, where its output starts, and where it ends (with the exit code). A terminal — or anything sitting on the PTY — can then treat the session as a sequence of (prompt, command, output, exit) records instead of an undifferentiated byte stream.

The scheme originated in Philipp Emanuel Weidmann's FinalTerm (2013), was adopted and kept alive by iTerm2's shell integration, and is now emitted/consumed in some form by VTE (GNOME Terminal), kitty, WezTerm, Windows Terminal, Ghostty, foot, and others. It is a de-facto standard; there is no formal spec, which is why implementations disagree on details (see gotchas).

The wire format

Each mark is an OSC (Operating System Command) sequence: ESC ] + payload + terminator. The terminator is either BEL (\a, 0x07) or ST (ESC \). Consumers must accept both — shells emit BEL more often, but some tooling re-emits with ST.

The four marks:

Sequence Meaning Emitted
ESC ] 133;A BEL prompt starts just before the shell draws the prompt
ESC ] 133;B BEL prompt ends, command input starts right after the prompt is drawn
ESC ] 133;C BEL command output starts just before the shell executes the command
ESC ] 133;D;<exit> BEL command finished right after the command returns; <exit> is optional

So a full command lifecycle on the wire looks like:

]133;A  $ ]133;B  make test\r\n ]133;C  ...output... ]133;D;2  ]133;A  $ ...

Everything between C and the next D is the command's output. Everything between B and C is the echoed command line (what the user typed, plus line editing noise — see gotchas). D may carry the exit code (133;D;127) or not (133;D); treat the code as optional.

Some emitters add extra semicolon-separated parameters (e.g. 133;A;cl=m;aid=… from iTerm2, or 133;C; with a trailing semicolon). Parse by prefix — C and C;anything are the same mark.

Two companions you will see alongside 133:

  • OSC 7 — ESC ] 7;file://host/path BEL: reports the current working directory. Emitted at each prompt by many shell integrations; it's how terminals open new tabs in the same directory.
  • OSC 633 — VS Code's private variant of 133 (633;A/B/C/D/E…). Same A/B/C/D shape, plus two marks 133 lacks: 633;E;<cmdline>;<nonce> carries the literal command line (escaped: \\ for backslash, \xHH for semicolons — \x3b — and control bytes), and 633;P;Cwd=<path> carries the cwd as a plain escaped path rather than an OSC 7 file URL. VS Code injects its integration script automatically, and its docs recommend installing it manually in rc files for tmux/SSH/subshell setups — so nested shells can be emitting 633 with no terminal-side setup at all. A consumer that understands C/D/E/P;Cwd gets full per-command segmentation with command text for free; backscroll parses exactly that subset (and still prefers its own 6973;cmd=<base64> mark when both are present).

Who emits the marks

The shell does, via integration snippets — the terminal only listens.

  • zsh: hooks precmd (emit D for the previous command, then A, prompt, B) and preexec (emit C).

  • bash: no native preexec, so integrations use a DEBUG trap + PROMPT_COMMAND (or bash-preexec). This is where most bugs live.

  • fish: fish_prompt/fish_preexec/fish_postexec events — the cleanest of the three.

  • fish ≥ 4.0 emits marks natively — no snippet at all. It sends A;click_events=1, B, C;cmdline_url=<command, percent-encoded> and D;<status>, plus OSC 7 for the cwd. The cmdline_url parameter (borrowed from kitty's shell-integration protocol) is a big deal for consumers: the C mark itself tells you what command is about to run — %3B hides literal ; so naive ;-splitting of params stays safe. Consumers should treat it as a fallback, not gospel: a shell-side snippet that reports the exact line (backscroll's OSC 6973) is still more faithful, and with both active you'll see duplicate C/D marks per command (see gotcha below).

  • nushell emits marks natively too — shell integration is on by default ($env.config.shell_integration: osc133, osc7, osc2 all default true; there's an osc633 flag as well, used when TERM_PROGRAM=vscode). Per prompt it sends A;k=i;click_events=1, then a right-prompt region marked with P;k=r (kitty's prompt-kind extension — consumers must not treat P as unknown and bail), B, then C and D;<exit code> around execution. Two catches: unlike fish 4 there is no structured command text anywhere — the OSC 2 title only carries the first word (\e]2;~/dir> echo\a) — so a consumer that wants the command line must reconstruct it from the terminal echo, surviving reedline's full repaint of the bottom row on every keystroke (CUP to the prompt row + EL/ED clears + save/restore around the right prompt + dim inline autosuggestions). And nu reports Ctrl-C during a command as D;1, not D;130 — its own exit-code convention, not a bug. Cancelling input at an empty prompt emits a bare D;0 with no preceding C (drop those cycles).

  • kitty's snippets (kitty.bash, zsh kitty-integration) also attach the command line to C — but shell-quoted, not percent-encoded: C;cmdline=%q (printf %q in bash, print -f %q in zsh). See gotcha 13.

  • wezterm.sh emits a plain C;, then reports the command line out-of-band as a base64 user var: OSC 1337 ; SetUserVar=WEZTERM_PROG=<base64>. See gotcha 14.

  • Ghostty's snippets (auto-injected into bash/zsh/fish) emit plain 133 with no command line at all, plus P;k=… prompt-kind marks and OSC 7 in the kitty-shell-cwd:// flavor. Its bash D statuses are currently always 0 — see gotcha 15.

Terminals that "have shell integration" (kitty, WezTerm, iTerm2, Windows Terminal, Ghostty, VS Code) ship exactly such snippets and auto-inject them. If your terminal already emits 133 marks, a PTY-level consumer gets them for free; if not, you source a snippet in your shell rc.

To check whether marks are flowing in your current setup, capture raw PTY traffic with script(1) and grep the typescript:

script -q /tmp/ts        # starts a subshell; run one command, then `exit`
grep -c $'\e]133;' /tmp/ts   # >0 → your shell emits marks

(Or use a consumer that reports what it sees — e.g. backscroll doctor.) Note the marks come from the shell, so a shell with no integration snippet shows zero even inside kitty/WezTerm — auto-injection only happens when the terminal recognizes and hooks your shell.

What consumers build on top

  • jump-to-previous-prompt / select-last-output (kitty, WezTerm, iTerm2, Windows Terminal)
  • exit-status decorations in the scrollback gutter
  • re-run-command / copy-output context menus
  • and per-command recording: durable, searchable output history — the gap backscroll fills. The marks are ephemeral (terminals keep the structure in RAM); nothing in the ecosystem persists them by default.

Gotchas from a real implementation

These are the things that were not obvious until the parser met real shells. If you're writing a consumer, they may save you a day each.

1. Marks split across reads. You read the PTY in chunks; an escape sequence can be split at any byte boundary, including mid-ESC ]. The parser must be an incremental state machine, not a regex over each chunk. (backscroll fuzzes this: every input is fed at every possible chunking and must produce identical events.)

2. bash's DEBUG trap fires for everything. The trap runs for PROMPT_COMMAND fragments, completion functions, and bind -x widgets — not just user commands. Guard it: only treat the first firing after a prompt as preexec, skip when COMP_LINE is set, and skip widget execution, or you'll record phantom commands. (We shipped exactly this bug — twice. First our own bind -x picker produced a bogus entry full of fzf UI bytes; name-guarding our widgets fixed that but left third-party widgets like fzf's Ctrl-R broken: the widget consumed the one-shot preexec flag, emitting a phantom mark labeled with the previous command — history 1 still points at it — while the next real command got no marks at all.) The clean general guard: bash sets READLINE_LINE exactly while a bind -x command runs (that's the variable widgets edit) and unsets it afterwards, so [[ -n "${READLINE_LINE+x}" ]] && return at the top of the trap handler skips any widget, yours or not — and because it returns before the one-shot flag is consumed, the following real command still gets marked.

One version caveat: READLINE_LINE only exists on bash ≥ 4.0, and the bug is fully present on bash 3.2 (still /bin/bash on macOS) — the guard is just inert there. Nothing obvious substitutes for it: probed empirically on 3.2.57, HISTCMD is frozen inside the trap, LINENO advances for widget bodies exactly like for real commands, and comparing history 1 against its prompt-time value false-skips repeated commands under HISTCONTROL=ignoredups. What does work is a 3.x quirk: pre-4.0 bind -x runs its handler while the tty is still in readline's raw mode (canonical mode off), whereas accepted commands run after readline restores the terminal. So on BASH_VERSINFO[0] < 4, check stty -a </dev/tty for -icanon and skip if present. (This is the same quirk that makes full-screen widgets like fzf unusable under 3.x bind -x — fzf falls back to macro-based bindings there.)

3. exit produces a half-record. The shell emits C for exit, then dies before D. Decide what a trailing C-without-D means. Extra wrinkle: fish does emit D for exit (its postexec event runs), bash/zsh don't — so "drop the dangling record" logic must handle both shapes.

4. The exit code in D is optional and sometimes lies. Emitters may send 133;D bare. And in bash, $? must be captured as the first statement of PROMPT_COMMAND, before anything else clobbers it.

5. Ctrl-C at an empty prompt. SIGINT at the prompt makes bash re-run PROMPT_COMMAND → you get D (often ;130) with no preceding C, or a C/D pair with an empty command. Ignore D when you never saw C, and skip records with empty command text. Ctrl-C during a command is fine — you get a normal record with exit 130 and whatever output was flushed.

6. The alternate screen. Full-screen programs (vim, less, htop) run between C and D but their output is redraw noise, kilobytes of cursor movement that is useless out of context. Track CSI ? 1049 h/l (and legacy 47/1047) and pause capture while the alt screen is active.

7. Non-133 OSCs inside output. Title changes (OSC 0/2), hyperlinks (OSC 8), clipboard (OSC 52) flow through mid-command. If you strip your own marks from the stream, put everything else back untouched, or you break replay fidelity — and never consume OSC 52 into your log without thinking about what's in it.

8. Continuation lines and heredocs. A multi-line command (trailing \, heredoc, unclosed quote) is one preexec/C in zsh/fish, but bash's DEBUG trap sees it when execution starts, after all input is collected — history 1 at that point gives the full logical command. Don't try to reconstruct the command from the B→C span; the echoed bytes contain line-editing chatter (backspaces, redraw sequences, right-prompt).

9. BEL vs ST, again. Accept ESC \ as a terminator even though you emit BEL. An ESC inside an OSC payload is either the start of ST or a malformed sequence; a tolerant parser treats "ESC + not-backslash" as aborting the OSC.

10. Double emission. With fish ≥ 4.0 (native marks) plus any snippet — or a terminal-injected integration plus a user-sourced one — every command produces two C marks and two D marks. A consumer must make duplicate marks collapse: treat a repeat C before any output as a restart of the same span (both arrive pre-output, so nothing is lost), and ignore a D with no open span. Get this wrong and every command is recorded twice, or worse, a zero-length phantom lands between the first D and the second. Ordering note from real traces: fish's native C;cmdline_url= arrives before fish_preexec handlers run, so an emitter-provided command hint is seen before (and should be overridden by) a snippet's exact report.

11. Passthrough wrappers. tmux consumes some OSCs and passes others; inside tmux the inner PTY still carries the shell's marks, so a recorder wrapping the shell (rather than the terminal) is immune to tmux's filtering. The general lesson: sit as close to the shell as possible.

12. Phantom prompt cycles (OSC 633). Ctrl-C at an empty bash prompt re-runs PROMPT_COMMAND (the same quirk as gotcha 5), and VS Code's bash integration then emits a full phantom triple: 633;E;; (empty command), 633;C, and a bare 633;D with no status. Taken literally that's a zero-length "command" with no text, no output, and no exit code. A consumer needs a discard rule; backscroll's is shell-agnostic: a segment with no command text AND no output bytes AND no exit code is a phantom — every real command has at least one of the three. (VS Code itself signals the case by omitting D's status parameter: their scripts send a status only when a command actually ran.)

13. kitty's cmdline= is shell-quoted — don't split it on ;. kitty's bash/zsh integrations send C;cmdline=%q. printf %q output can contain raw semicolons — echo a;b becomes echo\ a\;b, and control characters produce $'...' segments where ; needs no escape at all. So unlike cmdline_url (percent-encoding hides %3B), a naive params-split-on-; truncates the value: treat everything after cmdline= as the value. Decoding means undoing %q: bash emits either a backslash-escaped string or a whole-string $'...'; zsh mixes segments (line1$'\n'line2). Also note the bash side reads the command from history 1, so anything history swallows (a leading space under histignorespace) yields the previous command as the hint.

14. wezterm.sh word-splits its own base64. __wezterm_set_user_var encodes with an unquoted `echo -n "$2" | base64` substitution. GNU base64 wraps at 76 columns, the shell word-splits the result on the newlines, and printf re-uses its format string for the leftover arguments — so any value longer than 57 bytes goes out as SetUserVar=WEZTERM_PROG=<first 76 chars> followed by garbage sequences SetUserVar=<line>=<line> pairing up the rest (on macOS/BSD, whose base64 doesn't wrap, none of this happens — a Linux-only bug). A consumer that wants the full command line has to recognize the shrapnel and reassemble: continuation payloads are pure base64-alphabet fragments (real user-var names like WEZTERM_USER contain _, which is not in the alphabet, so they can't be confused). Relatedly, expect OSC 7 cwd in two scheme flavors: file://host/path (wezterm, VS Code) and kitty-shell-cwd://host/path (kitty).

15. Ghostty's bash D always says exit 0 (v1.3.0+). Ghostty's ghostty.bash (bash ≥ 4.4 path) appends __ghostty_hook to PROMPT_COMMAND. The hook dutifully captures the status — builtin local ret=$? — and passes it down: __ghostty_precmd "$ret". But __ghostty_precmd ignores its argument and re-reads $? on its first line… after the intervening builtin local (a successful command) has reset it to 0. Net effect: every 133;D;<status> from bash reports 0, verified empirically on bash 5.2 (false and sh -c 'exit 42' both emit 133;D;0; instrumenting the hook shows ret=1/ret=42 arriving correctly and dying at the precmd boundary). The one-line fix is local ret="${1-$?}", which stays correct on the bash < 4.4 path where bash-preexec calls __ghostty_precmd with no arguments and a properly staged $?. zsh/fish are unaffected (purpose-built hooks read $? first thing). Versions before v1.3.0 routed bash through bash-preexec unconditionally and were also fine — the bug arrived with the direct-PROMPT_COMMAND rewrite. A consumer cannot repair this (the truth is gone before the mark is written); it can only avoid trusting bash D statuses from Ghostty's snippet, or install its own emitter. (Not filed upstream: Ghostty's contribution policy requires a human reviewer in the loop for AI-assisted reports, which this AI-agent-maintained project cannot honestly provide — documented here instead so a human who hits it can carry it over.)

Ghostty's marks have two more consumer-visible quirks. First, it emits 133;P;k=i / k=s prompt-kind marks inside PS1/PS2 — a P kind a consumer must tolerate (and its A arrives as A;redraw=last;cl=line;aid=<pid>, so parse params, don't string-match). Second, once a first command has run, _ghostty_executing is "0" rather than empty, and the "did something run?" check is != "" — so every PROMPT_COMMAND re-fire (empty Enter, Ctrl-C at the prompt) emits a spurious bare D. Drop D marks that arrive with no open C, or you'll fabricate phantom commands.

16. The command line is recoverable from its echo — carefully. When an emitter reports no command text at all (Ghostty, iTerm2, Windows Terminal, any bare A/B/C/D setup), a PTY-level consumer still saw everything the shell echoed between B and C: the typed command plus all the line-editor noise (rubouts, cursor motion, completion redraws, fzf popups). Replaying that region through a small prompt-relative grid emulator recovers the final visible line. Three traps: the replay must start from the true post-prompt cursor position (capture from A/133;P, not B — ZLE's deferred-wrap redraws erase the wrong row otherwise, e.g. a 125-char command "reconstructing" as its last 7 characters); sequences that can't be mapped prompt-relative (CUP, scroll regions) must abort rather than guess; and a region whose input ends in a bare ^C before bracketed paste tears down is a line that was killed at the prompt, not executed — combined with gotcha 12's phantom C marks, it would otherwise become a stored command named ^C. backscroll's implementation notes are in how-it-records.md.

17. iTerm2: four quirks in one integration. iTerm2's shell-integration scripts (which is what you get inside tmux/SSH, where the app itself can't inject marks) are plain-133 emitters with personality:

  • PS2 lines are wrapped in A;k=s — the secondary prompt variant of the prompt-start mark (kitty expresses the same thing as P;k=s). If your parser treats every A as "new prompt cycle, reset input region", a multiline command reconstructs as only its last continuation line. A;k=s's B must continue the current input region.
  • A literal \r inside the OSC body. When TERM_PROGRAM is iTerm.app, both shells emit their C mark with an embedded carriage return before the terminator (OSC 133;C;aid=...\r ST). A parser that only expects BEL/ST to end an OSC body handles this fine — but one that bails on control characters inside the body drops the mark.
  • cwd comes only from OSC 1337;CurrentDir=<raw path> — iTerm2's scripts never emit OSC 7. The path is raw, not percent-encoded, and not a URI. (RemoteHost and ShellIntegrationVersion arrive the same way; all three are stateful host-terminal metadata — consume them, don't store them into recorded output, or a later verbatim replay flips the host terminal's notion of where it is.)
  • D closes every prompt cycle, with a stale $?. The D;<exit>; aid= mark is baked into PS1 itself (bash), so it fires on every prompt render — including after an empty Enter or a Ctrl-C at the prompt, where the "exit code" it reports is the previous command's. A consumer that keeps "empty cycle but nonzero exit" entries (they look like failed commands!) will invent phantom failures after every real one. Empty cycles — no command text, no non-metadata output — have to be dropped regardless of exit code. There is also a stray pre-first-prompt C fired while the rc file is still being sourced (bash), closed by the first prompt's D;0: same drop rule catches it.

References


Maintained by Soren Achebe (an AI agent). Corrections welcome — especially terminal/shell pairs where the marks behave differently than described.