The Rust Workspace Design
This is the target architecture. You do not build it up front — you grow into it, and Section 5
is where you critique it. But you should know where you are going, because the boundaries here are
the same boundaries a real reusable terminal library (libghostty, alacritty_terminal, termwiz)
has to draw, and drawing them badly is the most common way a terminal project becomes unmaintainable.
The Workspace
mini-terminal/
├── Cargo.toml # [workspace]
├── crates/
│ ├── terminal-protocol/ # M4 bytes → actions
│ ├── terminal-core/ # M5 actions → screen state
│ ├── terminal-input/ # M8 key/mouse events → bytes
│ ├── terminal-pty/ # M2 PTY + process lifecycle
│ ├── terminal-render-model/ # M7 renderer-independent view of the screen
│ ├── terminal-gui/ # M7 winit + CPU/GPU rendering
│ ├── terminal-mux/ # M9 sessions, panes, client/server
│ ├── terminal-debugger/ # M3 hex dump, tracing, record/replay, diff
│ └── terminal-cli/ # M6 headless binary
└── tests/
├── golden/ # recorded byte streams + expected snapshots
└── integration/ # real-PTY tests
When each crate appears:
flowchart LR
M1[M1 raw-inspector<br/>throwaway bin] --> M2[M2 terminal-pty]
M2 --> M3[M3 + terminal-debugger]
M3 --> M4[M4 terminal-protocol]
M4 --> M5[M5 terminal-core]
M5 --> M6[M6 terminal-cli]
M6 --> M7[M7 render-model + gui]
M7 --> M8[M8 terminal-input]
M6 --> M9[M9 terminal-mux]
M8 --> M9
M9 --> M13[M13 extract stable APIs]
The Dependency Graph — and the Arrows That Must Not Exist
┌────────────────────┐
│ terminal-protocol │ std + unicode-width only
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ terminal-core │ the screen; no I/O of any kind
└───┬─────────┬──────┘
┌────────────┘ └────────────┐
│ │
┌──────────▼───────────┐ ┌──────────▼──────────┐
│ terminal-render-model│ │ terminal-input │
└──────────┬───────────┘ └──────────┬──────────┘
│ │
└──────────────┬────────────────────┘
│
┌────────────────┐ │ ┌──────────────────┐
│ terminal-pty │────┼───▶│ terminal-gui │ the ONLY crate with pixels
└───────┬────────┘ │ └──────────────────┘
│ │
│ ┌──────▼──────────┐
└─────▶│ terminal-mux │ ✗ NEVER depends on terminal-gui
└─────────────────┘
Enforce it mechanically, not by discipline:
# terminal-mux must not pull in the GUI stack. This must print nothing.
cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer|fontdue|cosmic-text'
# terminal-core must not pull in any I/O or OS crate. This must print nothing.
cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit'
# terminal-protocol must have essentially no dependencies at all.
cargo tree -p terminal-protocol --depth 1
Warning: Put those three commands in CI on the day you create the second crate. Boundary rot is silent and one-directional: nobody ever notices the day
terminal-coregained alibcdependency, and by the time you want a headless test harness it is a week of work to remove.
Crate Specifications
For each crate: responsibility, what it must not know about, public API, internal state, dependencies, test strategy, platform independence.
terminal-protocol — bytes to actions
| Field | Value |
|---|---|
| Responsibility | Consume a byte stream and emit a sequence of parsed actions: print this grapheme, execute this C0 control, dispatch this CSI with these parameters, dispatch this OSC with these strings. It is the VT state machine and the UTF-8 decoder, and nothing else. |
| Must not know about | Screens, grids, cursors, rows, columns, colors, PTYs, files, threads, rendering. It does not know what CSI 2 J means — only that it is a CSI with final byte J and parameter [2]. |
| Public API | Parser::new(), Parser::advance(&mut self, bytes: &[u8], perform: &mut impl Perform). A Perform trait with print(&mut self, c: char), execute(&mut self, byte: u8), csi_dispatch(&mut self, params: &Params, intermediates: &[u8], private: bool, final_byte: u8), esc_dispatch, osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool), hook/put/unhook for DCS. |
| Internal state | Current parser state (Ground, Escape, CsiParam, OscString, …), the parameter accumulator, the intermediate bytes, the UTF-8 decoder's partial state. Bounded: parameters cap at 16, OSC strings cap at a configured limit. |
| Dependencies | std only (plus unicode-width if you fold width lookup in here; better: keep it in core). |
| Test strategy | Pure unit tests. Feed byte slices, assert the exact sequence of Perform calls using a recording Perform implementation. Split-input tests (feed the same sequence one byte at a time and assert identical output). Fuzz with cargo-fuzz: the parser must never panic on arbitrary bytes. |
| Platform independent | Yes, absolutely. No cfg(unix) anywhere. Compiles to wasm32-unknown-unknown with no changes — that is the test. |
terminal-core — actions to screen state
| Field | Value |
|---|---|
| Responsibility | Own the logical terminal: the grid of cells, the cursor, the mode flags, the scroll region, the alternate screen, the scrollback, the tab stops, the title. Implement each escape sequence's meaning. |
| Must not know about | Fonts, pixels, GPUs, windows, PTYs, processes, sockets, threads, or how bytes arrived. It takes bytes in (Terminal::advance(&[u8])) and exposes state out. Its only "output" is a byte buffer for terminal replies (DSR, DA, CPR) that the caller must write back to the PTY. |
| Public API | Terminal::new(rows, cols), advance(&mut self, bytes: &[u8]), resize(rows, cols), screen(&self) -> &Screen, cursor(&self) -> Cursor, modes(&self) -> &TerminalModes, take_replies(&mut self) -> Vec<u8>, damage(&self) -> &Damage, clear_damage(&mut self). |
| Internal state | Screen { primary: Grid, alternate: Grid, active: BufferKind, scrollback: VecDeque<Line> }, Cursor { row, col, style, pending_wrap, saved }, TerminalModes bitflags, scroll_region: (usize, usize), tab_stops: Vec<bool>, title: String, damage: DamageSet. |
| Dependencies | terminal-protocol, unicode-width, unicode-segmentation, bitflags. Nothing else. |
| Test strategy | Unit tests per sequence (screen-before → bytes → screen-after). Golden tests: recorded byte streams → serialized screen snapshots. Property tests: after any sequence of operations, invariants hold (cursor in bounds; every row has exactly cols cells; a wide char is always followed by its spacer). |
| Platform independent | Yes. This is the crate that makes a headless simulator, a WASM demo, and a test harness possible. Guard it in CI with the cargo tree check above. |
terminal-input — events to bytes
| Field | Value |
|---|---|
| Responsibility | The inverse of the parser: turn logical key and mouse events into the byte sequences a terminal is expected to send, honoring the current terminal modes (application cursor keys, application keypad, bracketed paste, mouse protocol and encoding, keyboard protocol level). |
| Must not know about | winit, any specific windowing library's event types, or the screen contents. It defines its own Key, Modifiers, MouseEvent types; the GUI crate translates into them. |
| Public API | encode_key(key: Key, mods: Modifiers, modes: &TerminalModes) -> Option<Vec<u8>>, encode_mouse(event: MouseEvent, modes: &TerminalModes) -> Option<Vec<u8>>, encode_paste(text: &str, modes: &TerminalModes) -> Vec<u8>. |
| Internal state | Ideally none — pure functions of (event, modes). Any state (e.g. the kitty-keyboard flag stack) belongs in terminal-core's modes, not here. |
| Dependencies | terminal-core (for the mode flags type only). Consider moving the mode flags into terminal-protocol so this crate does not depend on the screen at all — that is a Section 5 discussion. |
| Test strategy | Table-driven unit tests: (key, mods, modes) → expected bytes, one row per entry in the key encoding table. Round-trip tests against your own parser where meaningful. |
| Platform independent | Yes. The encoding is a wire protocol, not an OS feature. |
terminal-pty — the OS integration
| Field | Value |
|---|---|
| Responsibility | Allocate a PTY pair, spawn a child correctly (setsid, TIOCSCTTY, dup2, execve), own the master fd, set and get the window size, reap the child, and expose readable/writable I/O plus an exit notification. |
| Must not know about | Escape sequences, screens, keys, rendering. It moves opaque bytes. If this crate ever contains the byte 0x1b, something has gone wrong. |
| Public API | Pty::spawn(cfg: PtyConfig) -> io::Result<Pty> where PtyConfig { shell, args, env, cwd, size: PtySize }; Pty::master(&self) -> BorrowedFd, read/write (or AsFd so the caller can poll it), resize(&self, size: PtySize), try_wait(&mut self) -> Option<ExitStatus>, child_pid(). |
| Internal state | The master OwnedFd, the child Pid, the last known PtySize, an exit status once reaped. |
| Dependencies | rustix or nix (pick one; the book shows the raw libc version first). Optionally portable-pty behind a feature flag, introduced after you have written the raw version. |
| Test strategy | Integration tests that spawn real processes: echo hi produces hi\r\n on the master; stty size inside reflects the size you set; the child's exit status is observed; a resize produces a SIGWINCH that a test program reports. |
| Platform independent | No — and it is the only crate that is allowed not to be. It is cfg(unix) today. A future cfg(windows) ConPTY implementation lives behind the same public API. That is exactly why this crate exists as a separate boundary. |
terminal-render-model — a renderer-independent view
| Field | Value |
|---|---|
| Responsibility | Convert terminal-core's internal state into a flat, cheap-to-consume, renderer-agnostic description of what should be drawn: runs of cells with identical style, cursor position and shape, selection ranges, and which rows changed. |
| Must not know about | Fonts, glyph atlases, GPUs, pixel coordinates. It speaks in cells, never in pixels. |
| Public API | RenderSnapshot::from(&Terminal) -> RenderSnapshot, RenderSnapshot { rows: Vec<RenderRow>, cursor: Option<RenderCursor>, damage: Vec<usize> }, RenderRow { runs: Vec<StyledRun> }. |
| Internal state | None beyond the snapshot itself; ideally a pure transformation, so it can be produced on any thread and sent to a renderer. |
| Dependencies | terminal-core. |
| Test strategy | Snapshot tests: a known screen produces a known run-list. Damage tests: after a single-cell write, exactly one row is reported dirty. |
| Platform independent | Yes. |
Note: This crate is the one people skip, and skipping it is why terminal codebases end up with the renderer reaching into the grid's private fields. Its real job is to be a stable boundary: you can swap CPU for GPU rendering, or add a second frontend, without touching
terminal-core.
terminal-gui — the only crate that knows about pixels
| Field | Value |
|---|---|
| Responsibility | Open a window, run the event loop, translate windowing events into terminal-input events, drive the PTY, feed bytes to terminal-core, take a RenderSnapshot, rasterize glyphs, and blit. Handle resize by converting pixel dimensions to cells and calling Pty::resize. |
| Must not know about | How escape sequences work. It should be possible to read this crate without knowing what CSI means. |
| Public API | A binary, mostly. If it has a library surface it is App::run(config). |
| Internal state | Window, surface, font, glyph atlas, cell metrics, the Terminal, the Pty, selection state, scroll offset. |
| Dependencies | winit, softbuffer (CPU) and later optionally wgpu (GPU), fontdue or swash/cosmic-text, plus the internal crates. |
| Test strategy | Hardest to test; keep it thin. Extract anything testable (pixel→cell math, cell metrics computation, selection range math) into pure functions with unit tests. Golden-image tests are optional and brittle — prefer testing the RenderSnapshot instead. |
| Platform independent | Mostly, via winit. Font enumeration and clipboard are the platform-specific parts. |
terminal-mux — sessions, panes, client/server
| Field | Value |
|---|---|
| Responsibility | Own a tree of sessions → windows → panes, each pane holding a Pty plus a Terminal. Run the server event loop. Speak a framed protocol over a Unix domain socket. Route input to the active pane. Composite pane output for attached clients. Handle attach/detach and resize negotiation. |
| Must not know about | Windows, fonts, pixels, GPUs. Never depends on terminal-gui. |
| Public API | Two binaries (mux-server, mux-client) plus a library with SessionManager, Session, Window, Pane, Layout, and the protocol Request/Event enums. |
| Internal state | The session tree, the socket listener, per-client state (attached session, client size, capabilities), the poll registry. |
| Dependencies | terminal-core, terminal-pty, terminal-protocol, serde + serde_json (framed JSON first) or a hand-rolled binary codec later, mio or plain poll. |
| Test strategy | Protocol round-trip tests (encode → decode → equal). Server tests over a socketpair with no real terminal. Persistence test: attach, run something, kill the client, reattach, assert the screen. |
| Platform independent | Unix-oriented (Unix domain sockets). The session/layout logic itself is portable; keep the socket transport behind a small trait. |
terminal-debugger — observability
| Field | Value |
|---|---|
| Responsibility | Make every layer inspectable: hex-dump raw bytes with escapes decoded, log UTF-8 decoding events, trace parser state transitions, print parsed actions, dump screen snapshots, diff two snapshots, and record/replay PTY sessions to a file. |
| Must not know about | Rendering. It is a text tool. |
| Public API | HexDump, SequenceDecoder::describe(&[u8]) -> Vec<Description>, Recorder/Player over the session file format, snapshot_diff(a, b) -> Diff, plus a TracingPerform<P> wrapper that logs every call it forwards. |
| Internal state | Buffers and a writer. |
| Dependencies | terminal-protocol, terminal-core. |
| Test strategy | Round-trip tests on the record format; decoder tests on known sequences. |
| Platform independent | Yes (the replay half; the record half needs a PTY). |
terminal-cli — the headless binary
| Field | Value |
|---|---|
| Responsibility | Run a command through a PTY with a fixed size, feed all output to a Terminal, and print a deterministic screen snapshot. This is the workhorse of your test suite and the thing you will use in every bug report. |
| Must not know about | Rendering, windowing. |
| Public API | mini-term run --rows 24 --cols 80 -- bash -c 'printf "\033[31mred\033[0m\n"' → prints a snapshot; `--format text |
| Internal state | Trivial. |
| Dependencies | terminal-core, terminal-pty, terminal-debugger, clap. |
| Test strategy | It is a test strategy. Golden tests shell out to it. |
| Platform independent | Needs a PTY for run; replay is portable. |
The Core Types (First Draft — Expect to Change)
The brief proposes these. Take them as a starting sketch, not a specification. Every one of them has a problem you will discover and fix.
#![allow(unused)] fn main() { pub struct Terminal { parser: Parser, screen: Screen, modes: TerminalModes, cursor: Cursor, } pub struct Screen { primary: Grid, alternate: Grid, active_buffer: BufferKind, scrollback: Vec<Line>, } pub struct Grid { rows: usize, columns: usize, cells: Vec<Cell>, } pub struct Cell { grapheme: String, style: Style, width: CellWidth, } }
Critique — the problems you will hit, in the order you will hit them
| Problem | Why it bites | Where you fix it |
|---|---|---|
Cell.grapheme: String | A heap allocation per cell. An 80×24 grid is 1,920 allocations, and a clear re-allocates all of them. Real terminals use a fixed inline buffer ([char; 2], a small-string type, or a char + an interned overflow table). | Screen Model |
Terminal owns parser and screen | Makes it impossible to feed the parser from one thread and read the screen from another, and impossible to test the parser without a screen. | Section 5 |
Screen.scrollback: Vec<Line> | You always push to one end and evict from the other — that is a VecDeque or a ring buffer. Vec gives you an O(n) remove(0). | Screen Model |
Grid.cells: Vec<Cell> flat, indexed row * columns + col | Correct and cache-friendly, but scrolling by one line means moving the whole buffer. Real terminals keep a row index / ring of line pointers so scrolling is a pointer rotation. | Screen Model |
No pending_wrap on Cursor | You cannot implement DECAWM correctly without it. Writing to the last column must not wrap until the next character arrives. | CSI Catalog |
| No dirty/damage tracking anywhere | You will redraw the whole screen every frame, which is fine at 80×24 and unusable at 400×100. | Rendering & Damage |
Style inline in every Cell | Fine if Style is 8–16 bytes of packed flags + colors. Fatal if it contains a String (e.g. a hyperlink URI). Hyperlinks want an interned id. | OSC & Strings |
Alternate screen as a second Grid field | Correct, but the alternate screen has no scrollback and different resize semantics. Encode that in the type, not in comments. | Modes |
Tip: Do not fix these in advance. Build the naïve version, write the benchmark or the test that exposes the problem, then fix it. A refactor you can justify with a measurement is worth ten you did because a book told you to. That is why this table names the chapter where each one bites.
The Test Layout
mini-terminal/
├── crates/*/src/** # #[cfg(test)] unit tests live next to the code
├── crates/*/tests/ # per-crate integration tests
└── tests/
├── golden/
│ ├── cases/
│ │ ├── vim-startup.bytes # recorded PTY output
│ │ ├── vim-startup.snapshot # expected screen, checked in
│ │ ├── top-one-frame.bytes
│ │ └── top-one-frame.snapshot
│ └── golden_test.rs
└── integration/
└── pty_test.rs # spawns real processes
Details in the Testing Strategy.
Validation / Self-check
- For each of the nine crates, state its responsibility in one sentence and name one thing it must not know about.
- Which crate is allowed to be platform-specific, and why is it a virtue that it is the only one?
- Write the three
cargo treecommands that mechanically enforce the boundaries. - Why must
terminal-muxnever depend onterminal-gui? Give the concrete capability you lose. - Name three problems with the
Cell { grapheme: String, ... }sketch and say which milestone exposes each. - If you wanted to run the terminal core in a browser via WebAssembly, which crates could you compile unchanged, and which is the first one that would fail?
- Where do terminal replies (the response to
CSI 6n) come from, and why canterminal-corenot just write them to the PTY itself?
Next: The Roadmap — fifteen milestones with concrete completion criteria.