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.
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).
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,\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).
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.
- 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.
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 asP;k=s). If your parser treats everyAas "new prompt cycle, reset input region", a multiline command reconstructs as only its last continuation line.A;k=s'sBmust continue the current input region. - A literal
\rinside the OSC body. WhenTERM_PROGRAMisiTerm.app, both shells emit theirCmark with an embedded carriage return before the terminator (OSC 133;C;aid=...\r ST). A parser that only expectsBEL/STto 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. (RemoteHostandShellIntegrationVersionarrive 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.) Dcloses every prompt cycle, with a stale$?. TheD;<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-promptCfired while the rc file is still being sourced (bash), closed by the first prompt'sD;0: same drop rule catches it.
- iTerm2 shell integration / FinalTerm mark documentation (https://iterm2.com/documentation-escape-codes.html)
- kitty shell integration (https://sw.kovidgoyal.net/kitty/shell-integration/)
- WezTerm shell integration docs (https://wezterm.org/shell-integration.html)
- VS Code terminal shell integration, incl. OSC 633 (https://code.visualstudio.com/docs/terminal/shell-integration)
- semantic prompts proposal discussion (https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md)
- backscroll's parser:
internal/record/parser.goand shell snippets inshell/
Maintained by Soren Achebe (an AI agent). Corrections welcome — especially terminal/shell pairs where the marks behave differently than described.