Section 2: The Minimal Terminal Emulator

Your Section 1 runner relays \x1b[31mred\x1b[0m faithfully and understands nothing. This section is where the bytes acquire meaning.

You will build a terminal core that consumes bytes from a PTY and maintains an in-memory terminal screen — a grid of cells with a cursor, styles, scrollback, modes, and an alternate buffer. It is a library (terminal-core, on top of terminal-protocol) with no I/O, no threads, and no rendering. That constraint is what makes it testable, embeddable, and — in Section 5 — reusable.

No existing terminal emulator core is used here. Not vte, not alacritty_terminal, not termwiz. They appear only in differential testing, after yours works.


The Deliberately Limited Starting Point

Version 1 supports exactly seven things. Resist adding an eighth until all seven are tested.

V1 featureBytes
Printable ASCII0x20–0x7e
Newline0x0a (LF)
Carriage return0x0d (CR)
Backspace0x08 (BS)
Tab0x09 (HT)
Cursor positionTracked, not yet settable by escape sequence
Fixed rows × columns, a 2-D cell grid, basic scrolling, screen clear, basic ANSI colorsCSI J, CSI m with 30–37/40–47

Then, incrementally, in roughly this order:

 UTF-8 decoding ──▶ CSI sequences ──▶ SGR styling ──▶ cursor movement ──▶ erase ops
        │
        ▼
 save/restore cursor ──▶ scroll regions ──▶ alternate screen ──▶ insert/delete ops
        │
        ▼
 wide characters ──▶ combining characters ──▶ OSC sequences ──▶ window titles
        │
        ▼
 hyperlinks ──▶ bracketed paste ──▶ mouse reporting ──▶ application cursor keys
        │
        ▼
 DEC private modes (the long tail)

Each step gets: the raw bytes, the parser recognition, the state transition, the screen before and after, a focused unit test, and a shell command that generates the sequence. All six. Every time.


The Chapters

ChapterWhat it covers
Separation of ConcernsThe eight distinct jobs people conflate into "the terminal"
The Parser State MachineThe VT500 state machine: states, transitions, actions
UTF-8 and GraphemesDecoding, where it sits relative to the parser, widths, combining marks
The Screen ModelGrid, Cell, Line, cursor, pending wrap, scrollback, resize
The CSI CatalogEvery CSI sequence you implement, with bytes, semantics, tests, and generators
SGR and ColorAttributes, 16/256/truecolor, the colon-subparameter form
ModesANSI modes and DEC private modes, including the alternate screen
OSC and String SequencesTitles, hyperlinks, clipboard, semantic prompts, DCS/APC

The Labs

LabMilestoneBuild
Lab 6M4terminal-protocol: the state machine, tested byte-at-a-time
Lab 7M5terminal-core: grid, cursor, scrolling, erase, pending wrap
Lab 8M6terminal-cli: deterministic snapshots, golden tests, PTY integration tests
Lab 9—UTF-8, wide characters, combining marks, grapheme clusters
Lab 10—DECSTBM, alternate screen, insert/delete, save/restore
Lab 11—OSC, DEC private modes, mouse reporting, bracketed paste

The Architecture You Are Building

   bytes from the PTY master
        │
        ▼
 ┌────────────────────────────────────────────────────────────────┐
 │  terminal-protocol                                             │
 │  ┌──────────────────┐      ┌────────────────────────────────┐  │
 │  │ VT state machine │─────▶│  Perform trait (your callback) │  │
 │  │ ground/esc/csi/  │      │   print(char)                  │  │
 │  │ osc/dcs/...      │      │   execute(byte)                │  │
 │  │  + UTF-8 decoder │      │   csi_dispatch(params, .., 'm')│  │
 │  │    inside GROUND │      │   esc_dispatch / osc_dispatch  │  │
 │  └──────────────────┘      │   hook / put / unhook  (DCS)   │  │
 │      NO screen. NO I/O.    └────────────────────────────────┘  │
 └────────────────────────────────────────────────────────────────┘
        │  parsed actions
        ▼
 ┌────────────────────────────────────────────────────────────────┐
 │  terminal-core                                                 │
 │    Terminal { screen, cursor, modes, tabs, title, damage }     │
 │      impl Perform for Terminal  ← the meaning lives HERE       │
 │    Screen { primary: Grid, alternate: Grid, scrollback }       │
 │    Grid   { rows, cols, lines }                                │
 │    Cursor { row, col, style, pending_wrap, saved }             │
 │    replies: Vec<u8>   ← answers to DSR/DA, for the CALLER to   │
 │                          write back to the PTY                 │
 │      NO I/O. NO fonts. NO threads.                             │
 └────────────────────────────────────────────────────────────────┘
        │  screen state + damage
        ▼
   a snapshot (text, JSON) or a renderer  ← Section 3

The two boundaries that matter:

  1. The parser does not know what sequences mean. It knows CSI with params [2] and final byte J. That 2 means "erase the whole screen" is terminal-core's knowledge. Keeping them separate is what lets you fuzz the parser, reuse it, and test it without a screen.
  2. The core does no I/O. Terminal replies (the answer to CSI 6n) are appended to a buffer the caller drains and writes to the PTY. The core never touches a file descriptor.

Why That Separation Matters (The Concrete Payoff)

Because the parser has no screenBecause the core has no I/O
You can fuzz it with cargo-fuzz and assert only "does not panic"You can run a full terminal in a unit test with no PTY, no shell, no OS
You can reuse it for a log colorizer or an asciinema rendererYou can run it in WebAssembly
You can test "these bytes produce these actions" with no gridGolden tests are bytes → snapshot, fully deterministic
Its state is tiny and bounded, so a fuzz corpus covers itThe mux server can host 50 of them with no display

Deliverables for This Section

  • terminal-protocol implements the full VT state machine; feeding input byte-at-a-time produces an identical action stream to feeding it all at once.
  • A fuzz target for the parser that has run ≥10 minutes with no panic and no hang.
  • terminal-core implements every sequence in the CSI catalog, each with a unit test that shows the screen before and after.
  • Pending wrap (deferred DECAWM) implemented and tested explicitly.
  • Alternate screen, scroll regions, insert/delete, and save/restore cursor.
  • UTF-8, wide characters, and combining marks handled, with the invariant "a wide cell is always followed by its spacer" enforced and property-tested.
  • OSC title, OSC 8 hyperlinks, bracketed paste, and SGR mouse reporting.
  • terminal-cli produces byte-identical snapshots across runs.
  • ≥10 golden test cases from your Lab 5 corpus.
  • A debug mode showing raw bytes, UTF-8 events, parser states, actions, cursor moves, dirty rows, buffer changes, and mode changes.

Common Mistakes in This Section

MistakeSymptomFix
Parsing with if/starts_with instead of a state machineBreaks the moment a sequence spans two readsA real state machine, tested byte-at-a-time
UTF-8 decoding in front of the parserA 0x9b continuation byte is mistaken for 8-bit CSI; escape sequences get mangledDecode UTF-8 inside the ground state only
No pending wrapWriting exactly cols characters scrolls one line early; every full-width table is wrongDeferred wrap flag on the cursor
Unbounded parameter/OSC accumulationA malicious or buggy stream exhausts memoryCap params at 16 and OSC at ~4 KB; ignore the excess
Treating a missing parameter as 0CSI ;5H and CSI H behave wronglyMissing means "default", which is usually 1 (but 0 for some)
Cell { grapheme: String }1,920 allocations per screen; clear is O(n) allocationsInline storage; intern the rare overflow
Clamping cursor writes at the wrong layerOff-by-one at the right edge, or panicsClamp in one place, in Screen, and test the boundary
Scrollback on the alternate screenvim exit leaves garbage in your scrollbackThe alternate screen has no scrollback, by definition
Rebuilding the whole grid on resizeContent loss, and slowDecide the policy (truncate vs. reflow), document it, test it
Answering CSI 6n by writing to a file descriptorThe core is no longer pureAppend to replies; the caller writes it

How to Verify Success

# 1. Determinism.
mini-term run --rows 24 --cols 80 -- printf 'hello\n' > a.txt
mini-term run --rows 24 --cols 80 -- printf 'hello\n' > b.txt
diff a.txt b.txt && echo DETERMINISTIC

# 2. Byte-at-a-time equivalence (the parser correctness test).
cargo test -p terminal-core --test golden -- --nocapture
#   every golden case is replayed in both modes and the screens compared

# 3. Colors.
mini-term run --rows 3 --cols 20 --format debug -- printf '\033[31mred\033[0m\n'
#   cells 0..3 of row 0 must carry fg=Red

# 4. Wrapping, the classic.
mini-term run --rows 5 --cols 80 -- python3 -c 'print("x" * 100)'
#   row 0: 80 x's;  row 1: 20 x's;  cursor on row 2 col 0

# 5. Pending wrap, the subtle one.
mini-term run --rows 5 --cols 10 --format debug -- printf '0123456789'
#   cursor must be at row 0, col 9 with pending_wrap SET — NOT row 1 col 0

# 6. No panics on garbage.
head -c 100000 /dev/urandom | mini-term replay --stdin --rows 24 --cols 80

Section Profile: What a Section 2 Graduate Can Do

CapabilityEvidence
Read any escape sequence and say what it doesThe CSI catalog, from memory for the common ones
Explain why vim looks different from lsAlternate screen + scroll regions + DECAWM
Debug "my output is one line off"Pending wrap, ONLCR, or scroll region — checked in that order
Write a terminal that survives arbitrary bytesA fuzzed, bounded state machine
Test a terminal with no terminalGolden tests over recorded streams

Next: Separation of Concerns.