docs/superpowers/plans/2026-09-04-cells-on-the-wire.md
Ref: Size: 77.3 KiB History
# Cells on the Wire Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The replay frames carry the daemon's grid as cells, the client copies them into a grid of its own and paints from it, and no client binary links ghostty-vt.
**Architecture:** `protocol.zig` gains a `CellRow` codec (runs of styled, length-prefixed UTF-8 cells). `engine.zig` encodes ghostty's page cells into it on the daemon; a new `grid.zig` decodes into a flat client grid that `Replica` owns; `paint.zig` serializes grid rows to VT for the terminal, and the wasm painter reads the grid where it read ghostty's pages. `term` stops importing ghostty-vt; a new `engine` module row carries it for the daemon and the test oracles.
**Tech Stack:** Zig 0.15.2 (`deps/zig/zig`, vendored), ghostty-vt 1.3.0 (daemon side only after this), `zig build test`, `make check`, `make e2e`, `make bench`.
**Spec:** `docs/superpowers/specs/2026-09-04-cells-on-the-wire-design.md`
## Global Constraints
- Toolchain is `./deps/zig/zig`; system zig does not build this. `make check` before every commit; capture `$?` before piping (`make check; echo rc=$?`).
- There is no `-Dtest-filter` and `zig test FILE` does not link here: run the whole suite, `./deps/zig/zig build test 2>&1 | tail -30`. A `zig build test` that prints nothing for minutes is a test writing to stdout (fd 1 is the runner's protocol stream) — use `std.debug.print` (stderr) in measurement tests, never stdout.
- Commit subjects are `type: what changed`, type ∈ `feat fix refactor test docs build chore`, no parenthesised scope. Intermediate commits inside Task 5 may be `--fixup`; autosquash before delivery.
- `build.zig` rule 4: outside a `test` block, no file under `src/engine/` or `src/client/` spells `\x1b`, `termios`, `isatty`, `tcgetattr`, `tcsetattr` without a `// folder rule 4 exemption:` line. `grid.zig` and the new protocol code must carry no escape byte in production lines. Rule 7: nothing outside `src/os/` spells `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, `SO_PEERCRED`, `MSG_NOSIGNAL`.
- Comments say why; every cited symbol must resolve (`zig build check` gates references).
- Any hand-run rig exports an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` first.
- Never quote a speed number from the dev tree; byte counts are fine.
- Frame numbers: `snapshot = 0x95`, `delta = 0x96`, `scrollback_chunk = 0x97`; `0x81`, `0x85`, `0x87` retired and never reused.
- Colour packing in memory: `0` none, `(1 << 24) | index` palette, `(2 << 24) | r << 16 | g << 8 | b` RGB. Style flags bits 0–10 = ghostty `Style.Flags` bit order. Cell head = `wide << 6 | text_len`, `text_len ≤ 63`.
- Run header (revised 2026-09-04 after the Task 2 gate refused the 16-byte absolute form): `u16 LE count` ++ `u8 mask` ++ present fields — mask bit0 `flags` (u16 LE follows), bit1 `fg`, bit2 `bg`, bit3 `ul`, bit7 `ascii`; a present colour is `u8 code` (0 none, 1 palette + 1 index byte, 2 RGB + 3 bytes). Absent fields are UNCHANGED from the previous run of the same row; the first run of a row starts from the default style. Task 1's golden bytes were rewritten to this form in Task 2's fix round.
---
## File map
| File | Responsibility after this plan |
|---|---|
| `src/engine/protocol.zig` | Frame numbers, `CellRow` codec (`CellRowWriter`, `CellRowReader`, `CellStyle`, `Wide`, colour packing), snapshot cursor, `TermModes` bits. No engine, no ghostty. |
| `src/engine/grid.zig` (new) | `Grid`, `Row`, `Cell`; `applyRow`, `decodeRows`, `dumpPlain`, `clipCol`, `snapWide`. Platform-free, wasm-clean. |
| `src/engine/replica.zig` | `Replica` over `*Grid`. |
| `src/engine/term.zig` | Root of `term`: `protocol`, `replica`, `grid`. |
| `src/engine/engine.zig` | Root of the new `engine` module: ghostty wrapper, `encodeViewportRow`, `encodeScrollback`, `mirrorInto`. |
| `src/engine/delta.zig` | Child of `engine`: tracker hashes encoded rows; `buildSnapshot`. |
| `src/server/server.zig` | Three call sites: `buildSnapshotPayload`, `accrueSnapshotEquiv`, `onFetchScrollback`; `sampleTermModes` gains two bits. |
| `src/tui/paint.zig` | `rowToVt` serializer; renders take `*const Grid`. |
| `src/tui/interact.zig` | `Core.rep` over a `Grid`; scrollback pages are decoded rows; wheel rule reads `term_modes`. |
| `src/client/wasm_core.zig` | Grid-backed viewport and scroll view; no `Engine`. |
| `test/wsclient.zig` | Replica over a `Grid`. |
| `src/server/server_test_harness.zig`, `src/tui/wall_test_harness.zig` | Replica side is a `Grid`; screens authored through `Engine.mirrorInto`. |
| `build.zig` | `engine` row; `term` drops ghostty; wasm drops ghostty. |
| `docs/decisions.md`, `CLAUDE.md`, `README.md` | The record, the table, the wire sentence. |
---
### Task 1: The `CellRow` codec in `protocol.zig`
**Files:**
- Modify: `src/engine/protocol.zig` (append after the `DeltaRowIterator` block, before `ComposedDelta`)
- Test: `src/engine/protocol.zig` (tests at the end of the file)
**Interfaces:**
- Produces:
- `pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 }`
- `pub const CellStyle = struct { fg: u32 = 0, bg: u32 = 0, ul: u32 = 0, flags: u16 = 0 }` with `eql` and `isDefault`
- `pub fn colorPalette(index: u8) u32`, `pub fn colorRgb(r: u8, g: u8, b: u8) u32`, `pub const color_none: u32 = 0`
- `pub const run_ascii: u16 = 1 << 15`, `pub const run_header_len = 16`, `pub const cell_row_prefix_len = 2`, `pub const cell_text_max = 63`
- `pub const CellRowWriter` — `begin(list, alloc) !CellRowWriter`, `cell(style, wide, text) !void`, `finish() void`
- `pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 }`
- `pub const CellRowReader` — `init(bytes) !CellRowReader`, `next() !?DecodedCell`, `remaining() []const u8`, `ncells: u16`
- [ ] **Step 1: Write the failing golden tests**
Append to `src/engine/protocol.zig`:
```zig
test "cellrow: two default ascii cells are one ascii run" {
const alloc = std.testing.allocator;
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "a");
try w.cell(.{}, .narrow, "b");
w.finish();
// ncells=2; run: count=2, flags=ascii, fg=bg=ul=0; then "ab".
try std.testing.expectEqualSlices(u8, &[_]u8{
2, 0, // ncells
2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header
'a', 'b',
}, list.items);
}
test "cellrow: a wide glyph and its spacer, a grapheme, and a styled run round-trip" {
const alloc = std.testing.allocator;
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
const red: CellStyle = .{ .fg = colorPalette(1), .flags = 1 }; // bold red
var w = try CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .wide, "漢");
try w.cell(.{}, .spacer_tail, "");
try w.cell(.{}, .narrow, "e\u{301}");
try w.cell(red, .narrow, "x");
try w.cell(red, .narrow, "");
w.finish();
var r = try CellRowReader.init(list.items);
try std.testing.expectEqual(@as(u16, 5), r.ncells);
const c0 = (try r.next()).?;
try std.testing.expectEqual(Wide.wide, c0.wide);
try std.testing.expectEqualStrings("漢", c0.text);
const c1 = (try r.next()).?;
try std.testing.expectEqual(Wide.spacer_tail, c1.wide);
try std.testing.expectEqualStrings("", c1.text);
const c2 = (try r.next()).?;
try std.testing.expectEqualStrings("e\u{301}", c2.text);
const c3 = (try r.next()).?;
try std.testing.expect(c3.style.eql(red));
try std.testing.expectEqualStrings("x", c3.text);
const c4 = (try r.next()).?;
try std.testing.expect(c4.style.eql(red));
try std.testing.expectEqualStrings("", c4.text);
try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
try std.testing.expectEqual(@as(usize, 0), r.remaining().len);
}
test "cellrow: an ascii run is only taken when every cell qualifies" {
const alloc = std.testing.allocator;
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "a");
try w.cell(.{}, .narrow, "é"); // 2 bytes: breaks the ascii form for the whole run
w.finish();
// ncells=2; run: count=2, flags=0; cells: head 1 'a', head 2 0xC3 0xA9
try std.testing.expectEqualSlices(u8, &[_]u8{
2, 0,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 'a', 2, 0xC3, 0xA9,
}, list.items);
}
test "cellrow: the reader leaves the bytes after the row alone" {
const alloc = std.testing.allocator;
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "a");
w.finish();
try list.appendSlice(alloc, "tail");
var r = try CellRowReader.init(list.items);
_ = try r.next();
try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
try std.testing.expectEqualStrings("tail", r.remaining());
}
test "cellrow: malformed rows are BadPayload, never a read past the end" {
// A run that claims more cells than the row's ncells: refused at the
// run header, before a cell is read.
var over = try CellRowReader.init(&[_]u8{ 1, 0, 2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'a', 'b' });
try std.testing.expectError(error.BadPayload, over.next());
// A cell whose text_len runs past the payload.
var short_text = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 'a' });
try std.testing.expectError(error.BadPayload, short_text.next());
// A row shorter than its own prefix.
try std.testing.expectError(error.BadPayload, CellRowReader.init(&[_]u8{1}));
// A row that ends mid-run header.
var mid_header = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0 });
try std.testing.expectError(error.BadPayload, mid_header.next());
}
```
- [ ] **Step 2: Run the suite to see them fail**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: compile errors naming `CellRowWriter`, `CellRowReader`, `CellStyle`.
- [ ] **Step 3: Implement the codec**
Insert into `src/engine/protocol.zig` after `deltaRowIterator`:
```zig
// ---------------------------------------------------------------------------
// CellRow: one grid row as runs of styled cells. The client copies these into
// a grid and never parses VT; the daemon's ghostty is the only parser left.
// A row ends at the last cell that is not a default-style blank, and the
// reader's caller fills the rest of the width with blanks.
pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 };
pub const color_none: u32 = 0;
pub fn colorPalette(index: u8) u32 {
return (1 << 24) | @as(u32, index);
}
pub fn colorRgb(r: u8, g: u8, b: u8) u32 {
return (2 << 24) | (@as(u32, r) << 16) | (@as(u32, g) << 8) | @as(u32, b);
}
pub const CellStyle = struct {
fg: u32 = color_none,
bg: u32 = color_none,
ul: u32 = color_none,
/// ghostty Style.Flags bit order: bold 0, italic 1, faint 2, blink 3,
/// inverse 4, invisible 5, strikethrough 6, overline 7, underline 8-10.
flags: u16 = 0,
pub fn eql(a: CellStyle, b: CellStyle) bool {
return a.fg == b.fg and a.bg == b.bg and a.ul == b.ul and a.flags == b.flags;
}
pub fn isDefault(self: CellStyle) bool {
return self.eql(.{});
}
};
/// Run flag bit: every cell in the run is one byte 0x20..0x7E, narrow, and
/// carries no head byte. Dense text is what a wire pays for most.
pub const run_ascii: u16 = 1 << 15;
pub const run_header_len = 16;
pub const cell_row_prefix_len = 2;
pub const cell_text_max = 63;
pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 };
fn asciiCell(wide: Wide, text: []const u8) bool {
return wide == .narrow and text.len == 1 and text[0] >= 0x20 and text[0] <= 0x7E;
}
pub const CellRowWriter = struct {
list: *std.ArrayList(u8),
alloc: std.mem.Allocator,
prefix_at: usize,
ncells: u16 = 0,
/// The open run's cells, held back until the run closes so the ascii
/// decision is made over the whole run.
run: std.ArrayListUnmanaged(DecodedCell) = .empty,
run_style: CellStyle = .{},
pub fn begin(list: *std.ArrayList(u8), alloc: std.mem.Allocator) !CellRowWriter {
const at = list.items.len;
try list.appendSlice(alloc, &[_]u8{ 0, 0 });
return .{ .list = list, .alloc = alloc, .prefix_at = at };
}
pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, text: []const u8) !void {
std.debug.assert(text.len <= cell_text_max);
if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush();
if (self.run.items.len == 0) self.run_style = style;
try self.run.append(self.alloc, .{ .style = style, .wide = wide, .text = text });
self.ncells += 1;
}
fn flush(self: *CellRowWriter) !void {
const cells = self.run.items;
if (cells.len == 0) return;
var ascii = true;
for (cells) |c| {
if (!asciiCell(c.wide, c.text)) {
ascii = false;
break;
}
}
var hdr: [run_header_len]u8 = undefined;
std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little);
std.mem.writeInt(u16, hdr[2..4], self.run_style.flags | (if (ascii) run_ascii else 0), .little);
std.mem.writeInt(u32, hdr[4..8], self.run_style.fg, .little);
std.mem.writeInt(u32, hdr[8..12], self.run_style.bg, .little);
std.mem.writeInt(u32, hdr[12..16], self.run_style.ul, .little);
try self.list.appendSlice(self.alloc, &hdr);
for (cells) |c| {
if (ascii) {
try self.list.append(self.alloc, c.text[0]);
} else {
const head: u8 = (@as(u8, @intFromEnum(c.wide)) << 6) | @as(u8, @intCast(c.text.len));
try self.list.append(self.alloc, head);
try self.list.appendSlice(self.alloc, c.text);
}
}
self.run.clearRetainingCapacity();
}
/// Closes the open run and stamps ncells. The writer is spent after this.
pub fn finish(self: *CellRowWriter) void {
self.flush() catch |e| switch (e) {
error.OutOfMemory => @panic("CellRowWriter.finish: out of memory"),
};
std.mem.writeInt(u16, self.list.items[self.prefix_at..][0..2], self.ncells, .little);
self.run.deinit(self.alloc);
}
};
pub const CellRowReader = struct {
rest: []const u8,
ncells: u16,
read: u16 = 0,
run_left: u16 = 0,
run_style: CellStyle = .{},
run_ascii: bool = false,
pub fn init(bytes: []const u8) !CellRowReader {
if (bytes.len < cell_row_prefix_len) return error.BadPayload;
return .{
.rest = bytes[cell_row_prefix_len..],
.ncells = std.mem.readInt(u16, bytes[0..2], .little),
};
}
pub fn next(self: *CellRowReader) !?DecodedCell {
if (self.read == self.ncells) return null;
if (self.run_left == 0) {
if (self.rest.len < run_header_len) return error.BadPayload;
const count = std.mem.readInt(u16, self.rest[0..2], .little);
const flags = std.mem.readInt(u16, self.rest[2..4], .little);
// A run may not claim cells the row does not have.
if (count == 0 or count > self.ncells - self.read) return error.BadPayload;
self.run_left = count;
self.run_ascii = flags & run_ascii != 0;
self.run_style = .{
.flags = flags & ~run_ascii,
.fg = std.mem.readInt(u32, self.rest[4..8], .little),
.bg = std.mem.readInt(u32, self.rest[8..12], .little),
.ul = std.mem.readInt(u32, self.rest[12..16], .little),
};
self.rest = self.rest[run_header_len..];
}
self.run_left -= 1;
self.read += 1;
if (self.run_ascii) {
if (self.rest.len < 1) return error.BadPayload;
const text = self.rest[0..1];
self.rest = self.rest[1..];
return .{ .style = self.run_style, .wide = .narrow, .text = text };
}
if (self.rest.len < 1) return error.BadPayload;
const head = self.rest[0];
const len: usize = head & 0x3f;
if (self.rest.len < 1 + len) return error.BadPayload;
const text = self.rest[1 .. 1 + len];
self.rest = self.rest[1 + len ..];
return .{ .style = self.run_style, .wide = @enumFromInt(head >> 6), .text = text };
}
/// The bytes after this row — the next row of a dense run, or nothing.
pub fn remaining(self: *const CellRowReader) []const u8 {
return self.rest;
}
};
```
- [ ] **Step 4: Run the suite to see them pass**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: no failures; the five `cellrow:` tests ran (add a deliberate `expect(false)` to one, see it fail, remove it — a pin that never fired is not a pin).
- [ ] **Step 5: Commit**
```bash
./deps/zig/zig fmt src/engine/protocol.zig
make check; echo rc=$?
git add src/engine/protocol.zig
git commit -m "feat: the CellRow codec, a grid row as runs of styled cells"
```
---
### Task 2: The daemon encoder and the wire-size measurement
**Files:**
- Modify: `src/engine/engine.zig` (after `dumpScrollback`, before `extractSelection`)
- Test: `src/engine/engine.zig`
**Interfaces:**
- Consumes: `proto.CellRowWriter`, `proto.CellStyle`, `proto.Wide`, `proto.colorPalette`, `proto.colorRgb` (Task 1). `engine.zig` reaches them as `@import("protocol.zig")` until Task 6 moves it to `@import("term").protocol`.
- Produces:
- `pub fn encodeViewportRow(self: *Engine, alloc, y: u16) ![]u8`
- `pub fn encodeScrollback(self: *Engine, alloc, start: u32, count: u16) !EncodedRows` where `pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 }` — the clamped window `dumpScrollback` computes today, rows dense
- `fn packStyle(style: vt.Style) proto.CellStyle` (private)
- [ ] **Step 1: Write the failing round-trip tests**
Append to `src/engine/engine.zig`'s tests:
```zig
fn decodeAll(alloc: std.mem.Allocator, bytes: []const u8) ![]proto.DecodedCell {
var out: std.ArrayList(proto.DecodedCell) = .empty;
var r = try proto.CellRowReader.init(bytes);
while (try r.next()) |c| try out.append(alloc, c);
return out.toOwnedSlice(alloc);
}
test "encodeViewportRow: ascii, a wide glyph with its spacer, a grapheme, and trailing blanks dropped" {
const alloc = std.testing.allocator;
var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
defer e.deinit();
e.feed("ab漢e\u{301}");
const row = try e.encodeViewportRow(alloc, 0);
defer alloc.free(row);
const cells = try decodeAll(alloc, row);
defer alloc.free(cells);
// a b 漢 (spacer) é — five cells; the seven blanks after are not sent.
try std.testing.expectEqual(@as(usize, 5), cells.len);
try std.testing.expectEqualStrings("a", cells[0].text);
try std.testing.expectEqual(proto.Wide.wide, cells[2].wide);
try std.testing.expectEqualStrings("漢", cells[2].text);
try std.testing.expectEqual(proto.Wide.spacer_tail, cells[3].wide);
try std.testing.expectEqualStrings("e\u{301}", cells[4].text);
}
test "encodeViewportRow: styles pack as the wire says, and a bg-only cell is not blank" {
const alloc = std.testing.allocator;
var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
defer e.deinit();
// bold red on palette-4 blue 'x', then EL with the blue background held:
// the cells after x carry a background and no glyph.
e.feed("\x1b[1;31;44mx\x1b[0m\x1b[44m\x1b[K");
const row = try e.encodeViewportRow(alloc, 0);
defer alloc.free(row);
const cells = try decodeAll(alloc, row);
defer alloc.free(cells);
try std.testing.expectEqual(@as(usize, 8), cells.len);
try std.testing.expectEqual(proto.colorPalette(1), cells[0].style.fg);
try std.testing.expectEqual(proto.colorPalette(4), cells[0].style.bg);
try std.testing.expectEqual(@as(u16, 1), cells[0].style.flags & 1);
try std.testing.expectEqualStrings("", cells[7].text);
try std.testing.expectEqual(proto.colorPalette(4), cells[7].style.bg);
}
test "encodeViewportRow: an empty row is ncells 0 and nothing else" {
const alloc = std.testing.allocator;
var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
defer e.deinit();
const row = try e.encodeViewportRow(alloc, 0);
defer alloc.free(row);
try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row);
}
test "encodeScrollback: clamps like dumpScrollback and returns dense rows" {
const alloc = std.testing.allocator;
var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 });
defer e.deinit();
e.feed("one\r\ntwo\r\nthree\r\nfour");
// history: one two; viewport: three four. Ask for 3 rows from 1: two three four.
const got = try e.encodeScrollback(alloc, 1, 3);
defer alloc.free(got.bytes);
try std.testing.expectEqual(@as(u32, 1), got.first);
try std.testing.expectEqual(@as(u16, 3), got.count);
var r = try proto.CellRowReader.init(got.bytes);
try std.testing.expectEqualStrings("t", (try r.next()).?.text);
// Past the end clamps to what exists.
const tail = try e.encodeScrollback(alloc, 100, 5);
defer alloc.free(tail.bytes);
try std.testing.expectEqual(@as(u32, 3), tail.first);
try std.testing.expectEqual(@as(u16, 1), tail.count);
}
```
- [ ] **Step 2: Run to see them fail**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: `no member named 'encodeViewportRow'`.
- [ ] **Step 3: Implement the encoder**
Insert into `Engine` after `dumpScrollback`:
```zig
pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 };
fn packColor(col: anytype) u32 {
return switch (col) {
.none => proto.color_none,
.palette => |p| proto.colorPalette(p),
.rgb => |c| proto.colorRgb(c.r, c.g, c.b),
};
}
fn packStyle(style: vt.Style) proto.CellStyle {
return .{
.fg = packColor(style.fg_color),
.bg = packColor(style.bg_color),
.ul = packColor(style.underline_color),
.flags = @bitCast(style.flags),
};
}
/// One row of the active screen at `pt` as a CellRow. The last cell sent
/// is the last one that is not a default blank; a bare-background cell
/// counts as content, or a coloured EL would vanish.
fn encodeRowAt(self: *Engine, alloc: std.mem.Allocator, pt: vt.point.Point) ![]u8 {
const screen = self.term.screens.active;
var list: std.ArrayList(u8) = .empty;
errdefer list.deinit(alloc);
var w = try proto.CellRowWriter.begin(&list, alloc);
const pin = screen.pages.pin(pt) orelse {
w.finish();
return list.toOwnedSlice(alloc);
};
const page = &pin.node.data;
const rac = pin.rowAndCell();
const cells = page.getCells(rac.row);
const cols: usize = self.term.cols;
// Find the last cell worth sending.
var last: usize = 0;
var any = false;
for (cells[0..cols], 0..) |c, x| {
const blank = c.style_id == 0 and c.wide == .narrow and switch (c.content_tag) {
.codepoint => c.content.codepoint == 0 or c.content.codepoint == ' ',
.codepoint_grapheme => false,
.bg_color_palette, .bg_color_rgb => false,
};
if (!blank) {
last = x;
any = true;
}
}
if (!any) {
w.finish();
return list.toOwnedSlice(alloc);
}
var text: [proto.cell_text_max]u8 = undefined;
for (cells[0 .. last + 1]) |*c| {
var style: proto.CellStyle = if (c.style_id == 0) .{} else packStyle(page.styles.get(page.memory, c.style_id).*);
var len: usize = 0;
switch (c.content_tag) {
.codepoint, .codepoint_grapheme => {
if (c.content.codepoint != 0) {
len += std.unicode.utf8Encode(@intCast(c.content.codepoint), text[len..]) catch 0;
}
if (c.hasGrapheme()) {
if (page.lookupGrapheme(c)) |cps| {
for (cps) |cp| {
var buf: [4]u8 = undefined;
const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch continue;
if (len + n > proto.cell_text_max) break;
@memcpy(text[len .. len + n], buf[0..n]);
len += n;
}
}
}
},
.bg_color_palette => style.bg = proto.colorPalette(c.content.color_palette),
.bg_color_rgb => style.bg = proto.colorRgb(c.content.color_rgb.r, c.content.color_rgb.g, c.content.color_rgb.b),
}
const wide: proto.Wide = switch (c.wide) {
.narrow => .narrow,
.wide => .wide,
.spacer_tail => .spacer_tail,
.spacer_head => .spacer_head,
};
// A space is the same blank as no text; sending it would cost a
// byte per cell of every padded line.
const t: []const u8 = if (len == 1 and text[0] == ' ') "" else text[0..len];
try w.cell(style, wide, t);
}
w.finish();
return list.toOwnedSlice(alloc);
}
pub fn encodeViewportRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
std.debug.assert(y < self.term.rows);
return self.encodeRowAt(alloc, .{ .viewport = .{ .x = 0, .y = y } });
}
/// Screen-space rows [start, start+count) on the active screen, clamped
/// to what exists, dense. Row 0 is the oldest retained history row.
pub fn encodeScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) !EncodedRows {
const total: u32 = self.historyRows() + self.term.rows;
const first = @min(start, total -| 1);
const last = @min(first + count -| 1, total -| 1);
var list: std.ArrayList(u8) = .empty;
errdefer list.deinit(alloc);
var y = first;
var n: u16 = 0;
while (y <= last and total > 0) : (y += 1) {
const row = try self.encodeRowAt(alloc, .{ .screen = .{ .x = 0, .y = y } });
defer alloc.free(row);
try list.appendSlice(alloc, row);
n += 1;
}
return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) };
}
```
If `page.getCells`, `page.styles.get(page.memory, id)`, `c.hasGrapheme()` or `page.lookupGrapheme(c)` do not resolve under ghostty 1.3.0, read the same operations in `vt.formatter` (`~/.cache/zig/p/ghostty-1.3.0-*/src/terminal/formatter.zig`, the cell loop around line 660) and spell them the way it does; the four names above are what `PageList.Cell.style()` and the formatter use.
- [ ] **Step 4: Run to see them pass**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: the four `encode…` tests pass. Break one assertion on purpose, see it fail, restore.
- [ ] **Step 5: Write the measurement test**
Append:
```zig
fn vtBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
var n: usize = 0;
var y: u16 = 0;
while (y < e.term.rows) : (y += 1) {
const b = try e.dumpVtRow(alloc, y);
defer alloc.free(b);
n += b.len;
}
return n;
}
fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
var n: usize = 0;
var y: u16 = 0;
while (y < e.term.rows) : (y += 1) {
const b = try e.encodeViewportRow(alloc, y);
defer alloc.free(b);
n += b.len;
}
return n;
}
test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
const alloc = std.testing.allocator;
const Screen = struct { name: []const u8, feed: []const u8 };
const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24;
const vim = ("\x1b[33m 12 \x1b[0m\x1b[34mfn\x1b[0m main() \x1b[35m{\x1b[0m \x1b[32m// a comment that runs on\x1b[0m \x1b[31mreturn\x1b[0m 0;\r\n") ** 24;
const htop = ("\x1b[42m 1 \x1b[0m\x1b[7m[|||||||| 12.3%]\x1b[0m \x1b[36m1234\x1b[0m \x1b[33mroot\x1b[0m \x1b[1m20\x1b[0m 0 \x1b[32m 12.0\x1b[0m \x1b[31m 0.4\x1b[0m /usr/bin/thing --flag\r\n") ** 24;
const shell = "$ ls\r\nbuild.zig docs src test\r\n$ \r\n";
const screens = [_]Screen{
.{ .name = "prose", .feed = prose },
.{ .name = "vim", .feed = vim },
.{ .name = "htop", .feed = htop },
.{ .name = "shell", .feed = shell },
};
for (screens) |s| {
var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer e.deinit();
e.feed(s.feed);
const v = try vtBytes(alloc, e);
const c = try cellBytes(alloc, e);
std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) });
}
}
```
- [ ] **Step 6: Run it and record the numbers**
Run: `./deps/zig/zig build test 2>&1 | grep cells-measure`
Expected: four lines. Write them into `docs/decisions.md` under a new dated heading `## 2026-09-04 — cells on the wire: the size measurement`, with the gate verdict: prose ≤ 1.5 and vim/htop ≤ 1.2 means continue; otherwise STOP after committing and report to the user — the spec's alternative (VT frames for terminal clients, cells for native/browser) is a different plan.
- [ ] **Step 7: Commit**
```bash
./deps/zig/zig fmt src/engine/engine.zig
make check; echo rc=$?
git add src/engine/engine.zig docs/decisions.md
git commit -m "feat: the daemon encodes a grid row as cells, and the size is measured"
```
---
### Task 3: `grid.zig`, the client grid, pinned against the engine
**Files:**
- Create: `src/engine/grid.zig`
- Modify: `src/engine/term.zig` (export `grid`), `src/engine/engine.zig` (`mirrorInto` + oracle tests)
- Test: `src/engine/grid.zig`, `src/engine/engine.zig`
**Interfaces:**
- Consumes: `proto.CellRowReader`, `proto.DecodedCell`, `proto.CellStyle`, `proto.Wide` (Task 1); `Engine.encodeViewportRow` (Task 2).
- Produces:
- `pub const CursorPos = struct { x: u16, y: u16 }`
- `pub const Cell = struct { style: proto.CellStyle = .{}, wide: proto.Wide = .narrow, text_off: u32 = 0, text_len: u8 = 0 }`
- `pub const Row = struct { cells: []Cell, text: std.ArrayListUnmanaged(u8) = .empty; pub fn textOf(self: *const Row, c: Cell) []const u8; pub fn deinit(self: *Row, alloc) void }`
- `pub const RowView = struct { col_off: u16, cols: u16 }` (moves here from `Engine.RowView`; same fields)
- `pub const ColSpan = struct { from: u16, to: u16 }`
- `pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16`, `pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan` — row-level, so a fetched scrollback row (no grid) gets the same rules
- `pub const Grid = struct { alloc, cols: u16, rows: u16, cursor: CursorPos, lines: []Row; init(alloc, cols, rows) !*Grid; deinit(); resize(cols, rows) !void; clear(); applyRow(y, bytes) !void; row(y) *const Row; dumpPlain(alloc) ![]const u8; clipCol(y, view) ?u16; snapWide(y, from, to) ColSpan }` — the last two delegate to the row-level functions
- `pub fn decodeRow(alloc, into: *Row, bytes: []const u8, cols: u16) ![]const u8` — fills `into`, returns the bytes after the row; validates in a first pass so a bad payload leaves the row untouched
- `pub fn decodeRows(alloc, bytes, count: u16, cols: u16) ![]Row` and `pub fn freeRows(alloc, rows: []Row) void`
- `Engine.mirrorInto(self, g: *Grid) !void` — resize, encode every viewport row, apply, set cursor
- [ ] **Step 1: Write the failing grid tests**
Create `src/engine/grid.zig` with the header and tests only:
```zig
//! The client's grid: what a replica holds instead of an emulator. Cells
//! arrive already parsed (protocol.CellRow) and are copied into rows; the
//! only rules here are the two wide-glyph rules a painter needs and the
//! plain-text dump every harness compares against the daemon's.
//!
//! Deliberately platform-free: no posix, no fds, no clocks, no escape
//! bytes — this compiles for wasm32-freestanding and sits under rule 4.
const std = @import("std");
const proto = @import("protocol.zig");
test "grid: applyRow fills the row and blanks the rest of the width" {
const alloc = std.testing.allocator;
const g = try Grid.init(alloc, 6, 2);
defer g.deinit();
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try proto.CellRowWriter.begin(&list, alloc);
try w.cell(.{ .fg = proto.colorPalette(2) }, .narrow, "h");
try w.cell(.{}, .wide, "漢");
try w.cell(.{}, .spacer_tail, "");
w.finish();
try g.applyRow(1, list.items);
const r = g.row(1);
try std.testing.expectEqualStrings("h", r.textOf(r.cells[0]));
try std.testing.expectEqual(proto.colorPalette(2), r.cells[0].style.fg);
try std.testing.expectEqual(proto.Wide.wide, r.cells[1].wide);
try std.testing.expectEqual(proto.Wide.spacer_tail, r.cells[2].wide);
try std.testing.expectEqual(@as(u8, 0), r.cells[3].text_len);
try std.testing.expect(r.cells[5].style.isDefault());
// Row 0 was never written and is blank.
try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
}
test "grid: a row wider than the grid is BadPayload and leaves the row untouched" {
const alloc = std.testing.allocator;
const g = try Grid.init(alloc, 2, 1);
defer g.deinit();
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try proto.CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "a");
try w.cell(.{}, .narrow, "b");
try w.cell(.{}, .narrow, "c");
w.finish();
try std.testing.expectError(error.BadPayload, g.applyRow(0, list.items));
try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
}
test "grid: dumpPlain trims trailing blanks per row and joins rows with newlines" {
const alloc = std.testing.allocator;
const g = try Grid.init(alloc, 6, 3);
defer g.deinit();
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try proto.CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "a");
try w.cell(.{}, .narrow, "");
try w.cell(.{}, .narrow, "b");
w.finish();
try g.applyRow(0, list.items);
const s = try g.dumpPlain(alloc);
defer alloc.free(s);
try std.testing.expectEqualStrings("a b\n\n", s);
}
test "grid: clipCol steps inward off a wide glyph at the pane edge; snapWide steps outward" {
const alloc = std.testing.allocator;
const g = try Grid.init(alloc, 6, 1);
defer g.deinit();
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var w = try proto.CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "a");
try w.cell(.{}, .wide, "漢");
try w.cell(.{}, .spacer_tail, "");
try w.cell(.{}, .narrow, "b");
w.finish();
try g.applyRow(0, list.items);
// A pane 2 wide would cut 漢 in half: the last column that fits is 0.
try std.testing.expectEqual(@as(?u16, 0), g.clipCol(0, .{ .col_off = 0, .cols = 2 }));
try std.testing.expectEqual(@as(?u16, 3), g.clipCol(0, .{ .col_off = 0, .cols = 6 }));
try std.testing.expectEqual(@as(?u16, null), g.clipCol(0, .{ .col_off = 0, .cols = 0 }));
// A drag from the spacer to the wide cell covers the whole glyph.
const s = g.snapWide(0, 2, 1);
try std.testing.expectEqual(@as(u16, 1), s.from);
try std.testing.expectEqual(@as(u16, 2), s.to);
}
test "grid: decodeRows reads a dense run and refuses a short one" {
const alloc = std.testing.allocator;
var list: std.ArrayList(u8) = .empty;
defer list.deinit(alloc);
var i: usize = 0;
while (i < 2) : (i += 1) {
var w = try proto.CellRowWriter.begin(&list, alloc);
try w.cell(.{}, .narrow, "x");
w.finish();
}
const rows = try decodeRows(alloc, list.items, 2, 4);
defer freeRows(alloc, rows);
try std.testing.expectEqual(@as(usize, 2), rows.len);
try std.testing.expectEqualStrings("x", rows[1].textOf(rows[1].cells[0]));
try std.testing.expectError(error.BadPayload, decodeRows(alloc, list.items, 3, 4));
}
```
- [ ] **Step 2: Run to see them fail**
Add `pub const grid = @import("grid.zig");` and `_ = grid;` to `src/engine/term.zig` first, then run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: `use of undeclared identifier 'Grid'`.
- [ ] **Step 3: Implement the grid**
Insert above the tests in `src/engine/grid.zig`:
```zig
pub const CursorPos = struct { x: u16, y: u16 };
pub const Cell = struct {
style: proto.CellStyle = .{},
wide: proto.Wide = .narrow,
text_off: u32 = 0,
text_len: u8 = 0,
pub fn isBlank(self: Cell) bool {
return self.text_len == 0 and self.wide == .narrow and self.style.isDefault();
}
};
pub const Row = struct {
cells: []Cell,
/// Every cell's text, back to back; a cell slices into it.
text: std.ArrayListUnmanaged(u8) = .empty,
pub fn textOf(self: *const Row, c: Cell) []const u8 {
return self.text.items[c.text_off .. c.text_off + c.text_len];
}
fn blank(self: *Row) void {
@memset(self.cells, .{});
self.text.clearRetainingCapacity();
}
pub fn deinit(self: *Row, alloc: std.mem.Allocator) void {
alloc.free(self.cells);
self.text.deinit(alloc);
}
};
pub const RowView = struct { col_off: u16, cols: u16 };
/// Decode one CellRow into `into` (already sized to `cols`). Two passes on
/// purpose — validate, then fill — so a bad payload leaves the row as it
/// was without a scratch copy: the caller resyncs from a snapshot, and half
/// a row would paint as a lie until then.
pub fn decodeRow(alloc: std.mem.Allocator, into: *Row, bytes: []const u8, cols: u16) ![]const u8 {
if (cols > proto.max_cols) return error.BadPayload;
var check = try proto.CellRowReader.init(bytes);
if (check.ncells > cols) return error.BadPayload;
var text_total: usize = 0;
while (try check.next()) |c| text_total += c.text.len;
// Every byte has been read once and is well-formed; nothing below can fail
// except the allocation, which happens before the row is touched.
try into.text.ensureTotalCapacity(alloc, text_total);
into.blank();
var r = proto.CellRowReader.init(bytes) catch unreachable;
var x: usize = 0;
while (r.next() catch unreachable) |c| : (x += 1) {
into.cells[x] = .{
.style = c.style,
.wide = c.wide,
.text_off = @intCast(into.text.items.len),
.text_len = @intCast(c.text.len),
};
into.text.appendSliceAssumeCapacity(c.text);
}
return r.remaining();
}
/// A column span, inclusive at both ends.
pub const ColSpan = struct { from: u16, to: u16 };
/// The last column of `r` (a row `cols` wide) that fits in `view`, or null
/// when not one character does. Inward: half a wide glyph past a pane edge
/// is a column stolen from the neighbour.
pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16 {
if (view.cols == 0 or cols == 0) return null;
var hi: u16 = @min(view.cols - 1, cols - 1);
if (r.cells[hi].wide == .wide) {
if (hi == 0) return null;
hi -= 1;
}
return hi;
}
/// Widen [from, to] to whole glyphs. Outward: half a character under the
/// pointer means the character is under the pointer.
pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan {
var lo = @min(from, to);
var hi = @max(from, to);
if (lo > 0 and r.cells[lo].wide == .spacer_tail) lo -= 1;
if (hi + 1 < cols and r.cells[hi].wide == .wide) hi += 1;
return .{ .from = lo, .to = hi };
}
pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: u16) ![]Row {
var rows = try alloc.alloc(Row, count);
var made: usize = 0;
errdefer {
for (rows[0..made]) |*r| r.deinit(alloc);
alloc.free(rows);
}
var rest = bytes;
for (rows) |*r| {
r.* = .{ .cells = try alloc.alloc(Cell, cols) };
made += 1;
@memset(r.cells, .{});
rest = try decodeRow(alloc, r, rest, cols);
}
return rows;
}
pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void {
for (rows) |*r| r.deinit(alloc);
alloc.free(rows);
}
pub const Grid = struct {
alloc: std.mem.Allocator,
cols: u16,
rows: u16,
cursor: CursorPos = .{ .x = 0, .y = 0 },
lines: []Row,
pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !*Grid {
const g = try alloc.create(Grid);
errdefer alloc.destroy(g);
g.* = .{ .alloc = alloc, .cols = cols, .rows = rows, .lines = &.{} };
try g.allocLines(cols, rows);
return g;
}
fn allocLines(self: *Grid, cols: u16, rows: u16) !void {
const lines = try self.alloc.alloc(Row, rows);
var made: usize = 0;
errdefer {
for (lines[0..made]) |*r| r.deinit(self.alloc);
self.alloc.free(lines);
}
for (lines) |*r| {
r.* = .{ .cells = try self.alloc.alloc(Cell, cols) };
made += 1;
@memset(r.cells, .{});
}
self.freeLines();
self.lines = lines;
self.cols = cols;
self.rows = rows;
}
fn freeLines(self: *Grid) void {
for (self.lines) |*r| r.deinit(self.alloc);
self.alloc.free(self.lines);
self.lines = &.{};
}
pub fn deinit(self: *Grid) void {
self.freeLines();
self.alloc.destroy(self);
}
/// A resize is a blank grid: the snapshot that carries it repaints every row.
pub fn resize(self: *Grid, cols: u16, rows: u16) !void {
try self.allocLines(cols, rows);
self.cursor = .{ .x = 0, .y = 0 };
}
pub fn clear(self: *Grid) void {
for (self.lines) |*r| r.blank();
self.cursor = .{ .x = 0, .y = 0 };
}
pub fn applyRow(self: *Grid, y: u16, bytes: []const u8) !void {
if (y >= self.rows) return error.BadPayload;
_ = try decodeRow(self.alloc, &self.lines[y], bytes, self.cols);
}
pub fn row(self: *const Grid, y: u16) *const Row {
return &self.lines[y];
}
/// The text Engine.dumpPlain produces for the same screen: a space per
/// blank or spacer-less empty cell, nothing for a spacer, trailing spaces
/// trimmed per row, rows joined by newlines. The engine.zig oracle test
/// is the authority; this is written to match it.
pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
for (self.lines, 0..) |*r, y| {
const start = out.items.len;
for (r.cells) |c| {
switch (c.wide) {
.spacer_tail, .spacer_head => continue,
.narrow, .wide => {},
}
if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
}
while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
if (y + 1 < self.lines.len) try out.append(alloc, '\n');
}
return out.toOwnedSlice(alloc);
}
pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 {
return clipColOf(&self.lines[y], self.cols, view);
}
pub fn snapWide(self: *const Grid, y: u16, from: u16, to: u16) ColSpan {
return snapWideOf(&self.lines[y], self.cols, from, to);
}
};
```
Add to `src/engine/protocol.zig` beside `max_payload`: `pub const max_cols: u16 = 4096;` with the comment `/// The widest row a client will decode; a wider claim is a bad payload, not an allocation.` (and `decodeRow` refuses `cols > max_cols` with `error.BadPayload` before sizing its scratch).
- [ ] **Step 4: Run to see them pass**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: the five `grid:` tests pass.
- [ ] **Step 5: Write the oracle tests and `mirrorInto` in `engine.zig`**
Add to `Engine` (after `encodeScrollback`):
```zig
/// Encode every viewport row into `g` — the test bridge between an
/// authored screen and the grid a client would hold, and the only way a
/// harness gets a Grid from bytes without a daemon.
pub fn mirrorInto(self: *Engine, g: *Grid) !void {
if (g.cols != self.term.cols or g.rows != self.term.rows)
try g.resize(@intCast(self.term.cols), @intCast(self.term.rows));
var y: u16 = 0;
while (y < self.term.rows) : (y += 1) {
const row = try self.encodeViewportRow(self.alloc, y);
defer self.alloc.free(row);
try g.applyRow(y, row);
}
const cur = self.cursorPos();
g.cursor = .{ .x = cur.x, .y = cur.y };
}
```
with `const Grid = @import("grid.zig").Grid;` at the top (Task 6 turns it into `@import("term").grid.Grid`). Then the oracle tests:
```zig
test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for every screen shape" {
const alloc = std.testing.allocator;
const screens = [_][]const u8{
"plain text\r\nsecond line",
"w\u{6f22}\u{5b57}x e\u{301} \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} end",
"\x1b[1;31mred bold\x1b[0m \x1b[44mblue bg\x1b[0m\x1b[K",
"short\r\n\r\n\r\nafter blanks",
"\x1b[?1049h\x1b[HTUI on alt\x1b[5;10Hcursor here",
"line one\r\nline two\r\n" ** 30, // scrolled: history exists, viewport is the tail
"\x1b[3;1Hcol\x1b[3;40Hfar\x1b[8;1H",
};
for (screens) |s| {
var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer e.deinit();
e.feed(s);
const g = try Grid.init(alloc, 1, 1);
defer g.deinit();
try e.mirrorInto(g);
const want = try e.dumpPlain(alloc);
defer alloc.free(want);
const got = try g.dumpPlain(alloc);
defer alloc.free(got);
try std.testing.expectEqualStrings(want, got);
try std.testing.expectEqual(e.cursorPos().x, g.cursor.x);
try std.testing.expectEqual(e.cursorPos().y, g.cursor.y);
}
}
test "grid oracle: clipCol and snapWide agree with the engine's on a row with wide glyphs" {
const alloc = std.testing.allocator;
var e = try Engine.init(alloc, .{ .cols = 10, .rows = 1 });
defer e.deinit();
e.feed("a\u{6f22}b\u{5b57}");
const g = try Grid.init(alloc, 1, 1);
defer g.deinit();
try e.mirrorInto(g);
var cols: u16 = 0;
while (cols <= 10) : (cols += 1) {
const view = Engine.RowView{ .col_off = 0, .cols = cols };
try std.testing.expectEqual(e.clipCol(0, view), g.clipCol(0, .{ .col_off = 0, .cols = cols }));
}
var from: u16 = 0;
while (from < 6) : (from += 1) {
var to: u16 = from;
while (to < 6) : (to += 1) {
const a = e.snapWide(0, from, to);
const b = g.snapWide(0, from, to);
try std.testing.expectEqual(a.from, b.from);
try std.testing.expectEqual(a.to, b.to);
}
}
}
```
`Engine.clipCol` and `Engine.snapWide` are private today; make them `pub` for this task (they are deleted in Task 6).
- [ ] **Step 6: Run the oracle**
Run: `./deps/zig/zig build test 2>&1 | tail -30`
Expected: both oracle tests pass. If `dumpPlain` disagrees on a screen, the ENGINE is right: adjust `Grid.dumpPlain`'s trimming (the likely differences are how ghostty trims trailing blank ROWS and whether a bg-only cell prints as a space) until the test passes on every shape, and say which rule moved in the commit body.
- [ ] **Step 7: Commit**
```bash
./deps/zig/zig fmt src/engine/grid.zig src/engine/engine.zig src/engine/term.zig src/engine/protocol.zig
make check; echo rc=$?
git add src/engine/grid.zig src/engine/engine.zig src/engine/term.zig src/engine/protocol.zig
git commit -m "feat: the client grid, pinned against the engine as its oracle"
```
---
### Task 4: `term_modes` carries the alternate screen and cursor-keys bits
**Files:**
- Modify: `src/engine/protocol.zig` (`TermModes`), `src/server/server.zig` (`sampleTermModes` ~2581–2610), `src/tui/interact.zig` (~1747)
- Test: `src/engine/protocol.zig`, `src/server/server_test_session.zig`, `src/tui/interact.zig`
**Interfaces:**
- Produces: `proto.TermModes.alt_screen: bool`, `proto.TermModes.cursor_keys: bool`.
- [ ] **Step 1: Write the failing tests**
In `src/engine/protocol.zig`, next to the existing `TermModes` round-trip test:
```zig
test "term_modes: alt_screen and cursor_keys round-trip and are off by default" {
const on = encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = true });
const back = try decodeTermModes(&on);
try std.testing.expect(back.alt_screen);
try std.testing.expect(back.cursor_keys);
try std.testing.expect(!back.bracketed_paste);
const none = try decodeTermModes(&encodeTermModes(.{ .bracketed_paste = false }));
try std.testing.expect(!none.alt_screen and !none.cursor_keys);
}
```
In `src/server/server_test_session.zig`, find the existing test that asserts a `term_modes` frame after `\x1b[?2004h` (grep `term_modes`), and add one of the same shape whose shell writes `\x1b[?1049h\x1b[?1h` and expects the next `term_modes` frame to decode with `alt_screen == true` and `cursor_keys == true`, then `\x1b[?1l\x1b[?1049l` and a frame with both false.
In `src/tui/interact.zig`, find the test that covers the wheel-on-alt-screen rule (grep `sendAltScroll` or `alternate scroll`); it authors the alt screen by feeding `\x1b[?1049h` into the replica. Change it to set `core.semantic.terminal_modes = .{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = false }` instead, and add the inverse case (modes say primary → the wheel is a scrollback move, not arrows).
- [ ] **Step 2: Run to see them fail**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: `no field named 'alt_screen'`.
- [ ] **Step 3: Implement**
`protocol.zig`: in `pub const TermModes = packed struct(u32)`, add `alt_screen: bool = false, cursor_keys: bool = false,` after the mouse fields and shrink the trailing padding by 2 bits (the struct must stay exactly 32 bits — the compiler refuses otherwise). Update the `term_modes` comment on the `MsgType` line: `bit0 bracketed paste, mouse bits, alt_screen, cursor_keys`.
`server.zig` `sampleTermModes`: where `.bracketed_paste = eng.bracketedPaste(),` is set, add `.alt_screen = eng.onAltScreen(), .cursor_keys = eng.cursorKeys(),` — both in the sampler and in the attach-time send at ~2780.
`interact.zig:1747`: replace `self.rep.eng.onAltScreen()` with `self.semantic.terminal_modes.alt_screen` and `self.rep.eng.cursorKeys()` with `self.semantic.terminal_modes.cursor_keys` (`semantic` is the `client_core` state `semanticFrame` updates; if the field is named differently in `Core`, use that name — it is the one whose `.receive` is called in `semanticFrame`).
- [ ] **Step 4: Run to see them pass**
Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: pass. Then `make e2e 2>&1 | tail -5` — the mouse group (`E2E_ONLY=08_mouse make e2e`) exercises the wheel rule against a real `less`.
- [ ] **Step 5: Commit**
```bash
make check; echo rc=$?
git add src/engine/protocol.zig src/server/server.zig src/server/server_test_session.zig src/tui/interact.zig
git commit -m "feat: term_modes carries the alternate screen and DECCKM, so the wheel rule needs no engine"
```
---
### Task 5: The flip — the wire carries cells, the replica is a grid, every painter reads it
This is one task because the type of `Replica`'s target is one edge of the compile graph: the daemon's payloads and the replica's decoder change together or the server tests cannot round-trip, and every client painter holds the replica. Tasks 1–4 built and tested each piece; this task wires them. Commit in `--fixup` steps as each file compiles; `make check` must be green before the last commit of the task.
**Files:**
- Modify: `src/engine/protocol.zig`, `src/engine/delta.zig`, `src/engine/replica.zig`, `src/server/server.zig`, `src/server/server_test_harness.zig`, `src/server/server_test_attach.zig`, `src/server/server_test_session.zig`, `src/tui/paint.zig`, `src/tui/interact.zig`, `src/tui/wallview.zig`, `src/tui/wall_pump.zig`, `src/tui/wall_test_harness.zig`, `src/client/wasm_core.zig`, `test/wsclient.zig`
- Test: all of the above; `make e2e`
**Interfaces:**
- Consumes: everything from Tasks 1–4.
- Produces:
- `proto.MsgType.snapshot = 0x95`, `.delta = 0x96`, `.scrollback_chunk = 0x97`
- `proto.snapshot_cursor_len = 4`, `proto.readSnapshotCursor(payload) !grid.CursorPos`, `proto.writeSnapshotCursor(buf: *[4]u8, cur)`
- `delta.buildSnapshot(alloc, eng: *Engine, prefix: proto.SnapshotPrefix) ![]u8`
- `Replica { grid: *Grid, ... }` — `init(alloc, g: *Grid)`; `apply` unchanged in signature
- `paint.rowToVtFrom(alloc, r: *const grid.Row, cols: u16, view: grid.RowView, span: ?Span) ![]u8` — the serializer, on a row and its width
- `paint.rowToVt(alloc, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8` — `rowToVtFrom(alloc, g.row(y), g.cols, view, span)`
- `paint.renderClipped(alloc, g: *const Grid, vp, hl, rows, owns_screen, out_fd)`, `paint.paintDeltaClipped(alloc, payload, g: *const Grid, vp, hl, out_fd)`, `paint.renderScrollback(alloc, rows: []const grid.Row, g_cols: u16, vp, owns_screen, out_fd)`
- [ ] **Step 5.1: Frame numbers and the snapshot cursor (`protocol.zig`)**
Renumber in `MsgType`:
```zig
// Retired 2026-09-04 with the VT payloads they carried; never reused, so a
// binary from either side of the break drops the other's frames instead
// of painting them. 0x81 snapshot, 0x85 scrollback_chunk, 0x87 delta.
snapshot = 0x95, // payload: SnapshotPrefix ++ u16 LE cursor_x ++ u16 LE cursor_y ++ rows × CellRow
scrollback_chunk = 0x97, // payload: u32 LE start, u16 LE count ++ count × CellRow
delta = 0x96, // payload: DeltaHeader ++ row_count × (u16 LE row ++ u32 LE len ++ CellRow)
```
Add after `readSnapshotPrefix`:
```zig
pub const snapshot_cursor_len = 4;
pub fn writeSnapshotCursor(buf: *[snapshot_cursor_len]u8, x: u16, y: u16) void {
std.mem.writeInt(u16, buf[0..2], x, .little);
std.mem.writeInt(u16, buf[2..4], y, .little);
}
pub const SnapshotCursor = struct { x: u16, y: u16 };
pub fn readSnapshotCursor(payload: []const u8) !SnapshotCursor {
if (payload.len < snapshot_prefix_len + snapshot_cursor_len) return error.BadPayload;
const b = payload[snapshot_prefix_len..][0..snapshot_cursor_len];
return .{ .x = std.mem.readInt(u16, b[0..2], .little), .y = std.mem.readInt(u16, b[2..4], .little) };
}
```
Delete `ComposedDelta`, `composeDelta`, and the `// folder rule 4 exemption:` line at the top of the file. Fix the golden tests that pinned the old numbers and the `composeDelta` tests (delete the latter; the reader tests in Task 1 replace them). Any test in the file whose production line now needs an escape byte must be inside a `test` block or rule 4 fails `make check`.
- [ ] **Step 5.2: The tracker and the snapshot builder (`delta.zig`)**
Replace the three `eng.dumpVtRow(alloc, @intCast(y))` calls (`rebuild`, `update`, `buildDeltaSince`) with `eng.encodeViewportRow(alloc, @intCast(y))`; the hash and `appendDeltaRow` calls are unchanged. Add:
```zig
/// The snapshot payload: prefix, cursor, every viewport row dense. The
/// cursor rides here rather than in the prefix so the prefix's golden pin
/// and its readers stay as they were.
pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.SnapshotPrefix) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
proto.writeSnapshotPrefix(&pbuf, prefix);
try out.appendSlice(alloc, &pbuf);
var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
const cur = eng.cursorPos();
proto.writeSnapshotCursor(&cbuf, cur.x, cur.y);
try out.appendSlice(alloc, &cbuf);
var y: u16 = 0;
while (y < eng.term.rows) : (y += 1) {
const row = try eng.encodeViewportRow(alloc, y);
defer alloc.free(row);
try out.appendSlice(alloc, row);
}
return out.toOwnedSlice(alloc);
}
```
Add a test: feed `"a\r\nb"` into an 4×2 engine, `buildSnapshot`, `readSnapshotPrefix` + `readSnapshotCursor` agree with the engine, and `grid.decodeRows(alloc, payload[28..], 2, 4)` yields `a` and `b`.
- [ ] **Step 5.3: The daemon's three sites (`server.zig`)**
`buildSnapshotPayload` (~2695): replace the `dumpState` + prefix assembly with `delta_mod.buildSnapshot(self.alloc, s.eng, .{ .seq = ..., .history_rows = ..., .cols = ..., .rows = ..., .epoch = ... })` using the same values it writes today.
`accrueSnapshotEquiv` (~2415): replace the `dumpState` length with `buildSnapshot`'s length (build, measure, free) — the stat means "what a full snapshot would have cost" and must measure the thing sent.
`onFetchScrollback` (~1928): replace `dumpScrollback` with `encodeScrollback`; the header writes `got.first` and `got.count`, then `got.bytes`.
`dumpState` stays for `upgrade.zig`'s manifest and `debug_dump`; do not touch them.
- [ ] **Step 5.4: The replica over a grid (`replica.zig`)**
```zig
const Grid = @import("grid.zig").Grid;
const grid_mod = @import("grid.zig");
pub const Replica = struct {
alloc: std.mem.Allocator,
/// Borrowed. The grid the frames are copied into.
grid: *Grid,
grid_size: proto.Size,
session_epoch: u64 = 0,
last_seq: u64 = 0,
history_rows: u32 = 0,
state_since_attach: bool = false,
pub const Applied = enum { painted, resync };
pub fn init(alloc: std.mem.Allocator, g: *Grid) Replica {
return .{ .alloc = alloc, .grid = g, .grid_size = .{ .cols = g.cols, .rows = g.rows } };
}
/// `.snapshot`: a short prefix or cursor is error.BadPayload with nothing
/// consumed; a row that will not decode is error.BadPayload too — a
/// snapshot is the resync, so a resync cannot fix one, and the pump ends
/// the tile with the error. `.delta`: a bad row is `.resync`.
pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied {
switch (t) {
.snapshot => {
const prefix = try proto.readSnapshotPrefix(payload);
const cur = try proto.readSnapshotCursor(payload);
self.state_since_attach = true;
self.session_epoch = prefix.epoch;
self.last_seq = prefix.seq;
self.history_rows = prefix.history_rows;
if (prefix.cols != self.grid_size.cols or prefix.rows != self.grid_size.rows) {
try self.grid.resize(prefix.cols, prefix.rows);
self.grid_size = .{ .cols = prefix.cols, .rows = prefix.rows };
}
self.grid.clear();
var rest = payload[proto.snapshot_prefix_len + proto.snapshot_cursor_len ..];
var y: u16 = 0;
while (y < prefix.rows) : (y += 1) {
rest = try grid_mod.decodeRow(self.alloc, &self.grid.lines[y], rest, self.grid.cols);
}
self.grid.cursor = .{ .x = cur.x, .y = cur.y };
return .painted;
},
.delta => {
self.state_since_attach = true;
const hdr = proto.readDeltaHeader(payload) catch return .resync;
var it = proto.deltaRowIterator(payload);
var seen: usize = 0;
while (it.next() catch return .resync) |row| {
self.grid.applyRow(row.row, row.bytes) catch return .resync;
seen += 1;
}
if (seen != hdr.row_count) return .resync;
self.history_rows = hdr.history_rows;
self.last_seq = hdr.seq;
self.grid.cursor = .{ .x = hdr.cursor_x, .y = hdr.cursor_y };
return .painted;
},
else => unreachable,
}
}
// attachArgs and scrollStart unchanged.
};
```
Rewrite the file's tests to build payloads with `CellRowWriter` (the `testSnapshot` helper takes prefix + cursor + rows) and add: `"replica: a snapshot whose rows do not decode is BadPayload, not resync"` (a snapshot body of `"\x1b[1mVT"` bytes — inside the test block — errors out of `apply`), and `"replica: a delta whose row is wider than the grid is resync and the grid is untouched"`.
- [ ] **Step 5.5: The server harness and tests**
`server_test_harness.zig`: `applyFrame(alloc, replica: *Grid, frame)` builds `Replica.init(alloc, replica)`; `ReplicaWait.replica: *Grid`; `ReplicaFeed` likewise; every `Engine.init` that constructs the REPLICA side becomes `Grid.init(alloc, cols, rows)` (`const Grid = @import("term").grid.Grid;`); every `replica.dumpPlain(alloc)` keeps its name. The DAEMON-side engines stay engines. `server_test_attach.zig` and `server_test_session.zig`: the same substitution where they hold a replica; any test that fed VT into a replica to author expected state now authors through a scratch `Engine` + `mirrorInto` a `Grid` and compares `dumpPlain`.
Run: `./deps/zig/zig build test 2>&1 | tail -30` — expect the wall/client/wasm/wsclient modules to fail to compile still; the server tests must PASS here before moving on (commit `--fixup`).
- [ ] **Step 5.6: The serializer and the painters (`paint.zig`)**
Replace `dumpRow` and the three renders' engine parameters. The serializer:
```zig
/// One grid row as VT for a terminal: SGR on every style change, a wide
/// glyph once with its spacer skipped, a space for an empty cell, and a
/// stop at the last cell that is not a default blank (the caller's ECH has
/// cleared the rest). `span` (grid columns, inclusive) is painted inverted
/// and PLAIN, snapped outward to whole glyphs, closed by a full reset —
/// the three-piece shape Engine.dumpVtRowSpan had, on cells.
pub fn rowToVtFrom(alloc: std.mem.Allocator, r: *const grid.Row, cols: u16, view: grid.RowView, span: ?Span) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
try out.appendSlice(alloc, "\x1b[0m");
const last = grid.clipColOf(r, cols, view) orelse return out.toOwnedSlice(alloc);
const snapped: ?grid.ColSpan = if (span) |s| blk: {
if (s.from > last) break :blk null;
break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last));
} else null;
var end: u16 = last;
// Trailing default blanks are the caller's clear, not ours.
while (end > 0 and r.cells[end].isBlank() and (snapped == null or end > snapped.?.to)) end -= 1;
var cur: proto.CellStyle = .{};
var x: u16 = 0;
var inverted = false;
while (x <= end) : (x += 1) {
const c = r.cells[x];
if (c.wide == .spacer_tail or c.wide == .spacer_head) continue;
const in_span = if (snapped) |s| x >= s.from and x <= s.to else false;
if (in_span and !inverted) {
try out.writer(alloc).print("\x1b[{d}G\x1b[0m\x1b[7m", .{x + 1 + view.col_off});
inverted = true;
cur = .{};
} else if (!in_span and inverted) {
try out.writer(alloc).print("\x1b[0m\x1b[{d}G", .{x + 1 + view.col_off});
inverted = false;
cur = .{};
}
if (!in_span and !c.style.eql(cur)) {
try appendSgr(&out, alloc, c.style);
cur = c.style;
}
if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
}
try out.appendSlice(alloc, "\x1b[0m");
return out.toOwnedSlice(alloc);
}
pub fn rowToVt(alloc: std.mem.Allocator, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8 {
return rowToVtFrom(alloc, g.row(y), g.cols, view, span);
}
fn appendColor(out: *std.ArrayList(u8), alloc: std.mem.Allocator, base: u8, packed_col: u32) !void {
const w = out.writer(alloc);
switch (packed_col >> 24) {
0 => try w.print(";{d}", .{base + 9}), // default: 39 / 49 / 59
1 => {
const idx: u8 = @truncate(packed_col);
if (base != 58 and idx < 8) {
try w.print(";{d}", .{base + idx});
} else if (base != 58 and idx < 16) {
try w.print(";{d}", .{base + 60 + (idx - 8)});
} else try w.print(";{d};5;{d}", .{ base + 8, idx });
},
else => try w.print(";{d};2;{d};{d};{d}", .{ base + 8, (packed_col >> 16) & 0xff, (packed_col >> 8) & 0xff, packed_col & 0xff }),
}
}
/// A full SGR for `s` from a reset: every attribute the wire carries, so
/// a host terminal never inherits a neighbour cell's style.
fn appendSgr(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: proto.CellStyle) !void {
const w = out.writer(alloc);
try w.writeAll("\x1b[0");
if (s.flags & (1 << 0) != 0) try w.writeAll(";1");
if (s.flags & (1 << 2) != 0) try w.writeAll(";2");
if (s.flags & (1 << 1) != 0) try w.writeAll(";3");
switch ((s.flags >> 8) & 0x7) {
0 => {},
1 => try w.writeAll(";4"),
2 => try w.writeAll(";4:2"),
3 => try w.writeAll(";4:3"),
4 => try w.writeAll(";4:4"),
5 => try w.writeAll(";4:5"),
else => try w.writeAll(";4"),
}
if (s.flags & (1 << 3) != 0) try w.writeAll(";5");
if (s.flags & (1 << 4) != 0) try w.writeAll(";7");
if (s.flags & (1 << 5) != 0) try w.writeAll(";8");
if (s.flags & (1 << 6) != 0) try w.writeAll(";9");
if (s.flags & (1 << 7) != 0) try w.writeAll(";53");
if (s.fg != 0) try appendColor(out, alloc, 30, s.fg);
if (s.bg != 0) try appendColor(out, alloc, 40, s.bg);
if (s.ul != 0) try appendColor(out, alloc, 58, s.ul);
try w.writeAll("m");
}
```
`renderClipped(alloc, g: *const Grid, vp, hl, rows, owns_screen, out_fd)`: `grid_rows = g.rows`; per row `rowToVt(alloc, g, y, view, spanFor(hl, y, g))` where `spanFor` is today's `hl.span` callback ask. `finishPaint` takes `g.cursor`.
`paintDeltaClipped(alloc, payload, g, vp, hl, out_fd)`: read the header, collect the row indices from `deltaRowIterator` into a stack array of `u16` (cap `proto.max_cols`... rows, use the grid's `rows`), and call the row-list path of `renderClipped` — the verbatim branch, the over-wide branch and the `ech`-plus-`row.bytes` path are gone; every row paints from the grid clipped to the pane.
`renderScrollback(alloc, rows: []const grid.Row, g_cols: u16, vp, owns_screen, out_fd)`: paints each fetched row through `rowToVtFrom(alloc, &rows[n], g_cols, view, null)` at `appendRowAt(n)`, blank rows past the chunk; the `appendClippedHistory` scratch engine (`paint.zig:243`) is deleted because clipping is now `clipColOf` on the row.
Every test in `paint.zig` that did `var replica = try Engine.init(...); replica.feed(...)` now does that AND `const g = try Grid.init(alloc, 1, 1); try replica.mirrorInto(g);` and passes `g`. Tests that read the painted bytes back through `screenAfter`/`besideScreen` keep their Engine (that is the oracle reading the paint). `paint.zig` lives under `src/tui/`, outside rule 4's folders, so its escape bytes need no exemption marker.
Pins that must FIRE against the new serializer (break each once, watch it fail, restore): the span-bounded ECH pin, the `col_off` rail pin (`besideScreen`), the selection-inverts-plain pin, the wide-glyph-at-pane-edge pin.
- [ ] **Step 5.7: The interaction loop (`interact.zig`)**
- `Core.rep: Replica` is initialised at ~1249 with `const g = try Grid.init(alloc, size.cols, size.rows); .rep = Replica.init(alloc, g)`; the deinit that freed `eng` frees `g`.
- Every `self.rep.eng.term.cols`/`.rows` → `self.rep.grid.cols`/`.rows`; `self.rep.eng.cursorPos()` → `self.rep.grid.cursor`; `dumpPlain` → `self.rep.grid.dumpPlain`.
- `replicaCellChar` and the overlay judge take `*const Grid` and call `dumpPlain` on it.
- `scrollbackPage`: `const hdr_count = readInt(u16, payload[4..6])`; `const rows = try grid.decodeRows(self.alloc, payload[6..], hdr_count, self.rep.grid.cols); defer grid.freeRows(self.alloc, rows);` then `renderScrollback(self.alloc, rows, self.rep.grid.cols, ...)`.
- `paintDeltaClipped`/`renderClipped` call sites pass `self.rep.grid`.
- Tests: every `replica.feed("...")` / `core.rep.eng.feed("...")` that authored a screen becomes `var author = try Engine.init(alloc, .{...}); defer author.deinit(); author.feed("..."); try author.mirrorInto(core.rep.grid);`. Write the helper once at the top of the test section:
```zig
fn authorScreen(alloc: std.mem.Allocator, g: *Grid, bytes: []const u8) !void {
var e = try Engine.init(alloc, .{ .cols = g.cols, .rows = g.rows });
defer e.deinit();
e.feed(bytes);
try e.mirrorInto(g);
}
```
`wallview.zig` and `wall_pump.zig`: grep `\.eng\b` and `Engine`; every read of the replica engine becomes the grid read with the same meaning. `wall_test_harness.zig:217`: if that engine PLAYS THE DAEMON (feeds bytes and builds frames), it stays an `Engine`; if it plays the replica, it becomes a `Grid`.
- [ ] **Step 5.8: The wasm core (`wasm_core.zig`)**
- `c.eng: *Engine` → `c.grid: *Grid` (`Grid.init(alloc, cols, rows)`); `c.rep = Replica.init(alloc, c.grid)`.
- `paintRow(c, g: *const Grid, y)`: for each x, `const cell = g.row(y).cells[x]`; `viewport[base] = first codepoint of g.row(y).textOf(cell)` (decode with `std.unicode.utf8Decode` on the first sequence; 0 for empty); `[base+1] = cell.style.fg`, `[base+2] = cell.style.bg` (already packed as JS expects); `[base+3] = cell.style.flags | wide<<16 | spacer<<17` with the same switch on `cell.wide`.
- `mux_scroll_feed(len)`: replace the scratch engine with `c.scroll_rows: ?[]grid.Row` = `grid.decodeRows(alloc, input_buf[0..len], rows_in_chunk, c.cols)` where `rows_in_chunk` is `len`'s row count — JS strips the 6-byte header today and passes the rows; change the JS to pass the WHOLE chunk (header included) so the count is read here: `const count = readInt(u16, input_buf[4..6])`, rows from `input_buf[6..len]`. (`web/mux.js:771`: pass `chunk` rather than `rows`.) `mux_read_scroll_viewport` paints `c.scroll_rows` through a `paintRowFrom(c, r: *const grid.Row, y)` sibling of `paintRow` (the `Grid` form calls it with `g.row(y)`), blank rows past the chunk.
- `mux_dump_plain` → `c.grid.dumpPlain`.
- Remove the `Engine` import and `.max_scrollback`.
Then `./deps/zig/zig build 2>&1 | tail -5` builds `mux_core.wasm`; run `make e2e` (the web group drives the page through `wsclient` and `verify.js`).
- [ ] **Step 5.9: The browser stand-in (`wsclient.zig`)**
`Replica.init(alloc, grid)` over a `Grid.init(alloc, 80, 24)`; `cl.rep.eng.dumpPlain` → `cl.rep.grid.dumpPlain`. Its self-test at ~749 keeps `daemon_eng` as an `Engine` (it plays the daemon) and mirrors the FIXTURE side into a `Grid`, comparing `dumpPlain`.
- [ ] **Step 5.10: Green**
Run: `make check; echo rc=$?` — expect 0. Run: `make e2e 2>&1 | tail -8; echo rc=${PIPESTATUS[0]}` — expect 0. If an e2e leg reads the grid through `mux d dump`, it still works (`dumpPlain`/`dumpVt` are daemon-side and untouched).
- [ ] **Step 5.11: Commit (autosquash the fixups)**
```bash
git add -A src test web
git commit -m "feat: the wire carries cells; the replica is a grid and every painter reads it"
# then fold the --fixup commits made along the way:
GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $(git merge-base HEAD main)
```
---
### Task 6: The module split — the client links no emulator
**Files:**
- Modify: `build.zig` (module table, ghostty edges, wasm wiring), `src/engine/term.zig`, `src/engine/engine.zig`, `src/engine/delta.zig`, `src/server/server.zig`, `src/server/server_test_harness.zig`, `src/tui/paint.zig`, `src/tui/interact.zig`, `src/tui/wall_test_harness.zig`, `test/render.zig`, `test/wsclient.zig`, `CLAUDE.md` (the layout table row)
**Interfaces:**
- Produces: module row `engine` (`src/engine/engine.zig`, child `delta.zig`), `.imports = &.{"term"}`, links ghostty-vt; `term` has no ghostty edge and `.wasm = true`; `daemon` and `render` import `engine`; `wall`, `client`, `wsclient` list `engine` in `.test_imports`.
- [ ] **Step 1: Write the failing pins**
Two shell pins in `test/bans.sh`, beside the folder-rule checks and in their style (a named check that prints `bans ok: …` or fails the script):
```sh
# The term module is the wasm root and the client's whole view of the wire:
# it spells no emulator. engine.zig is the one file allowed the dependency.
if grep -l '"ghostty-vt"' src/engine/term.zig src/engine/protocol.zig src/engine/replica.zig src/engine/grid.zig >/dev/null 2>&1; then
fail "a term child imports ghostty-vt"
fi
if grep -q 'engine.zig' src/engine/term.zig; then
fail "term.zig re-exports the engine"
fi
# No client row links the engine outside its tests: the production client
# parses no VT. The table lines are one per row, so a row's .imports is on
# the line that names it.
for row in client wall webhub term; do
if grep -E "\.name = \"$row\"" build.zig | grep -q '\.imports = &\.{[^}]*"engine"'; then
fail "module row $row imports engine"
fi
done
echo "bans ok: the engine is the daemon's alone"
```
- [ ] **Step 2: Run to see them fail**
Run: `make check 2>&1 | tail -5` — `bans.sh` fails with `term.zig re-exports the engine`.
- [ ] **Step 3: Split**
`term.zig`: remove `pub const engine` and `pub const delta` (and their `_ =` lines); keep `protocol`, `replica`, `grid`.
`engine.zig`: `const proto = @import("term").protocol;` and `const Grid = @import("term").grid.Grid;` replace the relative imports; add `pub const delta = @import("delta.zig");` and `test { _ = delta; }` at the bottom so `engine` is a root. `delta.zig`: `const proto = @import("term").protocol;` and `const Engine = @import("engine.zig").Engine;` (a sibling in the same module is a relative import).
`build.zig` table: add `.{ .name = "engine", .path = "src/engine/engine.zig", .imports = &.{"term"} },` next to `term`; move the `ghostty_dep` `addImport("ghostty-vt", ...)` from `term_mod` to `mods[idxOf("engine")]`; `daemon` imports gain `"engine"`; `render` imports become `&.{ "term", "engine" }`; `wall`, `client` and `wsclient` gain `"engine"` in `.test_imports` (for `wsclient`, add the field). In the wasm block delete `ghostty_wasm_dep` and the `term_wasm_mod.addImport("ghostty-vt", …)` lines; `term` stays `.wasm = true`.
Every `@import("term").engine.Engine` in `server.zig`, `server_test_harness.zig`, `paint.zig` (tests), `interact.zig` (tests), `wall_test_harness.zig`, `render.zig`, `wsclient.zig` becomes `@import("engine").Engine`; `delta` references in `server.zig` become `@import("engine").delta`.
Delete from `engine.zig`: `dumpVtRow`, `dumpVtRowClipped`, `dumpVtRowSpan`, `dumpScrollback`, `RowView`, `clipCol`, `snapWide`, `viewportSpan` if nothing else uses it — `dumpVt`, `dumpVtFrom`, `dumpPlain`, `dumpState`, `extractSelection` stay. Delete the `vtBytes` half of the Task 2 measurement test (keep `cellBytes`, print cells only, and note in the test name that the VT figure was recorded in decisions.md on 2026-09-04).
`CLAUDE.md` layout table: the `src/engine/` row becomes ``term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test``.
- [ ] **Step 4: Run to see everything pass**
Run: `make check; echo rc=$?` — expect 0, and `bans.sh`'s new line prints its ok. Then `ls -la zig-out/bin/mux_core.wasm` (or wherever `make web` puts it — grep `mux_core` in the Makefile) before and after: record both sizes in the commit body.
- [ ] **Step 5: Commit**
```bash
git add build.zig src test CLAUDE.md
git commit -m "build: the engine is its own module, and no client row links ghostty-vt"
```
---
### Task 7: The gates and the record
**Files:**
- Modify: `docs/decisions.md`, `CLAUDE.md` (invariants: "One replay core" wording; the `proxy`/wire sentence), `README.md` (if it describes the wire as VT — grep `delta`), `test/xversion.sh` (a comment at the top), `RETRO.md` is untracked and not ours
- [ ] **Step 1: The delivery gate**
Run: `make ci 2>&1 | tail -12; echo rc=${PIPESTATUS[0]}` — expect 0. This is `check + e2e + agent + throughput`; every group must print its verdict line (watch for a group that never ran).
- [ ] **Step 2: The real bytes**
In an isolated rig (`export XDG_STATE_HOME=$SCRATCH/state XDG_RUNTIME_DIR=$SCRATCH/run`), run `make bench` on this branch and, from the `main` checkout at `/home/xanderle/code/rad/mux`, `make bench` there too. Both print delta bytes and the snapshot-equivalent figure from `mux d stats`. Record both pairs.
- [ ] **Step 3: The record**
`docs/decisions.md`: extend the Task 2 heading into the full entry — the three-parse finding, the verbatim-paint finding, the wire format, the renumbering rationale (blank tile, never garbage), the Task 2 synthetic ratios, the Task 7 bench pairs, the wasm size before/after, the clean-break policy and the `make xversion` note (no old side to grade until the next release; the gate re-enters when one exists).
`CLAUDE.md`: the invariant "**One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`" gains "over a `grid`, never an engine — the client parses no VT". Add to the top summary: "ghostty-vt engine runs authoritatively in `mux d`; the wire carries its grid as cells and the client copies them". Re-measure the file sizes the "Reading this repo" section quotes for the files this branch changed.
`test/xversion.sh`: a comment at the top: `# 2026-09-04: the cells-on-the-wire break has no old side to grade; XVER_OLD_WORKTREE must be this branch or newer.`
- [ ] **Step 4: Commit**
```bash
make check; echo rc=$?
git add docs/decisions.md CLAUDE.md README.md test/xversion.sh
git commit -m "docs: record cells on the wire, its measurements and the clean break"
```
- [ ] **Step 5: Autosquash and hand over**
`git log --oneline main..HEAD` must read as the feature's story: codec, encoder+measurement, grid, modes, the flip, the split, the record. Then the finishing-a-development-branch skill.