Skip to content

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 7ESC ] 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