docs/superpowers/specs/2026-09-04-cells-on-the-wire-design.md
Ref: Size: 11.1 KiB History
# Cells on the wire — design 2026-09-04. Clean break: the replay frames change shape and number, and no daemon or client of an earlier version pairs with one of this. The migration is `mux d upgrade` on every box, which the release note says. ## Goal The client stops parsing VT. Today a byte of session output is parsed three times before a person sees it: the daemon's ghostty-vt (the authoritative grid), the client's ghostty-vt (the replica, fed the delta's bytes), and the host terminal (fed the client's paint). After this change the daemon's parse is the only one in the system: the wire carries the CELLS the daemon's grid holds, the client copies them into a grid of its own, and paints from that grid — to a terminal today, to a renderer when a native or browser client wants one. The client no longer links ghostty-vt at all; `mux_core.wasm` shrinks by the whole emulator. ## Findings the design rests on - **The delta row is painted bytes by construction.** `protocol.composeDelta` stamps `CUP` + `EL2` around a row `Engine.dumpVtRow` rendered as SGR, and `replica.apply` FEEDS that to a second engine (`replica.zig:82-87`). The rule-4 exemption on `protocol.zig` says so in its own words. Nothing else makes the client an emulator. - **The terminal client already passes rows through verbatim.** On the common path `paint.paintDeltaClipped` writes the daemon's row bytes to the tty unchanged (`paint.zig:194`) and re-dumps from the replica only for a row under the selection or an over-wide pane. So for the TERMINAL client this is a trade — a stateless serialize replaces a VT state machine — and the over-wide special case (a daemon row is grid-wide; a narrower pane's surplus lands on the neighbour) disappears, because every row now paints from the grid clipped to the pane. - **The browser painter already consumes cells.** `wasm_core.paintRow` reads ghostty's page cells into a 4×u32 record (codepoint, fg, bg, flags|wide) that `web/mux.js` draws on a canvas. The cell the wire carries is that record plus underline colour and the grapheme cluster; the JS side does not change. - **Modes and title are already a side channel.** `term_modes` (a u32 bitset: bracketed paste, the mouse modes) and `term_title` frames carry the state the client acts on; the replica engine is consulted for exactly two mode bits — `onAltScreen` and `cursorKeys` for the wheel-to-arrows rule (`interact.zig:1747`). Those two become bits of `term_modes`. - **The wire size is the open question.** A run-length styled row is roughly SGR-shaped, but dense plain text costs a length byte per cell that SGR does not pay. An `ascii` run form (one byte per cell, no header) closes most of that. The number is measured in Task 2 before the flip is built, and the stats counters (`snapshot_bytes`, `delta_bytes`, `make bench`) grade the real workload after. ## Design ### The wire Frame kinds keep their names and take NEW numbers, so a binary of either side that meets the other shows a blank tile and never garbage: an unknown type is dropped by both readers, while a VT reader fed cell bytes, or a cell reader fed VT, would paint noise. | kind | number | payload | |---|---|---| | `snapshot` | `0x95` | `SnapshotPrefix` (24 B, unchanged) ++ `u16 LE cursor_x` ++ `u16 LE cursor_y` ++ `rows` × `CellRow`, row 0 first | | `delta` | `0x96` | `DeltaHeader` (18 B, unchanged) ++ `row_count` × (`u16 LE row` ++ `u32 LE len` ++ `CellRow`) — the delta row header is unchanged | | `scrollback_chunk` | `0x97` | `u32 LE start` ++ `u16 LE count` ++ `count` × `CellRow` | `0x81`, `0x85`, `0x87` are retired with a comment and never reused. **`CellRow`** — `u16 LE ncells` then runs until `ncells` cells are read. Cells past `ncells` up to the grid width are default-style blanks, so the encoder stops at the last cell that is not one (a cell whose content is a bare background colour is NOT blank). A **run** is a header that carries only what CHANGED since the previous run of the same row — the shape SGR has, which is why VT rows are small — then `count` cells. The first run of a row starts from the default style; a row is self-contained, so nothing carries between rows or frames. - `u16 LE count` ++ `u8 mask` ++ the present fields in order. `mask` bit 0: `flags` present, `u16 LE` follows; bit 1: `fg` present; bit 2: `bg` present; bit 3: `ul` present; bit 7: `ascii`. A present colour is `u8 code` — 0 none, 1 palette then one index byte, 2 RGB then three bytes. A default first run is 3 bytes; a palette foreground change is 5. (The first cut was a 16-byte absolute header, and the measurement gate refused it: vim 1.45×, htop 1.56× — see decisions.md, 2026-09-04.) - `flags` bits 0–10 are ghostty's `Style.Flags` verbatim — bold 0, italic 1, faint 2, blink 3, inverse 4, invisible 5, strikethrough 6, overline 7, underline style 8–10 (none 0, single 1, double 2, curly 3, dotted 4, dashed 5). `ascii` means every cell of the run is ONE byte in `0x20..0x7E`, narrow, with no per-cell header. - In memory (`CellStyle`, the grid, the wasm viewport) colours stay packed as `wasm_core.packColor` does today: `0` none, `1<<24 | index` palette, `2<<24 | r<<16 | g<<8 | b` RGB; the wire spells the same three cases as the code byte above. - A non-`ascii` cell is `u8 head` = `wide << 6 | text_len` then `text_len` bytes of UTF-8 — the whole grapheme cluster, capped at 63 bytes (the encoder keeps the leading codepoints that fit). `wide` is 0 narrow, 1 wide, 2 spacer_tail, 3 spacer_head; a spacer has `text_len` 0. An empty text is a blank glyph in that style. The delta tracker hashes the encoded `CellRow`, so "this row changed" on the daemon means "these bytes differ" on the wire. ### `term_modes` gains two bits `alt_screen` (the alternate screen is active) and `cursor_keys` (DECCKM). The daemon samples them where it samples bracketed paste (`Server.sampleTermModes`); the client's wheel rule reads them from `client_core`'s modes instead of from an engine. ### The client grid — `src/engine/grid.zig`, a child of `term` Platform-free like `replica.zig`: no posix, no clocks, compiles for wasm32-freestanding. `Grid` is `cols × rows` of `Cell` (`style`, `wide`, and a slice into the row's own text buffer), a cursor, and: - `applyRow(y, bytes)` — decode one `CellRow` into row `y`; - `decodeRows(alloc, bytes, count, cols)` — a dense run of rows (snapshot body, scrollback chunk) as `[]Row`, for the caller to own; - `dumpPlain(alloc)` — the text `Engine.dumpPlain` produces for the same screen, byte for byte, which is what every harness compares; - `clipCol` and `snapWide` — the two wide-glyph rules `engine.zig` holds today, on the `wide` flag instead of a page pin. `Replica` holds a `*Grid` instead of an `*Engine`. A snapshot resizes, clears, sets the cursor and applies `rows` dense rows; a delta applies each listed row and sets the cursor. A snapshot whose body will not decode is `error.BadPayload` out of `apply` — the pump ends the tile with it — never a `.resync`, because a resync re-attaches and reads the same bytes again. ### Painting from the grid — `src/tui/paint.zig` `rowToVt(alloc, row, view, span)` is the one serializer: it walks the row's cells to `clipCol`, emits an SGR when the style changes, writes a wide glyph once and skips its spacer, writes a space for an empty text, and stops at the last non-default cell (the caller's ECH has cleared the rest). A `span` is painted inverted and plain, snapped outward to whole glyphs, exactly as `Engine.dumpVtRowSpan` does today. `renderClipped`, `paintDeltaClipped` and `renderScrollback` call it; the verbatim branch and the over-wide branch of `paintDeltaClipped` go, because there is no daemon-rendered row to pass through any more. ### The daemon — `src/engine/engine.zig`, `delta.zig`, `server.zig` `Engine.encodeViewportRow(alloc, y)` and `Engine.encodeScrollback(alloc, start, count)` read ghostty's page cells (content, `page.styles`, `lookupGrapheme`, `wide`) into `CellRow` bytes. `delta.buildSnapshot` composes prefix + cursor + rows; `DeltaTracker` hashes encoded rows; `Server.buildSnapshotPayload`, `accrueSnapshotEquiv` and `onFetchScrollback` call them. `dumpState` stays — it is how a daemon carries its own state across `mux d upgrade`'s exec, and what `debug_dump` prints — and so do `dumpVt`/`dumpPlain` (the e2e render oracle, `mux d dump`). `dumpVtRow`, `dumpVtRowClipped`, `dumpVtRowSpan`, `dumpScrollback` and `protocol.composeDelta` are deleted with the exemption line that explained them. ### The module table `term` (`term.zig` → `protocol`, `replica`, `grid`) no longer imports ghostty-vt and stays the wasm root. A new row `engine` (`engine.zig` → `delta`) imports `term` and links ghostty-vt. `daemon` and the e2e `render` fixture import `engine`; `client`, `wall`, `webhub`, `wsclient` and the wasm core import only `term`. Tests that author a screen still do it through an `Engine` — `Engine.mirrorInto(grid)` encodes every row into a `Grid` — so `wall`, `client` and `wsclient` carry `engine` as a TEST import and the production client links no emulator. The ghostty wasm dependency is removed from `build.zig`. ## What this removes The client-side VT parse; the `protocol.zig` rule-4 exemption; the verbatim-versus-redump branch in `paintDeltaClipped` and its "only safe while the pane is as wide as the grid" rule; the scratch `Engine` the wasm core keeps for scrollback; ghostty-vt from every client binary and from the wasm build. ## The measurement gate Task 2 encodes four synthetic screens both ways (dense prose, a vim-like screen, an htop-like screen, a mostly-blank shell) and prints bytes and ratio. The flip (Task 5) is built only if dense prose is ≤ 1.5× the VT rows and the styled screens ≤ 1.2×; otherwise the branch stops there and reports, because the alternative plan — VT frames for terminal clients, cells only for native and browser ones — is a different and smaller project. After the flip, `make bench` on `main` and on this branch records the real numbers in `docs/decisions.md`. ## Testing - `protocol.zig`: golden bytes for every cell form and every malformed shape. - `engine.zig`: the encoder round-trips through the decoder for ASCII, wide, grapheme, bg-only, styled and trailing-blank rows; the ORACLE test feeds VT into an `Engine`, mirrors it into a `Grid`, and compares `dumpPlain` and the cursor — the engine is the oracle, the grid must agree. - `server_test_*`: unchanged in intent; the harness's replica is a `Grid`. - `paint.zig`, `interact.zig`, `wall_test_*`: the pins that exist, with screens authored through `mirrorInto`. The span-bounded clear and `col_off` pins must each fire once against the new serializer. - `make ci` is the delivery gate. `make xversion` has no old side to grade until the next release and is out of this branch's gate, recorded in `docs/decisions.md`. ## Deferred - OSC 8 hyperlinks: the wire has no field; today's formatter emits none on the delta path either, so parity holds. - The JS painter draws a cell's first codepoint; the wire now carries the whole cluster, so the canvas can draw it when someone wants to. - A native client. This change is what makes one cheap to write.