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-core gained a libc dependency, 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

FieldValue
ResponsibilityConsume 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 aboutScreens, 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 APIParser::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 stateCurrent 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.
Dependenciesstd only (plus unicode-width if you fold width lookup in here; better: keep it in core).
Test strategyPure 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 independentYes, absolutely. No cfg(unix) anywhere. Compiles to wasm32-unknown-unknown with no changes — that is the test.

terminal-core — actions to screen state

FieldValue
ResponsibilityOwn 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 aboutFonts, 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 APITerminal::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 stateScreen { 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.
Dependenciesterminal-protocol, unicode-width, unicode-segmentation, bitflags. Nothing else.
Test strategyUnit 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 independentYes. 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

FieldValue
ResponsibilityThe 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 aboutwinit, 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 APIencode_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 stateIdeally none — pure functions of (event, modes). Any state (e.g. the kitty-keyboard flag stack) belongs in terminal-core's modes, not here.
Dependenciesterminal-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 strategyTable-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 independentYes. The encoding is a wire protocol, not an OS feature.

terminal-pty — the OS integration

FieldValue
ResponsibilityAllocate 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 aboutEscape sequences, screens, keys, rendering. It moves opaque bytes. If this crate ever contains the byte 0x1b, something has gone wrong.
Public APIPty::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 stateThe master OwnedFd, the child Pid, the last known PtySize, an exit status once reaped.
Dependenciesrustix 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 strategyIntegration 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 independentNo — 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

FieldValue
ResponsibilityConvert 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 aboutFonts, glyph atlases, GPUs, pixel coordinates. It speaks in cells, never in pixels.
Public APIRenderSnapshot::from(&Terminal) -> RenderSnapshot, RenderSnapshot { rows: Vec<RenderRow>, cursor: Option<RenderCursor>, damage: Vec<usize> }, RenderRow { runs: Vec<StyledRun> }.
Internal stateNone beyond the snapshot itself; ideally a pure transformation, so it can be produced on any thread and sent to a renderer.
Dependenciesterminal-core.
Test strategySnapshot tests: a known screen produces a known run-list. Damage tests: after a single-cell write, exactly one row is reported dirty.
Platform independentYes.

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

FieldValue
ResponsibilityOpen 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 aboutHow escape sequences work. It should be possible to read this crate without knowing what CSI means.
Public APIA binary, mostly. If it has a library surface it is App::run(config).
Internal stateWindow, surface, font, glyph atlas, cell metrics, the Terminal, the Pty, selection state, scroll offset.
Dependencieswinit, softbuffer (CPU) and later optionally wgpu (GPU), fontdue or swash/cosmic-text, plus the internal crates.
Test strategyHardest 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 independentMostly, via winit. Font enumeration and clipboard are the platform-specific parts.

terminal-mux — sessions, panes, client/server

FieldValue
ResponsibilityOwn 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 aboutWindows, fonts, pixels, GPUs. Never depends on terminal-gui.
Public APITwo binaries (mux-server, mux-client) plus a library with SessionManager, Session, Window, Pane, Layout, and the protocol Request/Event enums.
Internal stateThe session tree, the socket listener, per-client state (attached session, client size, capabilities), the poll registry.
Dependenciesterminal-core, terminal-pty, terminal-protocol, serde + serde_json (framed JSON first) or a hand-rolled binary codec later, mio or plain poll.
Test strategyProtocol 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 independentUnix-oriented (Unix domain sockets). The session/layout logic itself is portable; keep the socket transport behind a small trait.

terminal-debugger — observability

FieldValue
ResponsibilityMake 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 aboutRendering. It is a text tool.
Public APIHexDump, 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 stateBuffers and a writer.
Dependenciesterminal-protocol, terminal-core.
Test strategyRound-trip tests on the record format; decoder tests on known sequences.
Platform independentYes (the replay half; the record half needs a PTY).

terminal-cli — the headless binary

FieldValue
ResponsibilityRun 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 aboutRendering, windowing.
Public APImini-term run --rows 24 --cols 80 -- bash -c 'printf "\033[31mred\033[0m\n"' → prints a snapshot; `--format text
Internal stateTrivial.
Dependenciesterminal-core, terminal-pty, terminal-debugger, clap.
Test strategyIt is a test strategy. Golden tests shell out to it.
Platform independentNeeds 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

ProblemWhy it bitesWhere you fix it
Cell.grapheme: StringA 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 screenMakes 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 + colCorrect 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 CursorYou 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 anywhereYou will redraw the whole screen every frame, which is fine at 80×24 and unusable at 400×100.Rendering & Damage
Style inline in every CellFine 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 fieldCorrect, 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

  1. For each of the nine crates, state its responsibility in one sentence and name one thing it must not know about.
  2. Which crate is allowed to be platform-specific, and why is it a virtue that it is the only one?
  3. Write the three cargo tree commands that mechanically enforce the boundaries.
  4. Why must terminal-mux never depend on terminal-gui? Give the concrete capability you lose.
  5. Name three problems with the Cell { grapheme: String, ... } sketch and say which milestone exposes each.
  6. 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?
  7. Where do terminal replies (the response to CSI 6n) come from, and why can terminal-core not just write them to the PTY itself?

Next: The Roadmap — fifteen milestones with concrete completion criteria.