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:
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,\xHHfor semicolons —\x3b— and control bytes), and633;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 understandsC/D/E/P;Cwdgets full per-command segmentation with command text for free; backscroll parses exactly that subset (and still prefers its own6973;cmd=<base64>mark when both are present).
Who emits the marks¶
The shell does, via integration snippets — the terminal only listens.
- zsh: hooks
precmd(emitDfor the previous command, thenA, prompt,B) andpreexec(emitC). - bash: no native
preexec, so integrations use aDEBUGtrap +PROMPT_COMMAND(or bash-preexec). This is where most bugs live. - fish:
fish_prompt/fish_preexec/fish_postexecevents — 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>andD;<status>, plus OSC 7 for the cwd. Thecmdline_urlparameter (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 —%3Bhides 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,osc2all defaulttrue; there's anosc633flag as well, used whenTERM_PROGRAM=vscode). Per prompt it sendsA;k=i;click_events=1, then a right-prompt region marked withP;k=r(kitty's prompt-kind extension — consumers must not treatPas unknown and bail),B, thenCandD;<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 (CUPto the prompt row +EL/EDclears + save/restore around the right prompt + dim inline autosuggestions). And nu reports Ctrl-C during a command asD;1, notD;130— its own exit-code convention, not a bug. Cancelling input at an empty prompt emits a bareD;0with no precedingC(drop those cycles). - kitty's snippets (
kitty.bash, zshkitty-integration) also attach the command line toC— but shell-quoted, not percent-encoded:C;cmdline=%q(printf %qin bash,print -f %qin 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 thekitty-shell-cwd://flavor. Its bashDstatuses 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