# Terminal Engine Internals

> A deep look at cavrn, crystl's terminal engine: the byte-to-pixel pipeline, how it stays fair across many concurrent agent shards, its memory and reflow model, and why the GPU sits near-idle by design.

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](/docs/metal-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)
```

1. **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 across `write()` calls, so a sequence spanning
   two reads still parses).

2. **Snapshot.** The parser's state is materialized into an immutable
   `ScreenSnapshot`: a 2D array of `ScreenCell` (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.

3. **Compose.** `CellGridFrameBuilder` turns a snapshot + a *damage set* into a
   `PrismFrame`: 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 a `GlyphCache` atlas (CPU +
   GPU copies, LRU eviction). This stage is damage-gated — see below.

4. **Render.** `PrismRenderer` submits 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 `sysctl` process-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 N` or the `CRYSTL_SCROLLBACK_ROWS` env 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.

---
Source: https://crystl.dev/docs/terminal-internals/
