Terminal Engine Internals
Updated July 21, 2026
Most terminals are built for one person at one prompt. crystl is built for a room full of agents, most of them working while you’re not looking at them. That single design choice explains almost everything below.
This is the deep-dive companion to Terminal GPU Rendering, which covers the same engine — cavrn — at a feature level. Here we go under the hood.
Built for an orchestra, not a soloist
Ghostty, Kitty, and Alacritty are superb at one thing: pushing a single stream
as fast as the hardware allows — minimum keystroke-to-glyph latency, maximum cat a-huge-file throughput. They’re built for a soloist: one performer, one
instrument, played as fast and clean as possible. If you drive one terminal by
hand, that’s the number that matters, and they win it.
crystl is built for an orchestra. A window routinely holds a dozen or more shards — independent terminal sessions, each usually running an agent — and most of them are hidden behind the one you’re looking at. Every hidden session is still alive: parsing its PTY, being watched for “is the agent waiting on me?”, and being recorded into a per-shard timeline. The hard problem isn’t playing one part brilliantly. It’s keeping twenty players in time — responsive, fairly scheduled, memory-bounded — while several flood output at once, with the bridge acting as conductor over the whole section.
So the engine is designed around fairness and coordination across N sessions rather than peak single-stream speed. The rest of this page is how that shows up in the actual code.
The pipeline: byte to pixel
cavrn’s shipping render path is four stages. Its Prism renderer draws on the GPU with Metal; a vendored fork of SwiftTerm does the VT parsing.
PTY bytes → SwiftTerm parser → grid snapshot → FrameBuilder (compose) → Metal (Prism)
(VT state machine) (cells + damage) (CPU, damage-gated) (GPU, 3 passes)
-
Parse. PTY bytes are fed to a vendored, heavily-patched fork of SwiftTerm — a full xterm-class VT state machine — which maintains the grid, scrollback, cursor, modes, and alternate screen. crystl-specific OSC sequences are split out of the byte stream first by an
OSCSequenceExtractor(payloads up to 4 MB; stateful acrosswrite()calls, so a sequence spanning two reads still parses). -
Snapshot. The parser’s state is materialized into an immutable
ScreenSnapshot: a 2D array ofScreenCell(grapheme + display width 1/2 for CJK + style + optional OSC 8 hyperlink), plus scrollback rows and cursor. Snapshots are memoized — built once per write batch and reused for every read until the next mutation, so the several consumers (the renderer, the bridge, the chat dock) don’t each rebuild it. -
Compose.
CellGridFrameBuilderturns a snapshot + a damage set into aPrismFrame: background colors (one RGBA per cell), glyph instances, rectangle instances (underlines/decorations), and an overlay layer (cursors). Glyphs are rasterized on demand via CoreText into aGlyphCacheatlas (CPU + GPU copies, LRU eviction). This stage is damage-gated — see below. -
Render.
PrismRenderersubmits three Metal raster passes — background, rectangles, glyphs+overlay — sampling the glyph atlas. There are no compute shaders; the GPU only draws textured quads.
Damage is the whole trick
Redrawing every cell every frame would be wasteful with one terminal and
catastrophic with twenty. Every mutation escalates a tri-state ScreenDamage:
.none— nothing changed; the renderer reuses the previous frame verbatim..rows(IndexSet)— only these viewport rows are re-composed; the row cache serves the rest..full— resize, screen switch, palette change, or clear; rebuild everything.
Damage is raise-only: it can escalate but never silently downgrade, and only
the consumer that reads it (takeScreenDamage()) resets it. This is what lets a
shard that printed one new line cost one row of compose work instead of a full
screen.
Concurrency and fairness (the part that’s actually different)
This is where the orchestra thesis lives in code.
Unbounded sessions, gated rendering. A window’s shard count has no hard limit
— it’s an ordinary array, capped only by OS resources (PTYs, memory). When a
shard isn’t the visible one, hiding it flips renderingVisible = false, which
gates every present path: no Metal submission, no cursor blink, no damage
watchdog, and its transient GPU swap-chain surfaces are freed. The hidden shard
keeps parsing its PTY (so it’s instantly correct when you switch to it) but
does zero GPU work. A finished agent three shards over is ready the moment you
click it — it was never frozen, just not drawn.
Occlusion and app-background gating. The same render gate observes window occlusion and app activation, with a 50 ms debounce so alt-tabbing doesn’t thrash the gate. Cover the window and the visible shard stops presenting too.
Off-main, serialized background work. The two jobs that scale with shard count — agent detection and timeline ingest — are deliberately kept off the main thread and serialized so they can’t stampede:
- Agent detection runs on a 2 s timer on a background queue. It snapshots
session state on the main thread, then does the heavy
sysctlprocess-tree walk off-main, and marshals results back. A coalescing guard drops a new scan if one is already in flight — so a fork/exec storm in one shard can’t pile up scans for all of them. - Timeline ingest (parsing each agent’s transcript into history) runs on a single serial sweep queue, debounced to once per 30 s per project (120 s for remote hosts), with a per-entry autorelease pool so a big sweep doesn’t balloon the queue’s memory. One flooding shard’s ingest can’t starve the others because they all share one orderly lane.
The net effect: a shard running yes at full tilt burns its own parse budget,
but the other nineteen keep their input latency because the shared work is
gated, coalesced, and serialized.
Every stream is recorded, not just drawn
A normal terminal throws bytes away once they’re on screen. crystl consumes each stream twice: once to pixels, once to a durable, attributed timeline.
- Each shard has a stable ID. crystl exports it into the shard’s shell and pins the agent’s conversation to a deterministic session id, so a transcript can always be mapped back to the shard that produced it — by instance, not by name.
- Completed shell commands are captured live via an OSC sequence emitted by crystl’s shell integration (cwd, command, exit).
- Agent turns are swept from the transcript on disk into a shard-keyed SQLite store, tagged with both shard id and session id. This is what powers cross-shard history search and per-shard cost/token rollups.
- A prompt-wait detector scans the visible buffer shortly after output settles to catch hook-less TUI prompts (an agent’s startup dialog), so a silently paused fan-out worker still surfaces as ”⏸ awaiting input” without needing an agent hook to fire.
None of this exists in a single-user terminal because a single user is the supervisor. With a full section playing at once, the terminal has to be the one keeping time.
Memory: bounded for many buffers, not one
Scrollback caps in crystl aren’t a stinginess knob — they exist because you’re holding N live buffers, and unbounded history times N is how you leak gigabytes over a multi-day session.
- Default scrollback is 2,000 rows per shard, clamped to
[200, 10,000]. - Quest-party shards default lower (1,000 rows) — a fan-out of agents multiplies buffer cost, so parties trade history depth for headroom.
- Tunable per shard via
--scrollback Nor theCRYSTL_SCROLLBACK_ROWSenv var. - The live event log carries its own independent budgets; when they’re exceeded the oldest events drop and a dropped-count is kept, so consumers can detect the gap rather than silently believe they have everything.
- Clearing scrollback (
Cmd+K) actually frees the buffer — it isn’t a cosmetic screen-wipe. The shard’s turns remain in history search regardless, because those live in the timeline database, not the scrollback.
Reflow and the frozen-scrollback model
Resize is the classic terminal-CPU spike: every retained line may have to re-wrap to the new column count. cavrn treats scrollback width as immutable by default rather than re-flowing everything.
Reflow is a per-policy choice:
- Freeze — the default. Completed lines keep the width they were printed at; nothing re-wraps. A logical line is frozen as a unit (its full soft-wrap chain), not as physical rows, so wrapped prose stays coherent.
- Preserve hard lines — a hybrid: program-authored layouts (tables, box drawing) freeze at capture width and render clipped, while soft-wrapped prose re-joins and re-folds to the new width. Box-drawing content is detected and kept as-printed so tables don’t shatter.
- Reflow — re-wrap all scrollback (classic behavior); available in Settings → general → terminal rendering for a plain shell.
Freezing sidesteps the reflow-scatter class of bugs entirely for history, and confines live re-wrap to the viewport. A recent refinement (a deferred-lines tier) captures lines that scroll off during a synchronized redraw (DEC mode 2026) and flushes them in order once the redraw settles, so an agent repainting its whole screen doesn’t drop or reorder scrollback.
Why the GPU sits near-idle (and that’s correct)
If you watch crystl in a GPU monitor you’ll see ~1–2%. That’s not underuse — it’s the sign of a healthy terminal renderer.
A terminal frame is a few thousand textured quads (one per visible cell). Any Apple GPU finishes that in microseconds and idles until the next frame — and most of the time nothing changed, so no frame is drawn at all. Compare a game shading millions of pixels every frame. A terminal that pinned the GPU would be the bug (a busy-loop redrawing unchanged content).
The real cost is CPU, and it’s not evenly spread. Under a heavy flood, the dominant cost by far is scrollback composition — parsing and placing cells, not drawing them. The levers that matter are therefore algorithmic: damage-gated compose, incremental scrollback (append/trim instead of full recompose), snapshot memoization. None of that is a job for the GPU.
“Can’t you move CPU work onto the GPU?” In principle, via Metal compute shaders — but it rarely pays for a terminal. The hot paths (VT parsing, reflow, diffing) are sequential and branch-heavy, which is exactly what GPUs are bad at; the data batches are small, so dispatch and readback overhead dominate; and what you actually feel is latency (echo this keystroke now), which GPU offload trades away for throughput. The one genuinely parallel piece — rasterizing glyphs — is already GPU/atlas work.
A useful mental model: on macOS, process CPU% is summed per core, so 100% is one core fully busy and a 10-core machine tops out at 1000%. A single-threaded job (like sequential VT parsing) can’t exceed ~100% no matter how many cores you have — so a shard pinned at exactly 100% is the tell that it’s bottlenecked on one thread, while crystl above 100% means several of its queues are working in parallel.
VT and compatibility
Parsing is handled by the vendored SwiftTerm fork, so cavrn supports the xterm-class feature set you’d expect: 16/256/truecolor SGR, bold/italic/underline (including underline color and style) / inverse / strikethrough, the alternate screen, application cursor/keypad modes, all the common mouse-reporting encodings (X10, UTF-8, SGR, urxvt, SGR-pixel), cursor visibility and style, bracketed paste, and synchronized output (DEC 2026).
On top of standard VT, crystl adds its own OSC channels: OSC 8 hyperlinks (clickable, URI stored per cell), plus private sequences for command metadata (cwd/command/exit) and structured block markers that feed the timeline.
Honest limitations
What cavrn doesn’t do (yet):
- Hidden shards still pay the publish walk. Rendering is gated when a shard is hidden, but the engine still publishes snapshots at ~30 Hz regardless of visibility, and there’s no QoS de-prioritization for backgrounded shards yet. A very large fan-out pays some idle compose cost for shards you can’t see.
- Selected-shard-while-window-occluded has a narrower gate than fully-hidden shards — an area still being tightened.
- Frozen scrollback is a deliberate trade. History doesn’t re-wrap on resize by default. That’s a correctness/perf win (no reflow scatter, tables stay intact) but it means very old lines keep their original width rather than filling a newly-widened window. The preserve-hard-lines and reflow policies exist for users who want the other trade.
- Renderer resize scatter on the live viewport is a known, worked-on class of bug in the cell-placement/reflow path (not a GPU issue) — transient and clears on redraw, but real.