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]
MTitleSectionCrate it creates
0Terminal mental modelOverview—
1Raw byte inspector1raw-inspector (throwaway)
2PTY shell runner1terminal-pty
3PTY event loop1+ terminal-debugger
4Terminal parser2terminal-protocol
5Screen grid2terminal-core
6Headless terminal emulator2terminal-cli
7Windowed renderer3terminal-render-model, terminal-gui
8Input encoder3terminal-input
9Multiple terminal sessions4terminal-mux
10Pane layout4—
11Multiplexer server4+ two binaries
12Detach and attach4—
13Reusable terminal library5API stabilization
14Advanced compatibilityTesting—

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.md containing 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.

  • a shows 61.
  • Enter shows 0d (CR), not 0a — and you can explain why.
  • Backspace shows 7f (DEL) on most terminals, and you can explain why it is not 08.
  • Up arrow shows 1b 5b 41 and is decoded as CSI A.
  • F1 shows 1b 4f 50 or 1b 5b 31 31 7e depending 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_hook or a guard type with Drop).
  • stty -a before 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 tty command 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,comm inside the inner shell shows the inner shell as its own session leader, with a TPGID matching the foreground job.
  • echo $$ inside and ps outside agree on the child PID your program reports.
  • bash prints a prompt (proving isatty is 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/kqueue loop watches: stdin, the PTY master, and a signal channel.
  • SIGWINCH on your process → ioctl(0, TIOCGWINSZ) → ioctl(master, TIOCSWINSZ) → the inner program redraws. Verified with vim and with printf '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/EWOULDBLOCK is 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 if chains): 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 Perform calls.

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, TAB all 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 a debug_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-cli exists 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.
  • --record writes a replayable session file; --replay reproduces the same snapshot without spawning a shell.
  • --format json emits 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 (softbuffer or pixels). 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 (yes or cat /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 from CSI A to SS3 A, driven by the mode set by the program.
  • Ctrl+letter → the correct control byte; Ctrl+Space → NUL; Ctrl+[ → ESC.
  • Alt+key → ESC prefix (and you have decided about the 8-bit meta alternative and documented the choice).
  • Bracketed paste (?2004) wraps pasted text in ESC[200~/ESC[201~ when enabled, and does not when disabled.
  • Mouse reporting: at least ?1000 (click) and ?1006 (SGR encoding); ideally ?1002 drag.
  • Focus reporting (?1004) emits CSI 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 SessionManager owning N (Pty, Terminal) pairs.
  • One event loop reads from all masters; input goes only to the active session.
  • A hidden session running top keeps 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-gui dependency in terminal-mux. Verified with cargo 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 Terminal is sized to its rectangle, and Pty::resize is 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 mode 0700.
  • 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 -9 on the client leaves the shells running — verified with ps.
  • 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+B by default) is parsed by the client before forwarding, with a literal-prefix escape (prefix prefix sends 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-core and terminal-protocol compile for wasm32-unknown-unknown with no cfg changes.
  • Every crate has a documented public API with #![deny(missing_docs)].
  • The three boundary-enforcing cargo tree checks run in CI.
  • The same terminal-core is 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 terminfo decision: either you ship an entry, or you declare TERM=xterm-256color and 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

  1. For each milestone, name its single most important completion criterion.
  2. Which milestones can be done in a different order, and which are strictly sequential?
  3. Which milestone introduces each crate?
  4. 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.
  5. Which milestones would be blocked if you had used portable-pty from day one, and what understanding would you be missing?

Next: The Teaching Method — the shape of every implementation step, and the predict-first protocol.