Skip to content

OSC 133 shell integration

A regular terminal sees a stream of bytes. Some of those bytes are the prompt, some are the command, some are the output. There’s no structural signal that separates them — the terminal is parsing characters into cells, not parsing shell semantics. Everything downstream of “scroll up” has to be best-effort.

tmnl’s shell mode adds a parser for one specific kind of marker: OSC 133, the “semantic prompt” escape sequences a cooperating shell can emit around its prompt + command lifecycle. With the shell-integration snippet installed, tmnl knows when a prompt is about to draw, where the command-line cursor sits, when a command starts running, when it finishes, and what exit code it returned.

This page covers the marks themselves, the per-session state tmnl folds them into, and the two user-facing affordances that ride on top: the ✗<n> tab chip for non-zero exits, and the ⌘↑ / ⌘↓ jump-to-prompt navigation that lets you walk scrollback prompt-by-prompt instead of line-by-line.

The integration is one source line at the end of your ~/.zshrc:

Terminal window
source /path/to/tmnl/shell-integration/tmnl.zsh

Source it after any prompt framework (oh-my-zsh, powerlevel10k, starship) so the B mark — appended to PS1 — survives the framework’s own PS1 rewrites. Open a new shell tab to pick it up.

The snippet is safe to source unconditionally. Outside tmnl, every modern terminal ignores unknown OSC codes — the bytes are inert.

Only a zsh snippet ships today. bash (via PROMPT_COMMAND + a DEBUG trap) and fish (via fish_prompt / fish_preexec events) emit the same OSC 133 sequences and would be parsed identically; they just aren’t written yet.

The OSC 133 marks are shell-agnostic — any shell that can emit four small escape sequences around its prompt + command lifecycle gets exactly the same treatment from tmnl’s parser. A zsh snippet is the only one packaged today; bash and fish are roll-your-own until someone PRs a snippet.

The four sequences to emit:

| Sequence | When to emit | Meaning | | --- | --- | --- | | OSC 133 ; A ST | before each prompt draws | prompt start | | OSC 133 ; B ST | end of the prompt string | command-line start | | OSC 133 ; C ST | after the user hits Enter | command starts running | | OSC 133 ; D ; <exit> ST | before the next prompt | command finished, exit code <exit> |

ST is either BEL (0x07) or ESC \ — both are accepted. The <exit> payload on D is the previous command’s exit code as a decimal integer.

OSC 133 integration for bash is roll-your-own today — the four sequences above are everything tmnl’s parser needs. The usual shape is a PROMPT_COMMAND hook that emits A + records the previous $? into D, a PS1 suffix that emits B, and a DEBUG trap that emits C just before the command runs. A PR adding a bash snippet to shell-integration/tmnl.bash is welcome.

Same shape for fish — roll-your-own today. The natural hook points are fish_prompt (emit A; the prompt itself ends with B), fish_preexec (emit C), and fish_postexec (emit D;$status). A PR adding shell-integration/tmnl.fish is welcome.

If you want to turn the integration off, don’t source the snippet — that’s it. Tmnl’s OSC 133 layer is passive: with no marks coming in, shell_integration_active() stays false, the ✗N chip never appears, and ⌘↑ / ⌘↓ toast the “no earlier prompt tracked” message. Nothing else in tmnl regresses.

If you want to source the snippet conditionally — say, leave it sourced in .zshrc but skip it for a specific session — unset MNML_CONTEXT (or override it to an empty string) before sourcing. The snippet’s header checks for an active tmnl context and no-ops outside it.

The cheapest check is the env var tmnl sets in every child process:

Terminal window
echo $TERM_PROGRAM

Inside a tmnl session this prints tmnl. Outside (a plain Terminal.app tab, an iTerm session, a SSH session that didn’t forward the var) it prints whatever that terminal advertises, or nothing at all.

If TERM_PROGRAM=tmnl but the ✗N chip never appears after false, the snippet isn’t sourced (or got sourced before your prompt framework clobbered PS1). Re-source it at the very end of .zshrc and open a new tab.

The snippet emits four marks around your prompt and commands:

| Mark | Emitted | Meaning | | --- | --- | --- | | A | before each prompt | a fresh prompt is about to draw | | B | end of the prompt (PS1) | command input begins here | | C | after Enter | command submitted; output begins | | D[;N] | before the next prompt | the previous command finished; N is its exit code |

Each mark is an OSC sequence: ESC ] 133 ; <body> ST, where ST is BEL (0x07) or ESC \. tmnl’s osc133::Scanner extracts them from the raw pty stream before the bytes reach the vt100 parser — vt100 neither interprets nor needs them, so this layer is purely additive.

Each shell session owns an osc133::State updated by the reader thread. Folding the four marks gives:

| Field | Set by | Read by | | --- | --- | --- | | active: bool | any mark | “is the snippet installed?” gates downstream features | | running: bool | C → true, D → false | tab chip thinking-indicator, ps-polling skip | | input_anchor: Option<(row, col)> | B (vt100 cursor sample) | AI command completion (⌘I / ⌘K prefix anchor) | | command_started_at / command_ended_at | C / D | run-time measurement | | last_exit_code: Option<i32> | D;N | ✗N chip in the tab label | | last_duration | CD delta | exposed but not surfaced in the UI yet | | finished_count: u64 | D after a matching C | exposed but not surfaced in the UI yet | | recent_commands: VecDeque<String> | C (snippet sends the line) | command palette Recents source | | prompt_rows: VecDeque<usize> | A (absolute scrollback row) | ⌘↑ / ⌘↓ jump-to-prompt |

Two ring buffers are bounded:

  • recent_commands caps at 100 entries (RECENT_COMMANDS_CAP).
  • prompt_rows caps at 200 entries (PROMPT_ROWS_CAP).

Older entries fall off the front; the runtime never grows unbounded on a long-lived session.

zsh’s precmd hook fires the very first D;<n> mark before the user has typed anything — $? at that point is whatever the last line of .zshrc happened to leave behind (often a 1 from a missing alias). If tmnl recorded that, every fresh shell would open with a stale ✗1 chip.

The fold drops any D that wasn’t preceded by a matching C in the same session. Subsequent real commands follow the proper B → C → D ordering and update everything. (See osc133::tests::state_ignores_d_without_a_preceding_c for the regression test.)

When the most recent command exited non-zero and no command is currently running, the tab strip appends a small chip to the tab’s label:

[ ● zsh ✗130 ]

The chip clears as soon as the next command starts (C mark fires). Programs that don’t ship the OSC 133 snippet get no chip — the chip relies on the D;<n> payload, and bare D (no exit code) doesn’t touch last_exit_code.

The chip is appended by compute_pane_label in src/main.rs, gated on three conditions:

  1. shell_integration_active() — at least one OSC 133 mark has been seen.
  2. !command_running() — no live C in flight.
  3. last_exit_code().is_some_and(|c| c != 0) — a real non-zero code.

Exit codes are signed i32 — shells use values like 130 (SIGINT), 137 (SIGKILL), 127 (command not found) so even three-digit codes fit in the chip without truncation.

Once tmnl is tracking prompt-row positions in prompt_rows, scrolling prompt-by-prompt is a single chord:

| Chord | Palette id | What it does | | --- | --- | --- | | ⌘↑ | scrollback.prev_prompt | Scroll up to the prompt above the current viewport top. | | ⌘↓ | scrollback.next_prompt | Scroll down to the prompt below the current viewport top. Snap to the live tail past the newest tracked prompt. |

The chord mirrors iTerm’s ⌘↑ / ⌘↓ behaviour, so the muscle memory matches the established Mac convention.

The reader thread computes each A mark’s absolute scrollback row as scrollback_total + cursor.0total is the parser’s current scrollback length (the count of rows above the live screen); cursor.0 is the vt100 cursor row, which lives in live-screen coordinates, not viewport coordinates. Sum: a stable index into the entire history that doesn’t shift as you scroll.

Both lookup helpers take a from reference row and walk prompt_rows:

  • prev_prompt_row(from) returns the largest tracked row strictly below from. None if no earlier prompt is on file.
  • next_prompt_row(from) returns the smallest tracked row strictly above from. None when the caller is at or past the newest tracked prompt.

ShellSession::scroll_to_prev_prompt / scroll_to_next_prompt read the viewport top via the existing scrollback() seek trick (set scrollback to usize::MAX, read it back, restore), pass that to the lookup, and on a hit delegate to scroll_to_absolute_row. On a miss for next, the scroll snaps to live tail — “go forward in history” with no newer prompt means “go back to now,” which keeps the chord useful even when you’re already caught up.

Two real misses:

  • Snippet not installed. prompt_rows stays empty; prev toasts "scrollback.prev_prompt: no earlier prompt tracked" to the log; next silently no-ops (already at the tail).
  • Session too fresh. No prompts have rolled in yet — same outcome.

Same-row duplicate A marks (some shells fire back-to-back As during prompt refresh) collapse via record_prompt_row’s dedupe so the chord doesn’t stall on the same row twice.

The OSC 133 layer also drives features documented on their own pages:

  • AI command completion (⌘I / ⌘K) consumes input_anchor — the vt100 cursor sampled at the B mark — as the prefix anchor for the qwen2.5-coder model. Both rely on the snippet being installed. See AI completion.
  • Command palette Recents consumes recent_commands so the palette can fuzzy-match against your shell history. The reader thread extracts each line between the B cursor and the C cursor and pushes it through record_command (which drops empty / whitespace-only lines and collapses back-to-back duplicates).
  • The thinking-glyph indicator on the tab chip lights while running() is true, so a long-running command shows visibly without polling the process table.
  • The C / D marks come from zsh’s precmd / preexec hooks and are robust regardless of your prompt framework.
  • The B mark is appended to PS1. If a later line in your .zshrc reassigns PS1, the B mark is lost — hence “source last.” The C / D lifecycle survives that, but ⌘I AI completion needs B, so source the snippet after your prompt framework.
  • tmnl sets TERM_PROGRAM=tmnl in the child env so you can gate shell config on running inside tmnl.
  • OSC 133 bodies are tiny (A, D;130, …). The scanner caps bodies at 64 bytes — anything longer isn’t treated as a short OSC 133.