Crate Boundaries: The Full Specification
For each crate: responsibility, what it must not know about, public API, internal state, dependencies, test strategy, platform independence — and, added here because it is the part that makes a boundary real, the concrete capability lost if the boundary breaks.
The workspace chapter gave you these as a plan. This chapter is the audit: check your actual code against each row.
1. terminal-protocol
| Field | Value |
|---|---|
| Responsibility | Bytes → parsed actions. The VT state machine and the UTF-8 decoder. |
| Must not know about | Screens, grids, cursors, colors, PTYs, files, threads, rendering, terminals. It does not know that CSI 2 J erases anything. |
| Public API | Parser::new(), Parser::advance(&mut self, &[u8], &mut impl Perform), the Perform trait, Params. |
| Internal state | Parser state, parameter accumulator, intermediates, UTF-8 partial buffer. All bounded and Copy-sized. |
| Dependencies | None. std only (and core+alloc under no_std). |
| Test strategy | Unit tests over a recording Perform. The split-input property at chunk sizes 1, 2, 3, 7, 13, ∞. Fuzzing for no-panic/no-hang/bounded-memory. |
| Platform independent | Yes, absolutely. Compiles for wasm32, no_std, and any target. |
| If the boundary breaks | You lose: independent fuzzing (the state space explodes if a screen is attached), reuse outside terminals (log colorizers, asciinema renderers), and the ability to test parsing without constructing a screen. |
Audit command:
cargo tree -p terminal-protocol --depth 1 # must show ONLY the crate itself
cargo build -p terminal-protocol --target wasm32-unknown-unknown
grep -rn "cfg(unix)\|cfg(windows)\|cfg(target_os" crates/terminal-protocol/src/ # empty
2. terminal-core
| Field | Value |
|---|---|
| Responsibility | Actions → screen state. Grid, cursor, modes, scroll region, scrollback, alternate screen, tab stops, title, hyperlink table, damage. Implements each sequence's meaning. |
| Must not know about | Fonts, pixels, GPUs, windows, PTYs, processes, sockets, threads, file descriptors, or how bytes arrived. |
| Public API | Terminal::new(rows, cols, config), advance(&[u8]), resize(rows, cols), screen(), cursor(), modes(), damage(), clear_damage(), take_replies(), take_clipboard_write(), take_title_changed(), snapshot(). |
| Internal state | Screen { primary, alternate, active, scrollback }, Cursor, TerminalModes, (scroll_top, scroll_bottom), tab_stops, title, replies, damage, hyperlinks. |
| Dependencies | terminal-protocol, unicode-width, bitflags. Nothing else, ever. |
| Test strategy | Per-sequence unit tests (screen before → bytes → screen after). Golden tests over recorded streams. Property tests for structural invariants (cursor in bounds, every row exactly cols, no orphan spacers). |
| Platform independent | Yes. This is the crate that makes a headless simulator, a WASM demo, a mux server, and a test harness possible. |
| If the boundary breaks | You lose: the multiplexer (a core that needs a display cannot run headless), WASM, fast tests, and the four-consumer property that is Milestone 13's whole point. |
The take_* pattern deserves a note. terminal-core does no I/O, but several sequences require
an effect outside the screen: CSI 6n needs a reply written to the PTY; OSC 52 needs the clipboard
set; OSC 0 needs the window title changed. Rather than taking callbacks (which would put the caller's
types into the core's signatures) or doing I/O (which would destroy portability), the core queues
requests the caller drains. That inversion is the single most important API decision in the
workspace.
cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit|wgpu' # must be empty
cargo build -p terminal-core --target wasm32-unknown-unknown
3. terminal-input
| Field | Value |
|---|---|
| Responsibility | Key and mouse events → bytes, honoring terminal modes. |
| Must not know about | winit, any windowing library's types, the screen contents, or pixels. It defines its own Key/Modifiers/MouseEvent. |
| Public API | encode_key(Key, Modifiers, &TerminalModes) -> Option<Vec<u8>>, encode_mouse, encode_paste. |
| Internal state | None. Pure functions of (event, modes). Any state (e.g. a kitty flag stack) belongs in the modes, in terminal-core. |
| Dependencies | terminal-core — and only for the mode flags type. |
| Test strategy | Table-driven: one row per entry in the key encoding table. Cross-terminal differential comparison against xterm's actual output. |
| Platform independent | Yes. The encoding is a wire protocol, not an OS feature. |
| If the boundary breaks | You lose: unit-testing input without a window (which is most of the test suite), reuse by the mux client, and the ability to swap windowing libraries. |
Note: The dependency on
terminal-coreis the weakest link in the graph, and it exists only forTerminalModes. Moving the mode flags intoterminal-protocol(where they arguably belong — they are protocol state, not screen state) would maketerminal-inputdepend on nothing but the protocol. That is a real refactor worth considering, and noticing it is exactly the kind of thing this audit is for.
4. terminal-pty
| Field | Value |
|---|---|
| Responsibility | Allocate a PTY pair, spawn a child correctly, own the master, get/set the size, reap the child, expose I/O and an exit notification. |
| Must not know about | Escape sequences, screens, keys, rendering. If this crate contains the byte 0x1b, something is wrong. |
| Public API | Pty::spawn(&PtyConfig), master()/AsFd, read/write, resize(PtySize), try_wait(), child_pid(), kill(). |
| Internal state | The master OwnedFd, the child Pid, the last PtySize, an exit status. |
| Dependencies | rustix or nix (or raw libc). Optionally portable-pty behind a feature. |
| Test strategy | Integration tests spawning real processes: echo produces CRLF, stty size reflects the set size, exit status observed, resize produces SIGWINCH. |
| Platform independent | No — and it is the only crate allowed not to be. |
| If the boundary breaks | You lose: Windows support (ConPTY behind the same API), and the ability to test everything else without spawning processes. |
This crate's non-portability is a feature. It is the designated place for cfg(unix) and
cfg(windows), and its existence is what keeps every other crate clean. Adding a ConPTY backend
should require changing exactly one crate.
# Everything EXCEPT terminal-pty and terminal-gui must be cfg-free:
grep -rn "cfg(unix)\|cfg(windows)\|cfg(target_os" crates/ \
| grep -v terminal-pty | grep -v terminal-gui # must be empty
5. terminal-render-model
| Field | Value |
|---|---|
| Responsibility | Terminal state → a flat, renderer-agnostic description: styled runs, resolved colors, cursor, selection, damage. |
| Must not know about | Fonts, glyph atlases, GPUs, pixel coordinates. It speaks in cells, never in pixels. |
| Public API | RenderSnapshot::from_terminal(&Terminal, &Theme, Option<Selection>), RenderSnapshot, RenderLine, StyledRun, RenderCursor. |
| Internal state | None; a pure transformation, so a snapshot can be produced on any thread and sent to a renderer. |
| Dependencies | terminal-core. |
| Test strategy | Snapshot tests (known screen → known run list). Damage tests. Color-resolution tests including inverse-plus-selection composition. |
| Platform independent | Yes. |
| If the boundary breaks | You lose: swapping CPU for GPU without touching the core, rendering off-thread, testing rendering logic, and a second frontend. |
This is the crate people skip, and skipping it is why terminal codebases end up with renderers
reaching into Grid's private fields.
6. terminal-gui
| Field | Value |
|---|---|
| Responsibility | Window, event loop, translate windowing events into terminal-input types, drive the PTY, feed the core, rasterize, blit, handle resize and clipboard. |
| Must not know about | How escape sequences work. It should be readable by someone who does not know what CSI means. |
| Public API | A binary. If a library surface exists, App::run(config). |
| Internal state | Window, surface, fonts, atlas, cell metrics, Terminal, Pty, selection, scroll offset. |
| Dependencies | winit, softbuffer/wgpu, fontdue/swash, plus the internal crates. |
| Test strategy | Keep it thin. Extract everything testable (pixel↔cell math, cell metrics, selection ranges) into pure functions. Prefer testing RenderSnapshot over golden images. |
| Platform independent | Mostly, via winit. Font discovery and clipboard are the platform-specific parts. |
| If the boundary breaks | If the core starts depending on this, you lose everything — headless operation, the mux, tests, WASM. This is the arrow that must never exist. |
7. terminal-mux
| Field | Value |
|---|---|
| Responsibility | Sessions → windows → panes, each pane a Pty + a headless Terminal. The server event loop, the socket protocol, input routing, compositing, attach/detach. |
| Must not know about | Windows, fonts, pixels, GPUs. Never depends on terminal-gui. |
| Public API | Two binaries plus a library: SessionManager, Session, Window, Pane, Layout, Request, Event. |
| Internal state | The session tree, the listener, per-client state, the poll registry. |
| Dependencies | terminal-core, terminal-pty, terminal-protocol, serde, mio/libc. |
| Test strategy | Protocol round-trip. Server tests over a socketpair with no terminal. The kill -9 persistence test. Layout property tests. |
| Platform independent | Unix-oriented (Unix sockets). Session and layout logic is portable; keep the transport behind a trait. |
| If the boundary breaks | If it depends on the GUI, you lose detach/attach entirely — the whole point. |
cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer|fontdue|cosmic-text' # must be empty
8. terminal-debugger
| Field | Value |
|---|---|
| Responsibility | Hex dumps with decoded escapes, UTF-8 decode logging, parser state tracing, action printing, screen snapshots, snapshot diffing, session record/replay. |
| Must not know about | Rendering. It is a text tool. |
| Public API | HexDump, SequenceDecoder, TracingPerform<P>, Recorder, Recording, snapshot_diff. |
| 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 for replay; recording needs a PTY. |
| If the boundary breaks | You lose the ability to debug the core without a terminal — which is when you most need it. |
9. terminal-cli
| Field | Value |
|---|---|
| Responsibility | Run a command through a PTY at a fixed size and print a deterministic snapshot; replay recordings; diff snapshots. |
| Must not know about | Rendering, windowing. |
| Public API | A binary: run, replay, feed, diff. |
| Internal state | Trivial. |
| Dependencies | terminal-core, terminal-pty, terminal-debugger, clap. |
| Test strategy | It is one. |
| Platform independent | replay yes; run needs a PTY. |
| If the boundary breaks | You lose golden tests, which is most of your regression safety. |
Configuration: The Concern Everyone Gets Wrong
Concern 8 from the section index. Configuration is where clean architectures go to die, because it is tempting to make it global.
#![allow(unused)] fn main() { // ✗ WRONG. Now terminal-core depends on a file format, a filesystem, a global, // and initialization order. It no longer compiles for WASM. It cannot be // instantiated twice with different settings — which the MUX needs. static CONFIG: OnceLock<Config> = OnceLock::new(); impl Terminal { fn new(rows: usize, cols: usize) -> Self { let scrollback = CONFIG.get().unwrap().scrollback_limit; // NO // ... } } // ✓ RIGHT. Configuration is DATA, passed down. The core does not know what TOML // is, cannot read a file, and can be instantiated a hundred times with a // hundred different configurations — which is exactly what the mux does. #[derive(Clone, Debug)] pub struct TerminalConfig { pub scrollback_limit: usize, pub tab_width: usize, pub ambiguous_width_is_wide: bool, pub clipboard_policy: ClipboardPolicy, pub max_osc_len: usize, } impl Default for TerminalConfig { /* documented, sensible defaults */ } impl Terminal { pub fn new(rows: usize, cols: usize, config: TerminalConfig) -> Self { /* ... */ } } }
The rule: each crate defines the config struct it needs, with Default. The application
parses the file, builds each struct, and passes them down. Only terminal-gui and the mux binaries
know that TOML exists.
Live reload then falls out for free: the app re-parses and calls terminal.set_config(new). No
global to invalidate, no ordering problem.
The Audit: Run This Against Your Code
#!/usr/bin/env bash
set -e
echo "=== 1. terminal-protocol has no dependencies ==="
test "$(cargo tree -p terminal-protocol --depth 1 | wc -l)" -le 2
echo "=== 2. terminal-core has no OS dependencies ==="
! cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit|wgpu'
echo "=== 3. terminal-mux has no GUI dependencies ==="
! cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer|fontdue|cosmic-text'
echo "=== 4. terminal-input has no windowing dependencies ==="
! cargo tree -p terminal-input | grep -E 'winit|wgpu|softbuffer'
echo "=== 5. The portable crates build for wasm32 ==="
cargo build --target wasm32-unknown-unknown \
-p terminal-protocol -p terminal-core -p terminal-render-model -p terminal-input
echo "=== 6. Platform cfgs are confined to pty and gui ==="
! grep -rn 'cfg(unix)\|cfg(windows)\|cfg(target_os' crates/ \
--include=*.rs | grep -v terminal-pty | grep -v terminal-gui
echo "=== 7. Public APIs are documented ==="
cargo doc --no-deps --workspace 2>&1 | grep -q 'missing documentation' && exit 1 || true
echo "=== 8. No escape bytes in terminal-pty ==="
! grep -rn '0x1b\|\\x1b\|\\033' crates/terminal-pty/src/
echo "ALL BOUNDARY CHECKS PASSED"
Put this in CI. It is eight commands, it runs in seconds, and it is the difference between a designed architecture and a described one.
Reading Other Projects Against This Map
The best way to confirm the pattern is general is to find it elsewhere:
| Project | Where the boundary is |
|---|---|
| Alacritty | vte = your terminal-protocol; alacritty_terminal = your terminal-core; alacritty = your terminal-gui. Published separately, used by others. |
| WezTerm | termwiz = protocol + core + input; portable-pty = your terminal-pty, with a Windows backend. |
| Ghostty | libghostty = protocol + core + input + pty, behind a C ABI; the Swift/GTK apps = your terminal-gui. |
| kitty | Less separated — the core is in C inside the application, with Python for the UI layer. A different trade-off, deliberately. |
| Zellij | A Rust multiplexer using vte for parsing. Maps onto your terminal-mux + terminal-protocol. |
Exercise: clone one of these, run cargo tree or read its module layout, and produce the mapping
table onto your nine crates. Where it differs, ask what it bought them. That exercise is worth more
than another chapter of prose.
Validation / Self-check
- For each of the nine crates: responsibility, one thing it must not know, and the capability lost if that boundary breaks.
- Which crate is allowed to be platform-specific, and why is it a virtue that it is the only one?
- Explain the
take_*pattern. Why not callbacks? Why not I/O? - Why does
terminal-inputdepend onterminal-core, and what refactor would remove it? - Why must configuration be passed down rather than read from a global? Name three things a global costs you.
- Write the eight audit commands from memory.
- Which arrow in the dependency graph must never exist, and what does it cost?
- Map
alacritty_terminal,vte,termwiz, and libghostty onto your crates. - Your renderer needs a cell's hyperlink URI. Which crate exposes it, and how, without putting a
StringinCell? - Run the audit script. Which checks fail on your code, and what will you do about each?
Next: Embedding Scenarios.