Separation of Concerns: Eight Jobs People Call "The Terminal"
When someone says "the terminal," they are referring to one of at least eight distinct jobs. Each has a different input, a different output, a different failure mode, and — critically — a different reason to change. Conflating any two of them is how terminal codebases become unmaintainable.
This chapter names all eight and states the boundaries precisely. It is the conceptual foundation for the workspace design and for Section 5.
The Eight Jobs
┌─────────────────────────────────────────────────────────────────────────┐
│ 8. APPLICATION EVENT HANDLING — tabs, config reload, menus, shortcuts │
├─────────────────────────────────────────────────────────────────────────┤
│ 6. RENDERING — cells → pixels; fonts, atlas, damage │
├─────────────────────────────────────────────────────────────────────────┤
│ 5. TEXT SHAPING — graphemes → positioned glyphs │
├─────────────────────────────────────────────────────────────────────────┤
│ 4. SCREEN GRID — the 2-D array of styled cells │
├─────────────────────────────────────────────────────────────────────────┤
│ 3. TERMINAL STATE — cursor, modes, scroll region, title │
├─────────────────────────────────────────────────────────────────────────┤
│ 2. ESCAPE-SEQUENCE PARSING — bytes → structured actions │
├─────────────────────────────────────────────────────────────────────────┤
│ 1. PTY TRANSPORT — moving opaque bytes │
├─────────────────────────────────────────────────────────────────────────┤
│ 7. SHELL EXECUTION — not yours at all; a separate process │
└─────────────────────────────────────────────────────────────────────────┘
1. PTY Transport
| Input | Bytes to send; readiness events |
| Output | Bytes received; resize acknowledgement; child exit |
| Owns | The master fd, the child pid, the window size, non-blocking I/O, buffering |
| Must not know | That 0x1b is special. If this layer contains the byte 0x1b, it is wrong. |
| Changes when | You port to a new OS (ConPTY), or change your I/O strategy |
| Crate | terminal-pty |
| Failure mode if merged | Merge it with parsing and you cannot test the parser without spawning a shell — which makes every test slow, flaky, and platform-dependent |
2. Escape-Sequence Parsing
| Input | A byte stream, in arbitrary chunks |
| Output | A stream of structured actions: Print(char), Execute(byte), CsiDispatch{params, intermediates, private, final}, EscDispatch, OscDispatch{params}, Hook/Put/Unhook |
| Owns | The state machine, the parameter accumulator, the intermediate buffer, the UTF-8 decoder |
| Must not know | What any sequence means. It reports CSI [2] J; it does not know that erases the screen. |
| Changes when | The VT grammar changes — which is to say, essentially never |
| Crate | terminal-protocol |
| Failure mode if merged | Merge it with state and you cannot fuzz it independently, cannot reuse it for a log colorizer, and every screen change risks a parser regression |
Note: This boundary is exactly the one
vte(Alacritty's parser crate) draws, and it is whyvtecan be used by projects that are not terminal emulators at all. The separation is not academic — it is what makes the component reusable.
3. Terminal State
| Input | Parsed actions |
| Output | Mutations to the screen; queued replies (DSR, DA, CPR) |
| Owns | Cursor position and style, saved cursor, mode flags, scroll region, tab stops, title, keyboard protocol level, active buffer |
| Must not know | Fonts, pixels, PTYs, threads, how bytes arrived |
| Changes when | You add support for a new mode or sequence |
| Crate | terminal-core |
| Failure mode if merged | Merge it with the grid and every state question becomes a grid question — resize, alt-screen switching, and scrollback all get harder |
4. Screen Grid
| Input | Cell writes, scrolls, erases, resizes |
| Output | Cells; damage information |
| Owns | rows × cols of styled cells, line wrap flags, scrollback, the alternate buffer |
| Must not know | What a CSI is. It exposes write_cell, scroll_up, erase_region — not handle_ED. |
| Changes when | You optimize the data structure (ring buffer, run-length runs, interning) |
| Failure mode if merged | Merge it with state and you cannot swap the storage strategy without touching escape-sequence handling |
Tip: The test for whether jobs 3 and 4 are properly separated: can you replace
Grid's flatVec<Cell>with a ring of line pointers without editing a single line of escape-sequence handling? If not, they are entangled.
5. Text Shaping
| Input | Graphemes and styles from cells |
| Output | Positioned glyph ids |
| Owns | Font fallback, ligatures (if any), combining-mark composition, bidi (if you dare) |
| Must not know | Escape sequences, PTYs |
| Changes when | You change font stacks or add a script |
| Crate | Inside terminal-gui, via swash/cosmic-text — or bypassed entirely with a monospace assumption |
Terminals sit awkwardly here. A grid says "this cell contains this grapheme at this column," which is a layout decision already made. General text shaping wants to decide positions itself. The reconciliation — shape per cell, or per run, with the grid as the authority on advance — is the hardest part of Section 3.
6. Rendering
| Input | A RenderSnapshot — cells, runs, cursor, selection, damage |
| Output | Pixels |
| Owns | Glyph rasterization, the atlas, the framebuffer or GPU pipeline, damage-limited redraw, frame timing |
| Must not know | Escape sequences, PTYs, processes |
| Changes when | You switch CPU→GPU, add a new backend, or optimize |
| Crate | terminal-gui (+ terminal-render-model as the boundary) |
7. Shell Execution
This is not your job at all. The shell is a separate program in a separate process. You do not
parse commands, you do not manage jobs, you do not know what cd is. You provide a PTY and get out
of the way.
The temptation to break this appears as: "I'll intercept cd to update the window title." Do not.
Use OSC 7 (the shell reports its own directory) — that is what it exists for.
8. Application Event Handling
| Input | User intent: keyboard shortcuts, menu clicks, config reload, new tab |
| Output | Commands to the layers below |
| Owns | Tabs/splits at the app level, configuration, keybindings, themes |
| Must not know | How a grid is stored |
| Changes when | Product decisions change — the most volatile layer, which is exactly why it must be outermost |
The Boundary Table
For each pair, the question "may A know about B?" has a definite answer.
| pty | protocol | state | grid | shaping | render | app | |
|---|---|---|---|---|---|---|---|
| pty | — | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| protocol | ❌ | — | ❌ | ❌ | ❌ | ❌ | ❌ |
| state | ❌ | ✅ | — | ✅ | ❌ | ❌ | ❌ |
| grid | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ |
| shaping | ❌ | ❌ | ❌ | ✅ (reads cells) | — | ❌ | ❌ |
| render | ❌ | ❌ | ✅ (reads) | ✅ (reads) | ✅ | — | ❌ |
| app | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | — |
Read the ❌s carefully — they are the design. protocol knows nothing about anything: that is why
it is reusable. pty knows nothing about anything: that is why it can be ported to Windows.
Worked Example: One Byte's Journey Through All Eight
ls writes \x1b[31mA\n. Follow it, naming the owner of each step.
LAYER 1 (pty transport)
read(master) → [0x1b, 0x5b, 0x33, 0x31, 0x6d, 0x41, 0x0d, 0x0a]
"here are 8 bytes" ← knows nothing else
LAYER 2 (parsing)
0x1b → state Ground→Escape
0x5b → state Escape→CsiEntry
0x33 → param accumulator = 3; state CsiParam
0x31 → param accumulator = 31
0x6d → final byte in 0x40..=0x7e → CsiDispatch(params=[31], final='m'); state→Ground
0x41 → Print('A')
0x0d → Execute(0x0d)
0x0a → Execute(0x0a)
"here are five actions" ← still does not know 31 means red
LAYER 3 (terminal state)
CsiDispatch([31], 'm') → SGR → cursor.style.fg = Color::Indexed(1)
Execute(0x0d) → CR → cursor.col = 0
Execute(0x0a) → LF → cursor.row += 1 (scroll if at the bottom of the region)
LAYER 4 (screen grid)
Print('A') with the current style → grid.write_cell(row, col, Cell{'A', style})
cursor advance; pending_wrap if at the last column
damage.mark_row(row)
LAYER 5 (shaping)
grapheme 'A' + style → glyph id 36 in the regular face, advance = cell_width
LAYER 6 (rendering)
glyph 36 at (col*cell_w, row*cell_h + baseline), foreground = palette[1]
blit only the damaged rows
LAYER 7 (shell)
not involved — `ls` already exited
LAYER 8 (app)
not involved — unless OSC 0 had changed the title, which would surface here
Every arrow crosses exactly one boundary. If your implementation has an arrow that skips a layer —
say, the renderer reaching into Grid's private Vec — that is the boundary that will rot first.
What Goes Wrong When You Merge Them
Real failure modes, each observed in real terminal codebases.
| Merge | The bug it produces |
|---|---|
| Parser + state | Adding a new mode risks breaking sequence parsing; no independent fuzzing; the parser cannot be reused |
| State + grid | Alternate-screen switching becomes a special case in twelve places instead of one |
| Grid + render | Changing to a ring buffer for O(1) scroll requires touching the renderer |
| Render + state | The renderer mutates the cursor to draw it; a redraw changes terminal state; screenshots differ from reality |
| PTY + parser | Every parser test needs a shell; tests become slow, flaky, and Linux-only |
| App + state | Config reload rebuilds the screen and loses scrollback |
| Mux + render | The mux cannot host a session with no display — which destroys detach/attach, the whole point |
That last row is the one that matters most. It is the mistake that makes a multiplexer impossible,
and it is why the dependency rule
forbids terminal-mux → terminal-gui mechanically rather than by convention.
The "Can I Still Do This?" Test
A boundary is real if these all remain possible. Check them after every refactor.
- Run a full terminal in a unit test with no PTY, no shell, no window.
-
Feed the parser 100 MB of
/dev/urandomand assert only "no panic." -
Compile
terminal-coreforwasm32-unknown-unknown. -
Replace
Grid's storage with a ring buffer without touching sequence handling. -
Swap the CPU renderer for a GPU renderer without touching
terminal-core. - Run 50 terminals in one process with no display.
- Render a screen snapshot to text, to JSON, and to pixels from the same state.
-
Reuse
terminal-protocolin a program that is not a terminal (a log colorizer).
If any of these has become hard, name the boundary that broke.
Validation / Self-check
- Name all eight jobs, and for each: its input, its output, and one thing it must not know.
- Why does the parser not know what
CSI 2 Jmeans? Name two concrete benefits. - Why must terminal replies be a buffer rather than a write to a file descriptor?
- Walk
\x1b[31mA\nthrough all eight layers, naming the owner of each step. - Which two layers are the hardest to separate in practice, and why?
- Give the concrete capability you lose by merging the multiplexer with the renderer.
- Which layer is the most volatile, and why must it be outermost?
- Run the eight-item "Can I Still Do This?" checklist against your current code. Which items fail?
Next: The Parser State Machine.