The Roadmap: Fifteen Milestones
Every milestone has a goal, an observable behavior, concrete completion criteria, an experiment, and a checkpoint question. You do not advance until the completion criteria are met and you can answer the checkpoint question without notes.
The rule that governs the whole sequence:
Do not move to the next milestone until the current behavior can be inspected and explained.
"It works" is not a completion criterion. "I can show you, at any layer, exactly what bytes are moving and why" is.
The Sequence at a Glance
flowchart TD
M0[M0 Mental model] --> M1[M1 Raw byte inspector]
M1 --> M2[M2 PTY shell runner]
M2 --> M3[M3 PTY event loop]
M3 --> M4[M4 Terminal parser]
M4 --> M5[M5 Screen grid]
M5 --> M6[M6 Headless emulator]
M6 --> M7[M7 Windowed renderer]
M7 --> M8[M8 Input encoder]
M6 --> M9[M9 Multiple sessions]
M8 --> M9
M9 --> M10[M10 Pane layout]
M10 --> M11[M11 Mux server]
M11 --> M12[M12 Detach & attach]
M12 --> M13[M13 Reusable library]
M13 --> M14[M14 Advanced compatibility]
M14 --> CAP[Capstone]
| M | Title | Section | Crate it creates |
|---|---|---|---|
| 0 | Terminal mental model | Overview | — |
| 1 | Raw byte inspector | 1 | raw-inspector (throwaway) |
| 2 | PTY shell runner | 1 | terminal-pty |
| 3 | PTY event loop | 1 | + terminal-debugger |
| 4 | Terminal parser | 2 | terminal-protocol |
| 5 | Screen grid | 2 | terminal-core |
| 6 | Headless terminal emulator | 2 | terminal-cli |
| 7 | Windowed renderer | 3 | terminal-render-model, terminal-gui |
| 8 | Input encoder | 3 | terminal-input |
| 9 | Multiple terminal sessions | 4 | terminal-mux |
| 10 | Pane layout | 4 | — |
| 11 | Multiplexer server | 4 | + two binaries |
| 12 | Detach and attach | 4 | — |
| 13 | Reusable terminal library | 5 | API stabilization |
| 14 | Advanced compatibility | Testing | — |
Milestone 0 — Terminal Mental Model
Goal. Explain the stack without code.
Observable behavior. None. This is the one milestone with no program.
Completion criteria.
- You can draw the full stack from key press to pixel, marking the user/kernel boundary, and label every arrow with a syscall or a signal.
-
You have written
answers-m0.mdcontaining your answers to the twelve questions in the introduction, and committed it. - You can define TTY, PTY, line discipline, emulator, shell, and multiplexer without using any of the other five in the definition.
Experiment. Run tty, then ps -o pid,pgid,sid,tpgid,tty,comm -p $$, then open a second window
and run the same. Explain every difference between the two outputs before reading further.
Checkpoint question. Who owns the terminal screen — the shell, the kernel, or the emulator?
Milestone 1 — Raw Byte Inspector
Goal. Read stdin in raw mode and display the exact input bytes.
Observable behavior. You press a key; the program prints its bytes in hex, its printable form,
and a decoded description. Ctrl+C prints 03 and does not terminate the program. Ctrl+Q (or a
chosen escape key) exits cleanly and restores the terminal.
Completion criteria.
-
ashows61. -
Enter shows
0d(CR), not0a— and you can explain why. -
Backspace shows
7f(DEL) on most terminals, and you can explain why it is not08. -
Up arrow shows
1b 5b 41and is decoded asCSI A. -
F1 shows
1b 4f 50or1b 5b 31 31 7edepending on your terminal, and you know why both exist. -
Ctrl+C shows
03; the program does not exit. -
The terminal is restored on every exit path, including panic (
std::panic::set_hookor a guard type withDrop). -
stty -abefore and after your program prints identical output.
Experiment. Run the program, then in another window run stty -a -F /dev/pts/N (Linux) against
the same terminal and observe -icanon -echo. Kill your program with kill -9 from the other
window and watch your shell become unusable — then fix it with stty sane (which you will have to
type blind).
Checkpoint question. Which component was echoing your keystrokes before you disabled ECHO, and
what is now responsible for it?
Milestone 2 — PTY Shell Runner
Goal. Launch a shell through a PTY and relay bytes.
Observable behavior. cargo run gives you a usable shell running inside your program. You can
run ls, vim, top. Exiting the shell exits your program.
Completion criteria.
-
The child's
ttycommand reports a/dev/pts/N(Linux) or/dev/ttysNNN(macOS) that is different from your outer terminal's. -
ps -o pid,pgid,sid,tpgid,tty,comminside the inner shell shows the inner shell as its own session leader, with aTPGIDmatching the foreground job. -
echo $$inside andpsoutside agree on the child PID your program reports. -
bashprints a prompt (provingisattyis true) and does not print "no job control in this shell". - Your program restores the outer terminal on exit.
- You have written down every syscall your spawn path makes, in order, with its purpose.
Experiment. Comment out ioctl(slave, TIOCSCTTY, 0) and rerun. Predict first: what breaks? Then
observe: bash warns about job control, Ctrl+C does nothing, vim misbehaves. Restore it.
Checkpoint question. Why must setsid() come before TIOCSCTTY, and what error do you get if
you get the order wrong?
Milestone 3 — PTY Event Loop
Goal. Handle input, output, resize, signals, and child termination in one correct loop.
Observable behavior. Resizing your outer terminal correctly resizes the inner one — vim and
top redraw at the new size. Ctrl+C interrupts the inner foreground job without touching your relay.
The child's exit is detected deterministically.
Completion criteria.
-
A single
poll/epoll/kqueueloop watches: stdin, the PTY master, and a signal channel. -
SIGWINCHon your process →ioctl(0, TIOCGWINSZ)→ioctl(master, TIOCSWINSZ)→ the inner program redraws. Verified withvimand withprintf 'size: '; tput lines; tput cols. -
SIGCHLD→waitpid(WNOHANG)→ clean shutdown with the child's exit code as yours. -
Signal handling is async-signal-safe: the handler writes one byte to a self-pipe (or you use
signalfd/kqueue), and all logic lives in the event loop. -
The
EIO-on-Linux vs.read()==0-on-macOS difference on master read after child exit is handled explicitly, with a comment naming both. -
Partial writes are handled:
write()returning fewer bytes than requested does not lose data. -
Non-blocking mode is set and
EAGAIN/EWOULDBLOCKis not treated as an error.
Experiment. Inside your runner, start sleep 100, press Ctrl+Z, run jobs, run bg, run fg,
then Ctrl+C. Watch TPGID change with ps from a third window at each step.
Checkpoint question. What happens if the PTY window size is never updated? Name three concrete symptoms.
Milestone 4 — Terminal Parser
Goal. Parse text and a minimal subset of control sequences.
Observable behavior. Bytes in, a stream of typed Actions out, printed by a tracing Perform.
Completion criteria.
-
A real state machine (not
ifchains):Ground,Escape,EscapeIntermediate,CsiEntry,CsiParam,CsiIntermediate,CsiIgnore,OscString,DcsEntry…DcsPassthrough,SosPmApcString. - UTF-8 decoding is layered correctly (a multi-byte character is never mistaken for a control byte, and an escape sequence is never fed through the UTF-8 decoder).
- Feeding the same input one byte at a time produces an identical action stream to feeding it all at once. This is a test, not a hope.
- Malformed input never panics and never hangs. Proven by a fuzz target that has run for at least 10 minutes clean.
- Parameters are bounded (max 16 params, max 6 digits each) and OSC strings are bounded.
-
Each implemented sequence has a unit test showing raw bytes → exact
Performcalls.
Experiment. Pipe real output through your tracer:
script -q -c 'ls --color=always' /dev/null | your-tracer (Linux) and read the actions.
Checkpoint question. Why must the UTF-8 decoder sit inside the Ground state rather than in
front of the whole parser?
Milestone 5 — Screen Grid
Goal. Maintain cursor position, cells, scrolling, and clearing.
Observable behavior. A Terminal that you can feed bytes and then print as a text snapshot.
Completion criteria.
-
Printable ASCII,
LF,CR,BS,TABall behave correctly, with a test each. -
Cursor movement (
CUU/CUD/CUF/CUB/CUP), erase (ED/EL), and scrolling are correct at the boundaries — that is where every bug lives. - Pending wrap (DECAWM deferred wrap) is implemented: writing to the last column does not move the cursor to the next line until another character arrives. Tested explicitly.
- Scrolling off the top of the primary screen pushes lines into scrollback.
- Basic ANSI colors via SGR 30–37/40–47/0/1/7, stored per cell.
-
A
snapshot()that renders the grid as deterministic text (and adebug_snapshot()that includes styles). - Resize is implemented, and you have decided and documented what happens to content (truncate? reflow? which?) rather than discovering it by accident.
Experiment. printf 'x%.0s' {1..100} in an 80-column terminal. Predict where the cursor ends up
before you run it. Then do the same with 80 characters exactly, and explain the difference.
Checkpoint question. What is pending wrap, and what visible bug appears if you omit it?
Milestone 6 — Headless Terminal Emulator
Goal. Run commands and print deterministic screen snapshots.
Observable behavior.
mini-term run --rows 5 --cols 20 -- printf 'hello\n'
mini-term run --rows 5 --cols 20 -- printf '\033[31mred\033[0m\n'
mini-term run --rows 5 --cols 20 -- python3 -c 'print("x" * 100)'
Completion criteria.
-
terminal-cliexists and produces byte-identical output across runs for a deterministic command. - Golden tests: at least 10 recorded byte streams with checked-in expected snapshots.
- PTY integration tests for the four commands in the testing chapter.
-
--recordwrites a replayable session file;--replayreproduces the same snapshot without spawning a shell. -
--format jsonemits a machine-readable screen dump (used later by the mux and the debugger).
Experiment. Record vim starting up, then replay it with --replay and compare snapshots.
Anything that differs is nondeterminism you must find (timing? terminal queries? $TERM?).
Checkpoint question. Why does vim behave differently when stdout is redirected to a file?
Milestone 7 — Windowed Renderer
Goal. Render the terminal grid in a desktop window.
Observable behavior. A window opens, a shell runs inside it, you can type, output renders.
Completion criteria.
-
CPU rendering first (
softbufferorpixels). GPU is explicitly deferred. - Correct font metrics: you compute cell width from the advance of a reference glyph and cell height from ascent + descent + line gap, and you can state each number for your font.
- A glyph atlas with a cache; a cache-miss path that rasterizes on demand.
- The cursor is drawn, with at least block and bar shapes.
-
Resize: pixels → cells →
Terminal::resize→Pty::resize, in that order, and the inner program redraws. - Damage tracking: only dirty rows are re-blitted, and you can print the dirty-row count per frame to prove it.
-
Frame timing is bounded — you render at most once per vsync/frame even under a flood of output
(
yesorcat /dev/urandom | head -c 10000000).
Experiment. Run yes in your terminal. If your program becomes unresponsive, your read loop and
your render loop are coupled. Fix that, and explain the fix.
Checkpoint question. Where exactly is the baseline, and what goes wrong if you draw glyphs at the top of the cell instead?
Milestone 8 — Input Encoder
Goal. Support special keys, modifiers, paste, and terminal modes.
Observable behavior. Arrow keys work in vim and in bash's history. Ctrl+C interrupts. Alt+B
moves back a word in readline. Pasting multi-line text into vim does not auto-indent into a
staircase.
Completion criteria.
- Every row of the key encoding table is implemented and unit-tested.
-
Application cursor keys (DECCKM
?1) change arrow encoding fromCSI AtoSS3 A, driven by the mode set by the program. -
Ctrl+letter → the correct control byte; Ctrl+Space →
NUL; Ctrl+[ →ESC. -
Alt+key →
ESCprefix (and you have decided about the 8-bit meta alternative and documented the choice). -
Bracketed paste (
?2004) wraps pasted text inESC[200~/ESC[201~when enabled, and does not when disabled. -
Mouse reporting: at least
?1000(click) and?1006(SGR encoding); ideally?1002drag. -
Focus reporting (
?1004) emitsCSI I/CSI O. - Paste text is sanitized: control bytes that would break out of the bracketed-paste envelope are filtered.
Experiment. Run cat -v (or your own inspector) inside your GUI and press every key on the
keyboard, with and without each modifier. Compare against the same keys in xterm/Ghostty. Every
difference is a bug or a deliberate decision — write down which.
Checkpoint question. Why does the same arrow key produce different bytes in bash and in vim?
Milestone 9 — Multiple Terminal Sessions
Goal. Manage multiple PTYs and terminal states in one process.
Observable behavior. One program hosting three shells; you switch between them with a key; all three keep producing output while hidden.
Completion criteria.
-
A
SessionManagerowning N(Pty, Terminal)pairs. - One event loop reads from all masters; input goes only to the active session.
-
A hidden session running
topkeeps updating — proven by switching away for 10 seconds and switching back to a current screen, not a stale one. - Creating and closing sessions works, including closing the active one.
- Each session has an independent size (they need not all match).
-
No
terminal-guidependency interminal-mux. Verified withcargo tree.
Experiment. Start yes > /dev/null in a hidden session. Measure your process's CPU. Then start
yes (writing to the terminal) in a hidden session and measure again. Explain the difference and
what you must do about output you are not displaying.
Checkpoint question. Why must you keep parsing output from an invisible session instead of just buffering the raw bytes until it becomes visible?
Milestone 10 — Pane Layout
Goal. Render and route input across multiple panes.
Observable behavior. A window split into two or more panes, each with its own shell, composited into one screen; input goes to the focused pane.
Completion criteria.
-
A layout tree (
Leaf(pane)|Split { direction, ratio, children }) with a function mapping it to pixel/cell rectangles. -
Each pane's
Terminalis sized to its rectangle, andPty::resizeis called on every layout change. - Compositing: pane grids are copied into the parent screen at the right offsets, with borders.
- Focus movement (left/right/up/down) and input routing to the focused pane.
- Splitting and closing panes rebalances the layout.
- Off-by-one correctness: a pane at column 40 with width 40 in an 80-column window must not write to column 80.
Experiment. Split into two panes and run vim in both. Resize the outer window by one column
repeatedly. Any pane that draws garbage has a rounding bug in your layout math — find it.
Checkpoint question. When a pane is 40 columns wide, what does the program inside it believe the terminal width is, and how did it learn that?
Milestone 11 — Multiplexer Server
Goal. Move PTY ownership into a background process.
Observable behavior. mux-server runs as a daemon; mux-client attaches over a Unix socket.
Completion criteria.
-
The server daemonizes correctly:
setsid(), no controlling terminal, working directory set, standard fds redirected, socket in a per-user directory with mode0700. - A framed protocol (length-prefixed JSON first) with a message catalog and round-trip tests.
- The client puts its terminal in raw mode, forwards input, and renders what the server sends.
-
kill -9on the client leaves the shells running — verified withps. - The server survives with zero clients attached and keeps parsing pane output.
- Stale socket files are detected and cleaned up.
Experiment. Start the server, attach, run sleep 300, kill -9 the client, then ps -o pid,ppid,sid,tty,comm and find the sleep. Explain who its parent is now and why it did not get
SIGHUP.
Checkpoint question. Explain exactly why tmux needs a server process, in terms of SIGHUP and
PTY master ownership.
Milestone 12 — Detach and Attach
Goal. Reconnect clients to persistent terminal sessions.
Observable behavior. Detach with a prefix key; the shell keeps running; reattach later — possibly from a different terminal of a different size — and see the current screen.
Completion criteria.
- Detach is clean: the client restores its terminal and exits; the server drops the client.
- Attach performs a full repaint from the server's terminal state, not a replay of the raw byte log.
- Reattaching from a different-sized terminal triggers a documented resize policy.
- Two clients attached simultaneously both render, and both can type.
- The multi-client size policy is implemented and documented (smallest-wins, or per-client independent windows — pick one and defend it).
-
A prefix key (
Ctrl+Bby default) is parsed by the client before forwarding, with a literal-prefix escape (prefix prefixsends the raw byte).
Experiment. Attach from two terminals of different sizes. Predict what happens before you look.
Then run tmux and compare its behavior with yours.
Checkpoint question. Why does reattaching repaint from state rather than replaying bytes? Name a concrete case where replay gives the wrong screen.
Milestone 13 — Reusable Terminal Library
Goal. Extract stable terminal-core APIs in a libghostty-inspired architecture.
Observable behavior. No user-visible change. The proof is structural.
Completion criteria.
-
terminal-coreandterminal-protocolcompile forwasm32-unknown-unknownwith nocfgchanges. -
Every crate has a documented public API with
#![deny(missing_docs)]. -
The three boundary-enforcing
cargo treechecks run in CI. -
The same
terminal-coreis used, unmodified, by: the GUI, the mux server, the CLI, and the test harness. Four consumers, one core. - A written boundary defense: for each crate, what it must not know about, and the concrete capability you would lose if that boundary broke.
-
Optional and last: a C ABI (
terminal-ffi) exposing create/feed/resize/snapshot/destroy, with a C example program that links it.
Experiment. Write a 40-line headless "terminal simulator" that uses only terminal-core — no
PTY, no GUI — feeds it a byte string, and prints a snapshot. If it needs anything else, your
boundary is wrong.
Checkpoint question. Which crate would you have to change to add a Windows ConPTY backend, and which crates would you have to recompile but not modify?
Milestone 14 — Advanced Compatibility
Goal. Run real interactive programs and implement missing protocol features based on observed failures.
Observable behavior. vim, less, top, htop, bash, zsh, the Python REPL, and ssh all
work in your terminal.
Completion criteria.
- A compatibility matrix: program × feature × works/broken, with the missing capability named for each break.
- Alternate screen, scroll regions, and mouse reporting all verified against real programs.
- Wide characters and combining characters render at the correct width, verified with CJK text and emoji.
-
Differential tests against a reference parser (
vte) on a corpus of recorded streams. - At least three bugs found by real programs, fixed, and covered by a regression test each.
-
A
terminfodecision: either you ship an entry, or you declareTERM=xterm-256colorand document which capabilities you actually implement — and where you lie.
Experiment. Run htop and hold down a mouse drag. Then run ssh localhost and inside it run
vim. Then resize. Each failure is a missing feature; log it, name it, fix it.
Checkpoint question. You claim TERM=xterm-256color. Name three capabilities that terminfo entry
advertises which you do not actually implement, and what breaks as a result.
Capstone
Everything above, combined: PTY process execution, terminal parsing, screen state, graphical rendering, multiple sessions, and a multiplexer server and client. See the capstone and its rubric.
Validation / Self-check
- For each milestone, name its single most important completion criterion.
- Which milestones can be done in a different order, and which are strictly sequential?
- Which milestone introduces each crate?
- Name the milestone at which each of these first becomes possible: running
vim; detaching a session; rendering a glyph; parsing an OSC sequence; testing without a shell. - Which milestones would be blocked if you had used
portable-ptyfrom day one, and what understanding would you be missing?
Next: The Teaching Method — the shape of every implementation step, and the predict-first protocol.