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 feature | Bytes |
|---|---|
| Printable ASCII | 0x20–0x7e |
| Newline | 0x0a (LF) |
| Carriage return | 0x0d (CR) |
| Backspace | 0x08 (BS) |
| Tab | 0x09 (HT) |
| Cursor position | Tracked, not yet settable by escape sequence |
| Fixed rows × columns, a 2-D cell grid, basic scrolling, screen clear, basic ANSI colors | CSI 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
| Chapter | What it covers |
|---|---|
| Separation of Concerns | The eight distinct jobs people conflate into "the terminal" |
| The Parser State Machine | The VT500 state machine: states, transitions, actions |
| UTF-8 and Graphemes | Decoding, where it sits relative to the parser, widths, combining marks |
| The Screen Model | Grid, Cell, Line, cursor, pending wrap, scrollback, resize |
| The CSI Catalog | Every CSI sequence you implement, with bytes, semantics, tests, and generators |
| SGR and Color | Attributes, 16/256/truecolor, the colon-subparameter form |
| Modes | ANSI modes and DEC private modes, including the alternate screen |
| OSC and String Sequences | Titles, hyperlinks, clipboard, semantic prompts, DCS/APC |
The Labs
| Lab | Milestone | Build |
|---|---|---|
| Lab 6 | M4 | terminal-protocol: the state machine, tested byte-at-a-time |
| Lab 7 | M5 | terminal-core: grid, cursor, scrolling, erase, pending wrap |
| Lab 8 | M6 | terminal-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:
- The parser does not know what sequences mean. It knows
CSIwith params[2]and final byteJ. That2means "erase the whole screen" isterminal-core's knowledge. Keeping them separate is what lets you fuzz the parser, reuse it, and test it without a screen. - 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 screen | Because 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 renderer | You can run it in WebAssembly |
| You can test "these bytes produce these actions" with no grid | Golden tests are bytes → snapshot, fully deterministic |
| Its state is tiny and bounded, so a fuzz corpus covers it | The mux server can host 50 of them with no display |
Deliverables for This Section
-
terminal-protocolimplements 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-coreimplements 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-cliproduces 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
| Mistake | Symptom | Fix |
|---|---|---|
Parsing with if/starts_with instead of a state machine | Breaks the moment a sequence spans two reads | A real state machine, tested byte-at-a-time |
| UTF-8 decoding in front of the parser | A 0x9b continuation byte is mistaken for 8-bit CSI; escape sequences get mangled | Decode UTF-8 inside the ground state only |
| No pending wrap | Writing exactly cols characters scrolls one line early; every full-width table is wrong | Deferred wrap flag on the cursor |
| Unbounded parameter/OSC accumulation | A malicious or buggy stream exhausts memory | Cap params at 16 and OSC at ~4 KB; ignore the excess |
| Treating a missing parameter as 0 | CSI ;5H and CSI H behave wrongly | Missing means "default", which is usually 1 (but 0 for some) |
Cell { grapheme: String } | 1,920 allocations per screen; clear is O(n) allocations | Inline storage; intern the rare overflow |
| Clamping cursor writes at the wrong layer | Off-by-one at the right edge, or panics | Clamp in one place, in Screen, and test the boundary |
| Scrollback on the alternate screen | vim exit leaves garbage in your scrollback | The alternate screen has no scrollback, by definition |
| Rebuilding the whole grid on resize | Content loss, and slow | Decide the policy (truncate vs. reflow), document it, test it |
Answering CSI 6n by writing to a file descriptor | The core is no longer pure | Append 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
| Capability | Evidence |
|---|---|
| Read any escape sequence and say what it does | The CSI catalog, from memory for the common ones |
Explain why vim looks different from ls | Alternate 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 bytes | A fuzzed, bounded state machine |
| Test a terminal with no terminal | Golden tests over recorded streams |
Next: Separation of Concerns.