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.
Installing the snippet
Section titled “Installing the snippet”The integration is one source line at the end of your ~/.zshrc:
source /path/to/tmnl/shell-integration/tmnl.zshSource 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.
Other shells
Section titled “Other shells”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.
Disabling
Section titled “Disabling”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.
Verifying it works
Section titled “Verifying it works”The cheapest check is the env var tmnl sets in every child process:
echo $TERM_PROGRAMInside 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 four marks
Section titled “The four marks”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.
What tmnl tracks per session
Section titled “What tmnl tracks per session”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 | C→D 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_commandscaps at 100 entries (RECENT_COMMANDS_CAP).prompt_rowscaps at 200 entries (PROMPT_ROWS_CAP).
Older entries fall off the front; the runtime never grows unbounded on a long-lived session.
One sharp edge: zsh’s first D
Section titled “One sharp edge: zsh’s first D”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.)
The ✗N tab chip
Section titled “The ✗N tab chip”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:
shell_integration_active()— at least one OSC 133 mark has been seen.!command_running()— no liveCin flight.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.
⌘↑ / ⌘↓ — jump to prompt
Section titled “⌘↑ / ⌘↓ — jump to prompt”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.
How it lines up with what you see
Section titled “How it lines up with what you see”The reader thread computes each A mark’s absolute scrollback row as
scrollback_total + cursor.0 — total 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 belowfrom. None if no earlier prompt is on file.next_prompt_row(from)returns the smallest tracked row strictly abovefrom. 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.
When the chord does nothing
Section titled “When the chord does nothing”Two real misses:
- Snippet not installed.
prompt_rowsstays empty;prevtoasts"scrollback.prev_prompt: no earlier prompt tracked"to the log;nextsilently 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.
Other things the marks unlock
Section titled “Other things the marks unlock”The OSC 133 layer also drives features documented on their own pages:
- AI command completion (
⌘I/⌘K) consumesinput_anchor— the vt100 cursor sampled at theBmark — 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_commandsso the palette can fuzzy-match against your shell history. The reader thread extracts each line between theBcursor and theCcursor and pushes it throughrecord_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.
Robustness notes
Section titled “Robustness notes”- The
C/Dmarks come from zsh’sprecmd/preexechooks and are robust regardless of your prompt framework. - The
Bmark is appended toPS1. If a later line in your.zshrcreassignsPS1, theBmark is lost — hence “source last.” TheC/Dlifecycle survives that, but⌘IAI completion needsB, so source the snippet after your prompt framework. - tmnl sets
TERM_PROGRAM=tmnlin 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.
Where to go next
Section titled “Where to go next”- AI completion (offline) — the
⌘I/⌘Kfeatures that depend on theBmark for their prefix anchor. - Tabs, splits, and panes — where the
✗Nchip appears on the tab strip. - Scrollback dump — capturing scrollback after a long session; the jump-to-prompt chord is the navigation half of the same story.
- FEATURES.md — the shipped-feature inventory.
- The implementation lives in
src/osc133.rs(the scanner +State),src/shell/reader.rs(the reader thread feeds the OSC 133 scanner per byte),src/shell/scroll.rs(scroll_to_prev_prompt/scroll_to_next_prompt), and thescrollback.prev_prompt/scrollback.next_promptbuiltins insrc/command.rs.