Command Blocks with OSC 133: What the Shell Marks, and What the Terminal Will Not Guess

Part 9 of "Building an SSH Terminal". This one is MistTerm v1.2.38 (AGPL), not the v1.2.26 snapshot used in parts 1–8. The behavior is from src/terminal/command_block.rs, docs/tech/TERMINAL-BEHAVIOR.md, and section 6.5 of the user manual.

TL;DR

Scrollback is a grid of cells. It does not know where ls ended and the next prompt began. OSC 133, and VS Code's lookalike OSC 633, is the shell drawing those boundaries: prompt start, prompt end, an optional output start, and command finished plus an exit code. MistTerm v1.2.38 jumps to those blocks and copies them. It does not invent a block from Enter or from a line that looks like a prompt. A stock bash emits none of this until something in the shell rc prints the bytes.

Part 1 stops at the parser and the grid. After that, "the previous command" is still a thing a person sees, not a thing the emulator stores. The cells for a prompt, a typed line and a page of output sit in one scrollback. Selecting "just that ls" by hand is what you do when nobody marked the edges.

Guessing from Enter does not work

One way out is to guess. Treat every Enter as a boundary, or treat any short line that ends in $ as a prompt. That falls over quickly. A command can span lines. A prompt can end in something else, or contain a $ in the path. vim, less and htop take the alternate screen (CSI ?1049 h, the same mode from Part 1) and fill it with lines that are not commands at all.

MistTerm's rule in v1.2.38 is the opposite of a guess. If the primary screen has not shown an OSC 133 or OSC 633 mark, previous / next / copy do nothing to the viewport. They also do not get forwarded to the shell. A toast points at the manual. The right-click menu still lists the three actions, so the missing integration is visible instead of looking like a dead shortcut.

The four marks

OSC is the same family as a window title: ESC ], a payload, then BEL (0x07) or the string terminator ESC \. FinalTerm and iTerm2 shell integration use four letters. VS Code's shell integration uses OSC 633 with the same letters, which is why MistTerm accepts both.

MarkSequenceMeaning
AOSC 133 ; A or OSC 633 ; APrompt starts
BOSC 133 ; B or OSC 633 ; BPrompt ends. The command input starts
COSC 133 ; C or OSC 633 ; CCommand submitted, output starts. Optional
DOSC 133 ; D [; exit]Command finished. The first decimal is the exit code

Extra parameters after the letter, such as iTerm's cl= or aid=, are ignored. The first decimal on D is kept. Everything else in this family is not a block boundary: OSC 1337 (iTerm properties, including the usual current-directory report), OSC 133 ; P (where VS Code puts Cwd), and title OSC 0/2.

So a block here is a range of cells. It is not a record of the working directory, and v1.2.38 has no "run this block again" action. Jump and copy are the whole UI.

These sequences are not something $TERM negotiates. terminfo does not make bash print them. The shell prints them from PROMPT_COMMAND, from zsh precmd / preexec, or from the integration script iTerm2 or VS Code already installed. A remote account with an untouched ~/.bashrc produces zero marks, and the terminal cannot fetch them over SSH on its own.

How a mark becomes a block

Coordinates live on the primary grid and move when rows fall into scrollback. The alternate screen is not sampled. A command that enters vim or less is still one block, flagged fullscreen; the copy is the command on the primary grid, not the fullscreen frame.

A block is something you can land on only after C or D. A bare prompt (A, or A then B while you are still typing) is not a command, so "previous" does not stop on the empty prompt. Copy starts at B when B was seen, which leaves the prompt out, and ends at D. While the command is still running, the end is the cursor. The next A closes an open block without calling it finished.

Copy is capped so a long yes or a fat log does not get pulled onto the UI thread as one string. At most 4000 grid lines and 200000 characters, keeping the head. The suffixes are \n… [truncated] for the line cap and \n… [truncated, N chars omitted] for the character cap.

A column resize drops the stored coordinates. Integration stays "seen", but the old points are gone. Clearing history drops blocks that ended above the new top line.

Part 10's local log may read an exit code off the same D. That read is not a UI boundary. The block list and the JSONL line are two consumers.

With the terminal focused, the shortcuts are consumed before the key is written to the PTY. Windows and Linux use Ctrl+Shift. macOS uses ⌘⇧.

What has to be in the shell rc

If iTerm2 or VS Code shell integration is already enabled on that account, leave it. Otherwise the manual's shortest bash hook is this. D runs before the next prompt is drawn, while $? is still the previous command. A marks the new prompt. B is stuffed into PS1 so it lands after the prompt text. C is optional; A / B / D still form a block once the command finishes.

__mist_cmd_block() {
  local ec=$?
  printf '\e]133;D;%s\a' "$ec"
  printf '\e]133;A\a'
}
PROMPT_COMMAND="__mist_cmd_block${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
PS1="${PS1}"'\[\e]133;B\a\]'

zsh is the same letters: precmd prints D and A, preexec prints C, and PS1 carries B. Open a new tab so the shell actually reloads. The hook is ordinary shell code. Anyone who can edit the rc can remove it, and any program can print the same bytes. Same limit as the in-band marker in Part 6: fine for jumping and copying, useless as a security boundary.

Summary

FAQ

Does a terminal know where a command starts without shell integration?

Not reliably. Scrollback is a grid of cells. MistTerm v1.2.38 does not invent a command block from Enter or from a line that looks like a prompt. It waits for OSC 133 or OSC 633 marks from the shell.

What does OSC 133 A, B, C and D mean?

A is prompt start, B is prompt end (the command input starts), C is optional and means output has started, and D means the command finished. The first decimal on D is the exit code. OSC 633 uses the same letters.

Does MistTerm track the working directory from OSC 133?

No. v1.2.38 ignores OSC 1337, OSC 133 ; P, and extra parameters other than the exit code on D. A block is a range of cells on the primary grid, used to jump and to copy, not a cwd record and not a re-run button.

Related in this series

← Part 8 · Series index · Part 10 →