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

InputBytes to send; readiness events
OutputBytes received; resize acknowledgement; child exit
OwnsThe master fd, the child pid, the window size, non-blocking I/O, buffering
Must not knowThat 0x1b is special. If this layer contains the byte 0x1b, it is wrong.
Changes whenYou port to a new OS (ConPTY), or change your I/O strategy
Crateterminal-pty
Failure mode if mergedMerge 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

InputA byte stream, in arbitrary chunks
OutputA stream of structured actions: Print(char), Execute(byte), CsiDispatch{params, intermediates, private, final}, EscDispatch, OscDispatch{params}, Hook/Put/Unhook
OwnsThe state machine, the parameter accumulator, the intermediate buffer, the UTF-8 decoder
Must not knowWhat any sequence means. It reports CSI [2] J; it does not know that erases the screen.
Changes whenThe VT grammar changes — which is to say, essentially never
Crateterminal-protocol
Failure mode if mergedMerge 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 why vte can 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

InputParsed actions
OutputMutations to the screen; queued replies (DSR, DA, CPR)
OwnsCursor position and style, saved cursor, mode flags, scroll region, tab stops, title, keyboard protocol level, active buffer
Must not knowFonts, pixels, PTYs, threads, how bytes arrived
Changes whenYou add support for a new mode or sequence
Crateterminal-core
Failure mode if mergedMerge it with the grid and every state question becomes a grid question — resize, alt-screen switching, and scrollback all get harder

4. Screen Grid

InputCell writes, scrolls, erases, resizes
OutputCells; damage information
Ownsrows × cols of styled cells, line wrap flags, scrollback, the alternate buffer
Must not knowWhat a CSI is. It exposes write_cell, scroll_up, erase_region — not handle_ED.
Changes whenYou optimize the data structure (ring buffer, run-length runs, interning)
Failure mode if mergedMerge 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 flat Vec<Cell> with a ring of line pointers without editing a single line of escape-sequence handling? If not, they are entangled.

5. Text Shaping

InputGraphemes and styles from cells
OutputPositioned glyph ids
OwnsFont fallback, ligatures (if any), combining-mark composition, bidi (if you dare)
Must not knowEscape sequences, PTYs
Changes whenYou change font stacks or add a script
CrateInside 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

InputA RenderSnapshot — cells, runs, cursor, selection, damage
OutputPixels
OwnsGlyph rasterization, the atlas, the framebuffer or GPU pipeline, damage-limited redraw, frame timing
Must not knowEscape sequences, PTYs, processes
Changes whenYou switch CPU→GPU, add a new backend, or optimize
Crateterminal-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

InputUser intent: keyboard shortcuts, menu clicks, config reload, new tab
OutputCommands to the layers below
OwnsTabs/splits at the app level, configuration, keybindings, themes
Must not knowHow a grid is stored
Changes whenProduct 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.

ptyprotocolstategridshapingrenderapp
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.

MergeThe bug it produces
Parser + stateAdding a new mode risks breaking sequence parsing; no independent fuzzing; the parser cannot be reused
State + gridAlternate-screen switching becomes a special case in twelve places instead of one
Grid + renderChanging to a ring buffer for O(1) scroll requires touching the renderer
Render + stateThe renderer mutates the cursor to draw it; a redraw changes terminal state; screenshots differ from reality
PTY + parserEvery parser test needs a shell; tests become slow, flaky, and Linux-only
App + stateConfig reload rebuilds the screen and loses scrollback
Mux + renderThe 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/urandom and assert only "no panic."
  • Compile terminal-core for wasm32-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-protocol in 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

  1. Name all eight jobs, and for each: its input, its output, and one thing it must not know.
  2. Why does the parser not know what CSI 2 J means? Name two concrete benefits.
  3. Why must terminal replies be a buffer rather than a write to a file descriptor?
  4. Walk \x1b[31mA\n through all eight layers, naming the owner of each step.
  5. Which two layers are the hardest to separate in practice, and why?
  6. Give the concrete capability you lose by merging the multiplexer with the renderer.
  7. Which layer is the most volatile, and why must it be outermost?
  8. Run the eight-item "Can I Still Do This?" checklist against your current code. Which items fail?

Next: The Parser State Machine.