a73x

941927df

docs: cells on the wire — the design and the plan

a73x   2026-09-04 16:59

Commit message
docs: cells on the wire — the design and the plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

docs/superpowers/plans/2026-09-04-cells-on-the-wire.md
Old New
@@ -0,0 +1,1585 @@
1 # Cells on the Wire Implementation Plan
2
3 > **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.
4
5 **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.
6
7 **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.
8
9 **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`.
10
11 **Spec:** `docs/superpowers/specs/2026-09-04-cells-on-the-wire-design.md`
12
13 ## Global Constraints
14
15 - Toolchain is `./deps/zig/zig`; system zig does not build this. `make check` before every commit; capture `$?` before piping (`make check; echo rc=$?`).
16 - 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.
17 - 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.
18 - `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`.
19 - Comments say why; every cited symbol must resolve (`zig build check` gates references).
20 - Any hand-run rig exports an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` first.
21 - Never quote a speed number from the dev tree; byte counts are fine.
22 - Frame numbers: `snapshot = 0x95`, `delta = 0x96`, `scrollback_chunk = 0x97`; `0x81`, `0x85`, `0x87` retired and never reused.
23 - Colour packing: `0` none, `(1 << 24) | index` palette, `(2 << 24) | r << 16 | g << 8 | b` RGB. Run flags bits 0–10 = ghostty `Style.Flags` bit order; bit 15 = `ascii`. Cell head = `wide << 6 | text_len`, `text_len ≤ 63`.
24
25 ---
26
27 ## File map
28
29 | File | Responsibility after this plan |
30 |---|---|
31 | `src/engine/protocol.zig` | Frame numbers, `CellRow` codec (`CellRowWriter`, `CellRowReader`, `CellStyle`, `Wide`, colour packing), snapshot cursor, `TermModes` bits. No engine, no ghostty. |
32 | `src/engine/grid.zig` (new) | `Grid`, `Row`, `Cell`; `applyRow`, `decodeRows`, `dumpPlain`, `clipCol`, `snapWide`. Platform-free, wasm-clean. |
33 | `src/engine/replica.zig` | `Replica` over `*Grid`. |
34 | `src/engine/term.zig` | Root of `term`: `protocol`, `replica`, `grid`. |
35 | `src/engine/engine.zig` | Root of the new `engine` module: ghostty wrapper, `encodeViewportRow`, `encodeScrollback`, `mirrorInto`. |
36 | `src/engine/delta.zig` | Child of `engine`: tracker hashes encoded rows; `buildSnapshot`. |
37 | `src/server/server.zig` | Three call sites: `buildSnapshotPayload`, `accrueSnapshotEquiv`, `onFetchScrollback`; `sampleTermModes` gains two bits. |
38 | `src/tui/paint.zig` | `rowToVt` serializer; renders take `*const Grid`. |
39 | `src/tui/interact.zig` | `Core.rep` over a `Grid`; scrollback pages are decoded rows; wheel rule reads `term_modes`. |
40 | `src/client/wasm_core.zig` | Grid-backed viewport and scroll view; no `Engine`. |
41 | `test/wsclient.zig` | Replica over a `Grid`. |
42 | `src/server/server_test_harness.zig`, `src/tui/wall_test_harness.zig` | Replica side is a `Grid`; screens authored through `Engine.mirrorInto`. |
43 | `build.zig` | `engine` row; `term` drops ghostty; wasm drops ghostty. |
44 | `docs/decisions.md`, `CLAUDE.md`, `README.md` | The record, the table, the wire sentence. |
45
46 ---
47
48 ### Task 1: The `CellRow` codec in `protocol.zig`
49
50 **Files:**
51 - Modify: `src/engine/protocol.zig` (append after the `DeltaRowIterator` block, before `ComposedDelta`)
52 - Test: `src/engine/protocol.zig` (tests at the end of the file)
53
54 **Interfaces:**
55 - Produces:
56 - `pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 }`
57 - `pub const CellStyle = struct { fg: u32 = 0, bg: u32 = 0, ul: u32 = 0, flags: u16 = 0 }` with `eql` and `isDefault`
58 - `pub fn colorPalette(index: u8) u32`, `pub fn colorRgb(r: u8, g: u8, b: u8) u32`, `pub const color_none: u32 = 0`
59 - `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`
60 - `pub const CellRowWriter` — `begin(list, alloc) !CellRowWriter`, `cell(style, wide, text) !void`, `finish() void`
61 - `pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 }`
62 - `pub const CellRowReader` — `init(bytes) !CellRowReader`, `next() !?DecodedCell`, `remaining() []const u8`, `ncells: u16`
63
64 - [ ] **Step 1: Write the failing golden tests**
65
66 Append to `src/engine/protocol.zig`:
67
68 ```zig
69 test "cellrow: two default ascii cells are one ascii run" {
70 const alloc = std.testing.allocator;
71 var list: std.ArrayList(u8) = .empty;
72 defer list.deinit(alloc);
73 var w = try CellRowWriter.begin(&list, alloc);
74 try w.cell(.{}, .narrow, "a");
75 try w.cell(.{}, .narrow, "b");
76 w.finish();
77 // ncells=2; run: count=2, flags=ascii, fg=bg=ul=0; then "ab".
78 try std.testing.expectEqualSlices(u8, &[_]u8{
79 2, 0, // ncells
80 2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header
81 'a', 'b',
82 }, list.items);
83 }
84
85 test "cellrow: a wide glyph and its spacer, a grapheme, and a styled run round-trip" {
86 const alloc = std.testing.allocator;
87 var list: std.ArrayList(u8) = .empty;
88 defer list.deinit(alloc);
89 const red: CellStyle = .{ .fg = colorPalette(1), .flags = 1 }; // bold red
90 var w = try CellRowWriter.begin(&list, alloc);
91 try w.cell(.{}, .wide, "漢");
92 try w.cell(.{}, .spacer_tail, "");
93 try w.cell(.{}, .narrow, "e\u{301}");
94 try w.cell(red, .narrow, "x");
95 try w.cell(red, .narrow, "");
96 w.finish();
97
98 var r = try CellRowReader.init(list.items);
99 try std.testing.expectEqual(@as(u16, 5), r.ncells);
100 const c0 = (try r.next()).?;
101 try std.testing.expectEqual(Wide.wide, c0.wide);
102 try std.testing.expectEqualStrings("漢", c0.text);
103 const c1 = (try r.next()).?;
104 try std.testing.expectEqual(Wide.spacer_tail, c1.wide);
105 try std.testing.expectEqualStrings("", c1.text);
106 const c2 = (try r.next()).?;
107 try std.testing.expectEqualStrings("e\u{301}", c2.text);
108 const c3 = (try r.next()).?;
109 try std.testing.expect(c3.style.eql(red));
110 try std.testing.expectEqualStrings("x", c3.text);
111 const c4 = (try r.next()).?;
112 try std.testing.expect(c4.style.eql(red));
113 try std.testing.expectEqualStrings("", c4.text);
114 try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
115 try std.testing.expectEqual(@as(usize, 0), r.remaining().len);
116 }
117
118 test "cellrow: an ascii run is only taken when every cell qualifies" {
119 const alloc = std.testing.allocator;
120 var list: std.ArrayList(u8) = .empty;
121 defer list.deinit(alloc);
122 var w = try CellRowWriter.begin(&list, alloc);
123 try w.cell(.{}, .narrow, "a");
124 try w.cell(.{}, .narrow, "é"); // 2 bytes: breaks the ascii form for the whole run
125 w.finish();
126 // ncells=2; run: count=2, flags=0; cells: head 1 'a', head 2 0xC3 0xA9
127 try std.testing.expectEqualSlices(u8, &[_]u8{
128 2, 0,
129 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
130 1, 'a', 2, 0xC3, 0xA9,
131 }, list.items);
132 }
133
134 test "cellrow: the reader leaves the bytes after the row alone" {
135 const alloc = std.testing.allocator;
136 var list: std.ArrayList(u8) = .empty;
137 defer list.deinit(alloc);
138 var w = try CellRowWriter.begin(&list, alloc);
139 try w.cell(.{}, .narrow, "a");
140 w.finish();
141 try list.appendSlice(alloc, "tail");
142 var r = try CellRowReader.init(list.items);
143 _ = try r.next();
144 try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
145 try std.testing.expectEqualStrings("tail", r.remaining());
146 }
147
148 test "cellrow: malformed rows are BadPayload, never a read past the end" {
149 // A run that claims more cells than the row's ncells: refused at the
150 // run header, before a cell is read.
151 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' });
152 try std.testing.expectError(error.BadPayload, over.next());
153 // A cell whose text_len runs past the payload.
154 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' });
155 try std.testing.expectError(error.BadPayload, short_text.next());
156 // A row shorter than its own prefix.
157 try std.testing.expectError(error.BadPayload, CellRowReader.init(&[_]u8{1}));
158 // A row that ends mid-run header.
159 var mid_header = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0 });
160 try std.testing.expectError(error.BadPayload, mid_header.next());
161 }
162 ```
163
164 - [ ] **Step 2: Run the suite to see them fail**
165
166 Run: `./deps/zig/zig build test 2>&1 | tail -20`
167 Expected: compile errors naming `CellRowWriter`, `CellRowReader`, `CellStyle`.
168
169 - [ ] **Step 3: Implement the codec**
170
171 Insert into `src/engine/protocol.zig` after `deltaRowIterator`:
172
173 ```zig
174 // ---------------------------------------------------------------------------
175 // CellRow: one grid row as runs of styled cells. The client copies these into
176 // a grid and never parses VT; the daemon's ghostty is the only parser left.
177 // A row ends at the last cell that is not a default-style blank, and the
178 // reader's caller fills the rest of the width with blanks.
179
180 pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 };
181
182 pub const color_none: u32 = 0;
183
184 pub fn colorPalette(index: u8) u32 {
185 return (1 << 24) | @as(u32, index);
186 }
187
188 pub fn colorRgb(r: u8, g: u8, b: u8) u32 {
189 return (2 << 24) | (@as(u32, r) << 16) | (@as(u32, g) << 8) | @as(u32, b);
190 }
191
192 pub const CellStyle = struct {
193 fg: u32 = color_none,
194 bg: u32 = color_none,
195 ul: u32 = color_none,
196 /// ghostty Style.Flags bit order: bold 0, italic 1, faint 2, blink 3,
197 /// inverse 4, invisible 5, strikethrough 6, overline 7, underline 8-10.
198 flags: u16 = 0,
199
200 pub fn eql(a: CellStyle, b: CellStyle) bool {
201 return a.fg == b.fg and a.bg == b.bg and a.ul == b.ul and a.flags == b.flags;
202 }
203
204 pub fn isDefault(self: CellStyle) bool {
205 return self.eql(.{});
206 }
207 };
208
209 /// Run flag bit: every cell in the run is one byte 0x20..0x7E, narrow, and
210 /// carries no head byte. Dense text is what a wire pays for most.
211 pub const run_ascii: u16 = 1 << 15;
212 pub const run_header_len = 16;
213 pub const cell_row_prefix_len = 2;
214 pub const cell_text_max = 63;
215
216 pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 };
217
218 fn asciiCell(wide: Wide, text: []const u8) bool {
219 return wide == .narrow and text.len == 1 and text[0] >= 0x20 and text[0] <= 0x7E;
220 }
221
222 pub const CellRowWriter = struct {
223 list: *std.ArrayList(u8),
224 alloc: std.mem.Allocator,
225 prefix_at: usize,
226 ncells: u16 = 0,
227 /// The open run's cells, held back until the run closes so the ascii
228 /// decision is made over the whole run.
229 run: std.ArrayListUnmanaged(DecodedCell) = .empty,
230 run_style: CellStyle = .{},
231
232 pub fn begin(list: *std.ArrayList(u8), alloc: std.mem.Allocator) !CellRowWriter {
233 const at = list.items.len;
234 try list.appendSlice(alloc, &[_]u8{ 0, 0 });
235 return .{ .list = list, .alloc = alloc, .prefix_at = at };
236 }
237
238 pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, text: []const u8) !void {
239 std.debug.assert(text.len <= cell_text_max);
240 if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush();
241 if (self.run.items.len == 0) self.run_style = style;
242 try self.run.append(self.alloc, .{ .style = style, .wide = wide, .text = text });
243 self.ncells += 1;
244 }
245
246 fn flush(self: *CellRowWriter) !void {
247 const cells = self.run.items;
248 if (cells.len == 0) return;
249 var ascii = true;
250 for (cells) |c| {
251 if (!asciiCell(c.wide, c.text)) {
252 ascii = false;
253 break;
254 }
255 }
256 var hdr: [run_header_len]u8 = undefined;
257 std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little);
258 std.mem.writeInt(u16, hdr[2..4], self.run_style.flags | (if (ascii) run_ascii else 0), .little);
259 std.mem.writeInt(u32, hdr[4..8], self.run_style.fg, .little);
260 std.mem.writeInt(u32, hdr[8..12], self.run_style.bg, .little);
261 std.mem.writeInt(u32, hdr[12..16], self.run_style.ul, .little);
262 try self.list.appendSlice(self.alloc, &hdr);
263 for (cells) |c| {
264 if (ascii) {
265 try self.list.append(self.alloc, c.text[0]);
266 } else {
267 const head: u8 = (@as(u8, @intFromEnum(c.wide)) << 6) | @as(u8, @intCast(c.text.len));
268 try self.list.append(self.alloc, head);
269 try self.list.appendSlice(self.alloc, c.text);
270 }
271 }
272 self.run.clearRetainingCapacity();
273 }
274
275 /// Closes the open run and stamps ncells. The writer is spent after this.
276 pub fn finish(self: *CellRowWriter) void {
277 self.flush() catch |e| switch (e) {
278 error.OutOfMemory => @panic("CellRowWriter.finish: out of memory"),
279 };
280 std.mem.writeInt(u16, self.list.items[self.prefix_at..][0..2], self.ncells, .little);
281 self.run.deinit(self.alloc);
282 }
283 };
284
285 pub const CellRowReader = struct {
286 rest: []const u8,
287 ncells: u16,
288 read: u16 = 0,
289 run_left: u16 = 0,
290 run_style: CellStyle = .{},
291 run_ascii: bool = false,
292
293 pub fn init(bytes: []const u8) !CellRowReader {
294 if (bytes.len < cell_row_prefix_len) return error.BadPayload;
295 return .{
296 .rest = bytes[cell_row_prefix_len..],
297 .ncells = std.mem.readInt(u16, bytes[0..2], .little),
298 };
299 }
300
301 pub fn next(self: *CellRowReader) !?DecodedCell {
302 if (self.read == self.ncells) return null;
303 if (self.run_left == 0) {
304 if (self.rest.len < run_header_len) return error.BadPayload;
305 const count = std.mem.readInt(u16, self.rest[0..2], .little);
306 const flags = std.mem.readInt(u16, self.rest[2..4], .little);
307 // A run may not claim cells the row does not have.
308 if (count == 0 or count > self.ncells - self.read) return error.BadPayload;
309 self.run_left = count;
310 self.run_ascii = flags & run_ascii != 0;
311 self.run_style = .{
312 .flags = flags & ~run_ascii,
313 .fg = std.mem.readInt(u32, self.rest[4..8], .little),
314 .bg = std.mem.readInt(u32, self.rest[8..12], .little),
315 .ul = std.mem.readInt(u32, self.rest[12..16], .little),
316 };
317 self.rest = self.rest[run_header_len..];
318 }
319 self.run_left -= 1;
320 self.read += 1;
321 if (self.run_ascii) {
322 if (self.rest.len < 1) return error.BadPayload;
323 const text = self.rest[0..1];
324 self.rest = self.rest[1..];
325 return .{ .style = self.run_style, .wide = .narrow, .text = text };
326 }
327 if (self.rest.len < 1) return error.BadPayload;
328 const head = self.rest[0];
329 const len: usize = head & 0x3f;
330 if (self.rest.len < 1 + len) return error.BadPayload;
331 const text = self.rest[1 .. 1 + len];
332 self.rest = self.rest[1 + len ..];
333 return .{ .style = self.run_style, .wide = @enumFromInt(head >> 6), .text = text };
334 }
335
336 /// The bytes after this row — the next row of a dense run, or nothing.
337 pub fn remaining(self: *const CellRowReader) []const u8 {
338 return self.rest;
339 }
340 };
341 ```
342
343 - [ ] **Step 4: Run the suite to see them pass**
344
345 Run: `./deps/zig/zig build test 2>&1 | tail -20`
346 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).
347
348 - [ ] **Step 5: Commit**
349
350 ```bash
351 ./deps/zig/zig fmt src/engine/protocol.zig
352 make check; echo rc=$?
353 git add src/engine/protocol.zig
354 git commit -m "feat: the CellRow codec, a grid row as runs of styled cells"
355 ```
356
357 ---
358
359 ### Task 2: The daemon encoder and the wire-size measurement
360
361 **Files:**
362 - Modify: `src/engine/engine.zig` (after `dumpScrollback`, before `extractSelection`)
363 - Test: `src/engine/engine.zig`
364
365 **Interfaces:**
366 - 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`.
367 - Produces:
368 - `pub fn encodeViewportRow(self: *Engine, alloc, y: u16) ![]u8`
369 - `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
370 - `fn packStyle(style: vt.Style) proto.CellStyle` (private)
371
372 - [ ] **Step 1: Write the failing round-trip tests**
373
374 Append to `src/engine/engine.zig`'s tests:
375
376 ```zig
377 fn decodeAll(alloc: std.mem.Allocator, bytes: []const u8) ![]proto.DecodedCell {
378 var out: std.ArrayList(proto.DecodedCell) = .empty;
379 var r = try proto.CellRowReader.init(bytes);
380 while (try r.next()) |c| try out.append(alloc, c);
381 return out.toOwnedSlice(alloc);
382 }
383
384 test "encodeViewportRow: ascii, a wide glyph with its spacer, a grapheme, and trailing blanks dropped" {
385 const alloc = std.testing.allocator;
386 var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
387 defer e.deinit();
388 e.feed("ab漢e\u{301}");
389 const row = try e.encodeViewportRow(alloc, 0);
390 defer alloc.free(row);
391 const cells = try decodeAll(alloc, row);
392 defer alloc.free(cells);
393 // a b 漢 (spacer) é — five cells; the seven blanks after are not sent.
394 try std.testing.expectEqual(@as(usize, 5), cells.len);
395 try std.testing.expectEqualStrings("a", cells[0].text);
396 try std.testing.expectEqual(proto.Wide.wide, cells[2].wide);
397 try std.testing.expectEqualStrings("漢", cells[2].text);
398 try std.testing.expectEqual(proto.Wide.spacer_tail, cells[3].wide);
399 try std.testing.expectEqualStrings("e\u{301}", cells[4].text);
400 }
401
402 test "encodeViewportRow: styles pack as the wire says, and a bg-only cell is not blank" {
403 const alloc = std.testing.allocator;
404 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
405 defer e.deinit();
406 // bold red on palette-4 blue 'x', then EL with the blue background held:
407 // the cells after x carry a background and no glyph.
408 e.feed("\x1b[1;31;44mx\x1b[0m\x1b[44m\x1b[K");
409 const row = try e.encodeViewportRow(alloc, 0);
410 defer alloc.free(row);
411 const cells = try decodeAll(alloc, row);
412 defer alloc.free(cells);
413 try std.testing.expectEqual(@as(usize, 8), cells.len);
414 try std.testing.expectEqual(proto.colorPalette(1), cells[0].style.fg);
415 try std.testing.expectEqual(proto.colorPalette(4), cells[0].style.bg);
416 try std.testing.expectEqual(@as(u16, 1), cells[0].style.flags & 1);
417 try std.testing.expectEqualStrings("", cells[7].text);
418 try std.testing.expectEqual(proto.colorPalette(4), cells[7].style.bg);
419 }
420
421 test "encodeViewportRow: an empty row is ncells 0 and nothing else" {
422 const alloc = std.testing.allocator;
423 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
424 defer e.deinit();
425 const row = try e.encodeViewportRow(alloc, 0);
426 defer alloc.free(row);
427 try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row);
428 }
429
430 test "encodeScrollback: clamps like dumpScrollback and returns dense rows" {
431 const alloc = std.testing.allocator;
432 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 });
433 defer e.deinit();
434 e.feed("one\r\ntwo\r\nthree\r\nfour");
435 // history: one two; viewport: three four. Ask for 3 rows from 1: two three four.
436 const got = try e.encodeScrollback(alloc, 1, 3);
437 defer alloc.free(got.bytes);
438 try std.testing.expectEqual(@as(u32, 1), got.first);
439 try std.testing.expectEqual(@as(u16, 3), got.count);
440 var r = try proto.CellRowReader.init(got.bytes);
441 try std.testing.expectEqualStrings("t", (try r.next()).?.text);
442 // Past the end clamps to what exists.
443 const tail = try e.encodeScrollback(alloc, 100, 5);
444 defer alloc.free(tail.bytes);
445 try std.testing.expectEqual(@as(u32, 3), tail.first);
446 try std.testing.expectEqual(@as(u16, 1), tail.count);
447 }
448 ```
449
450 - [ ] **Step 2: Run to see them fail**
451
452 Run: `./deps/zig/zig build test 2>&1 | tail -20`
453 Expected: `no member named 'encodeViewportRow'`.
454
455 - [ ] **Step 3: Implement the encoder**
456
457 Insert into `Engine` after `dumpScrollback`:
458
459 ```zig
460 pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 };
461
462 fn packColor(col: anytype) u32 {
463 return switch (col) {
464 .none => proto.color_none,
465 .palette => |p| proto.colorPalette(p),
466 .rgb => |c| proto.colorRgb(c.r, c.g, c.b),
467 };
468 }
469
470 fn packStyle(style: vt.Style) proto.CellStyle {
471 return .{
472 .fg = packColor(style.fg_color),
473 .bg = packColor(style.bg_color),
474 .ul = packColor(style.underline_color),
475 .flags = @bitCast(style.flags),
476 };
477 }
478
479 /// One row of the active screen at `pt` as a CellRow. The last cell sent
480 /// is the last one that is not a default blank; a bare-background cell
481 /// counts as content, or a coloured EL would vanish.
482 fn encodeRowAt(self: *Engine, alloc: std.mem.Allocator, pt: vt.point.Point) ![]u8 {
483 const screen = self.term.screens.active;
484 var list: std.ArrayList(u8) = .empty;
485 errdefer list.deinit(alloc);
486 var w = try proto.CellRowWriter.begin(&list, alloc);
487 const pin = screen.pages.pin(pt) orelse {
488 w.finish();
489 return list.toOwnedSlice(alloc);
490 };
491 const page = &pin.node.data;
492 const rac = pin.rowAndCell();
493 const cells = page.getCells(rac.row);
494 const cols: usize = self.term.cols;
495
496 // Find the last cell worth sending.
497 var last: usize = 0;
498 var any = false;
499 for (cells[0..cols], 0..) |c, x| {
500 const blank = c.style_id == 0 and c.wide == .narrow and switch (c.content_tag) {
501 .codepoint => c.content.codepoint == 0 or c.content.codepoint == ' ',
502 .codepoint_grapheme => false,
503 .bg_color_palette, .bg_color_rgb => false,
504 };
505 if (!blank) {
506 last = x;
507 any = true;
508 }
509 }
510 if (!any) {
511 w.finish();
512 return list.toOwnedSlice(alloc);
513 }
514
515 var text: [proto.cell_text_max]u8 = undefined;
516 for (cells[0 .. last + 1]) |*c| {
517 var style: proto.CellStyle = if (c.style_id == 0) .{} else packStyle(page.styles.get(page.memory, c.style_id).*);
518 var len: usize = 0;
519 switch (c.content_tag) {
520 .codepoint, .codepoint_grapheme => {
521 if (c.content.codepoint != 0) {
522 len += std.unicode.utf8Encode(@intCast(c.content.codepoint), text[len..]) catch 0;
523 }
524 if (c.hasGrapheme()) {
525 if (page.lookupGrapheme(c)) |cps| {
526 for (cps) |cp| {
527 var buf: [4]u8 = undefined;
528 const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch continue;
529 if (len + n > proto.cell_text_max) break;
530 @memcpy(text[len .. len + n], buf[0..n]);
531 len += n;
532 }
533 }
534 }
535 },
536 .bg_color_palette => style.bg = proto.colorPalette(c.content.color_palette),
537 .bg_color_rgb => style.bg = proto.colorRgb(c.content.color_rgb.r, c.content.color_rgb.g, c.content.color_rgb.b),
538 }
539 const wide: proto.Wide = switch (c.wide) {
540 .narrow => .narrow,
541 .wide => .wide,
542 .spacer_tail => .spacer_tail,
543 .spacer_head => .spacer_head,
544 };
545 // A space is the same blank as no text; sending it would cost a
546 // byte per cell of every padded line.
547 const t: []const u8 = if (len == 1 and text[0] == ' ') "" else text[0..len];
548 try w.cell(style, wide, t);
549 }
550 w.finish();
551 return list.toOwnedSlice(alloc);
552 }
553
554 pub fn encodeViewportRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
555 std.debug.assert(y < self.term.rows);
556 return self.encodeRowAt(alloc, .{ .viewport = .{ .x = 0, .y = y } });
557 }
558
559 /// Screen-space rows [start, start+count) on the active screen, clamped
560 /// to what exists, dense. Row 0 is the oldest retained history row.
561 pub fn encodeScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) !EncodedRows {
562 const total: u32 = self.historyRows() + self.term.rows;
563 const first = @min(start, total -| 1);
564 const last = @min(first + count -| 1, total -| 1);
565 var list: std.ArrayList(u8) = .empty;
566 errdefer list.deinit(alloc);
567 var y = first;
568 var n: u16 = 0;
569 while (y <= last and total > 0) : (y += 1) {
570 const row = try self.encodeRowAt(alloc, .{ .screen = .{ .x = 0, .y = y } });
571 defer alloc.free(row);
572 try list.appendSlice(alloc, row);
573 n += 1;
574 }
575 return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) };
576 }
577 ```
578
579 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.
580
581 - [ ] **Step 4: Run to see them pass**
582
583 Run: `./deps/zig/zig build test 2>&1 | tail -20`
584 Expected: the four `encode…` tests pass. Break one assertion on purpose, see it fail, restore.
585
586 - [ ] **Step 5: Write the measurement test**
587
588 Append:
589
590 ```zig
591 fn vtBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
592 var n: usize = 0;
593 var y: u16 = 0;
594 while (y < e.term.rows) : (y += 1) {
595 const b = try e.dumpVtRow(alloc, y);
596 defer alloc.free(b);
597 n += b.len;
598 }
599 return n;
600 }
601
602 fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
603 var n: usize = 0;
604 var y: u16 = 0;
605 while (y < e.term.rows) : (y += 1) {
606 const b = try e.encodeViewportRow(alloc, y);
607 defer alloc.free(b);
608 n += b.len;
609 }
610 return n;
611 }
612
613 test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
614 const alloc = std.testing.allocator;
615 const Screen = struct { name: []const u8, feed: []const u8 };
616 const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24;
617 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;
618 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;
619 const shell = "$ ls\r\nbuild.zig docs src test\r\n$ \r\n";
620 const screens = [_]Screen{
621 .{ .name = "prose", .feed = prose },
622 .{ .name = "vim", .feed = vim },
623 .{ .name = "htop", .feed = htop },
624 .{ .name = "shell", .feed = shell },
625 };
626 for (screens) |s| {
627 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
628 defer e.deinit();
629 e.feed(s.feed);
630 const v = try vtBytes(alloc, e);
631 const c = try cellBytes(alloc, e);
632 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)) });
633 }
634 }
635 ```
636
637 - [ ] **Step 6: Run it and record the numbers**
638
639 Run: `./deps/zig/zig build test 2>&1 | grep cells-measure`
640 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.
641
642 - [ ] **Step 7: Commit**
643
644 ```bash
645 ./deps/zig/zig fmt src/engine/engine.zig
646 make check; echo rc=$?
647 git add src/engine/engine.zig docs/decisions.md
648 git commit -m "feat: the daemon encodes a grid row as cells, and the size is measured"
649 ```
650
651 ---
652
653 ### Task 3: `grid.zig`, the client grid, pinned against the engine
654
655 **Files:**
656 - Create: `src/engine/grid.zig`
657 - Modify: `src/engine/term.zig` (export `grid`), `src/engine/engine.zig` (`mirrorInto` + oracle tests)
658 - Test: `src/engine/grid.zig`, `src/engine/engine.zig`
659
660 **Interfaces:**
661 - Consumes: `proto.CellRowReader`, `proto.DecodedCell`, `proto.CellStyle`, `proto.Wide` (Task 1); `Engine.encodeViewportRow` (Task 2).
662 - Produces:
663 - `pub const CursorPos = struct { x: u16, y: u16 }`
664 - `pub const Cell = struct { style: proto.CellStyle = .{}, wide: proto.Wide = .narrow, text_off: u32 = 0, text_len: u8 = 0 }`
665 - `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 }`
666 - `pub const RowView = struct { col_off: u16, cols: u16 }` (moves here from `Engine.RowView`; same fields)
667 - `pub const ColSpan = struct { from: u16, to: u16 }`
668 - `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
669 - `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
670 - `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
671 - `pub fn decodeRows(alloc, bytes, count: u16, cols: u16) ![]Row` and `pub fn freeRows(alloc, rows: []Row) void`
672 - `Engine.mirrorInto(self, g: *Grid) !void` — resize, encode every viewport row, apply, set cursor
673
674 - [ ] **Step 1: Write the failing grid tests**
675
676 Create `src/engine/grid.zig` with the header and tests only:
677
678 ```zig
679 //! The client's grid: what a replica holds instead of an emulator. Cells
680 //! arrive already parsed (protocol.CellRow) and are copied into rows; the
681 //! only rules here are the two wide-glyph rules a painter needs and the
682 //! plain-text dump every harness compares against the daemon's.
683 //!
684 //! Deliberately platform-free: no posix, no fds, no clocks, no escape
685 //! bytes — this compiles for wasm32-freestanding and sits under rule 4.
686 const std = @import("std");
687 const proto = @import("protocol.zig");
688
689 test "grid: applyRow fills the row and blanks the rest of the width" {
690 const alloc = std.testing.allocator;
691 const g = try Grid.init(alloc, 6, 2);
692 defer g.deinit();
693 var list: std.ArrayList(u8) = .empty;
694 defer list.deinit(alloc);
695 var w = try proto.CellRowWriter.begin(&list, alloc);
696 try w.cell(.{ .fg = proto.colorPalette(2) }, .narrow, "h");
697 try w.cell(.{}, .wide, "漢");
698 try w.cell(.{}, .spacer_tail, "");
699 w.finish();
700 try g.applyRow(1, list.items);
701 const r = g.row(1);
702 try std.testing.expectEqualStrings("h", r.textOf(r.cells[0]));
703 try std.testing.expectEqual(proto.colorPalette(2), r.cells[0].style.fg);
704 try std.testing.expectEqual(proto.Wide.wide, r.cells[1].wide);
705 try std.testing.expectEqual(proto.Wide.spacer_tail, r.cells[2].wide);
706 try std.testing.expectEqual(@as(u8, 0), r.cells[3].text_len);
707 try std.testing.expect(r.cells[5].style.isDefault());
708 // Row 0 was never written and is blank.
709 try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
710 }
711
712 test "grid: a row wider than the grid is BadPayload and leaves the row untouched" {
713 const alloc = std.testing.allocator;
714 const g = try Grid.init(alloc, 2, 1);
715 defer g.deinit();
716 var list: std.ArrayList(u8) = .empty;
717 defer list.deinit(alloc);
718 var w = try proto.CellRowWriter.begin(&list, alloc);
719 try w.cell(.{}, .narrow, "a");
720 try w.cell(.{}, .narrow, "b");
721 try w.cell(.{}, .narrow, "c");
722 w.finish();
723 try std.testing.expectError(error.BadPayload, g.applyRow(0, list.items));
724 try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
725 }
726
727 test "grid: dumpPlain trims trailing blanks per row and joins rows with newlines" {
728 const alloc = std.testing.allocator;
729 const g = try Grid.init(alloc, 6, 3);
730 defer g.deinit();
731 var list: std.ArrayList(u8) = .empty;
732 defer list.deinit(alloc);
733 var w = try proto.CellRowWriter.begin(&list, alloc);
734 try w.cell(.{}, .narrow, "a");
735 try w.cell(.{}, .narrow, "");
736 try w.cell(.{}, .narrow, "b");
737 w.finish();
738 try g.applyRow(0, list.items);
739 const s = try g.dumpPlain(alloc);
740 defer alloc.free(s);
741 try std.testing.expectEqualStrings("a b\n\n", s);
742 }
743
744 test "grid: clipCol steps inward off a wide glyph at the pane edge; snapWide steps outward" {
745 const alloc = std.testing.allocator;
746 const g = try Grid.init(alloc, 6, 1);
747 defer g.deinit();
748 var list: std.ArrayList(u8) = .empty;
749 defer list.deinit(alloc);
750 var w = try proto.CellRowWriter.begin(&list, alloc);
751 try w.cell(.{}, .narrow, "a");
752 try w.cell(.{}, .wide, "漢");
753 try w.cell(.{}, .spacer_tail, "");
754 try w.cell(.{}, .narrow, "b");
755 w.finish();
756 try g.applyRow(0, list.items);
757 // A pane 2 wide would cut 漢 in half: the last column that fits is 0.
758 try std.testing.expectEqual(@as(?u16, 0), g.clipCol(0, .{ .col_off = 0, .cols = 2 }));
759 try std.testing.expectEqual(@as(?u16, 3), g.clipCol(0, .{ .col_off = 0, .cols = 6 }));
760 try std.testing.expectEqual(@as(?u16, null), g.clipCol(0, .{ .col_off = 0, .cols = 0 }));
761 // A drag from the spacer to the wide cell covers the whole glyph.
762 const s = g.snapWide(0, 2, 1);
763 try std.testing.expectEqual(@as(u16, 1), s.from);
764 try std.testing.expectEqual(@as(u16, 2), s.to);
765 }
766
767 test "grid: decodeRows reads a dense run and refuses a short one" {
768 const alloc = std.testing.allocator;
769 var list: std.ArrayList(u8) = .empty;
770 defer list.deinit(alloc);
771 var i: usize = 0;
772 while (i < 2) : (i += 1) {
773 var w = try proto.CellRowWriter.begin(&list, alloc);
774 try w.cell(.{}, .narrow, "x");
775 w.finish();
776 }
777 const rows = try decodeRows(alloc, list.items, 2, 4);
778 defer freeRows(alloc, rows);
779 try std.testing.expectEqual(@as(usize, 2), rows.len);
780 try std.testing.expectEqualStrings("x", rows[1].textOf(rows[1].cells[0]));
781 try std.testing.expectError(error.BadPayload, decodeRows(alloc, list.items, 3, 4));
782 }
783 ```
784
785 - [ ] **Step 2: Run to see them fail**
786
787 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`
788 Expected: `use of undeclared identifier 'Grid'`.
789
790 - [ ] **Step 3: Implement the grid**
791
792 Insert above the tests in `src/engine/grid.zig`:
793
794 ```zig
795 pub const CursorPos = struct { x: u16, y: u16 };
796
797 pub const Cell = struct {
798 style: proto.CellStyle = .{},
799 wide: proto.Wide = .narrow,
800 text_off: u32 = 0,
801 text_len: u8 = 0,
802
803 pub fn isBlank(self: Cell) bool {
804 return self.text_len == 0 and self.wide == .narrow and self.style.isDefault();
805 }
806 };
807
808 pub const Row = struct {
809 cells: []Cell,
810 /// Every cell's text, back to back; a cell slices into it.
811 text: std.ArrayListUnmanaged(u8) = .empty,
812
813 pub fn textOf(self: *const Row, c: Cell) []const u8 {
814 return self.text.items[c.text_off .. c.text_off + c.text_len];
815 }
816
817 fn blank(self: *Row) void {
818 @memset(self.cells, .{});
819 self.text.clearRetainingCapacity();
820 }
821
822 pub fn deinit(self: *Row, alloc: std.mem.Allocator) void {
823 alloc.free(self.cells);
824 self.text.deinit(alloc);
825 }
826 };
827
828 pub const RowView = struct { col_off: u16, cols: u16 };
829
830 /// Decode one CellRow into `into` (already sized to `cols`). Two passes on
831 /// purpose — validate, then fill — so a bad payload leaves the row as it
832 /// was without a scratch copy: the caller resyncs from a snapshot, and half
833 /// a row would paint as a lie until then.
834 pub fn decodeRow(alloc: std.mem.Allocator, into: *Row, bytes: []const u8, cols: u16) ![]const u8 {
835 if (cols > proto.max_cols) return error.BadPayload;
836 var check = try proto.CellRowReader.init(bytes);
837 if (check.ncells > cols) return error.BadPayload;
838 var text_total: usize = 0;
839 while (try check.next()) |c| text_total += c.text.len;
840 // Every byte has been read once and is well-formed; nothing below can fail
841 // except the allocation, which happens before the row is touched.
842 try into.text.ensureTotalCapacity(alloc, text_total);
843 into.blank();
844 var r = proto.CellRowReader.init(bytes) catch unreachable;
845 var x: usize = 0;
846 while (r.next() catch unreachable) |c| : (x += 1) {
847 into.cells[x] = .{
848 .style = c.style,
849 .wide = c.wide,
850 .text_off = @intCast(into.text.items.len),
851 .text_len = @intCast(c.text.len),
852 };
853 into.text.appendSliceAssumeCapacity(c.text);
854 }
855 return r.remaining();
856 }
857
858 /// A column span, inclusive at both ends.
859 pub const ColSpan = struct { from: u16, to: u16 };
860
861 /// The last column of `r` (a row `cols` wide) that fits in `view`, or null
862 /// when not one character does. Inward: half a wide glyph past a pane edge
863 /// is a column stolen from the neighbour.
864 pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16 {
865 if (view.cols == 0 or cols == 0) return null;
866 var hi: u16 = @min(view.cols - 1, cols - 1);
867 if (r.cells[hi].wide == .wide) {
868 if (hi == 0) return null;
869 hi -= 1;
870 }
871 return hi;
872 }
873
874 /// Widen [from, to] to whole glyphs. Outward: half a character under the
875 /// pointer means the character is under the pointer.
876 pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan {
877 var lo = @min(from, to);
878 var hi = @max(from, to);
879 if (lo > 0 and r.cells[lo].wide == .spacer_tail) lo -= 1;
880 if (hi + 1 < cols and r.cells[hi].wide == .wide) hi += 1;
881 return .{ .from = lo, .to = hi };
882 }
883
884 pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: u16) ![]Row {
885 var rows = try alloc.alloc(Row, count);
886 var made: usize = 0;
887 errdefer {
888 for (rows[0..made]) |*r| r.deinit(alloc);
889 alloc.free(rows);
890 }
891 var rest = bytes;
892 for (rows) |*r| {
893 r.* = .{ .cells = try alloc.alloc(Cell, cols) };
894 made += 1;
895 @memset(r.cells, .{});
896 rest = try decodeRow(alloc, r, rest, cols);
897 }
898 return rows;
899 }
900
901 pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void {
902 for (rows) |*r| r.deinit(alloc);
903 alloc.free(rows);
904 }
905
906 pub const Grid = struct {
907 alloc: std.mem.Allocator,
908 cols: u16,
909 rows: u16,
910 cursor: CursorPos = .{ .x = 0, .y = 0 },
911 lines: []Row,
912
913 pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !*Grid {
914 const g = try alloc.create(Grid);
915 errdefer alloc.destroy(g);
916 g.* = .{ .alloc = alloc, .cols = cols, .rows = rows, .lines = &.{} };
917 try g.allocLines(cols, rows);
918 return g;
919 }
920
921 fn allocLines(self: *Grid, cols: u16, rows: u16) !void {
922 const lines = try self.alloc.alloc(Row, rows);
923 var made: usize = 0;
924 errdefer {
925 for (lines[0..made]) |*r| r.deinit(self.alloc);
926 self.alloc.free(lines);
927 }
928 for (lines) |*r| {
929 r.* = .{ .cells = try self.alloc.alloc(Cell, cols) };
930 made += 1;
931 @memset(r.cells, .{});
932 }
933 self.freeLines();
934 self.lines = lines;
935 self.cols = cols;
936 self.rows = rows;
937 }
938
939 fn freeLines(self: *Grid) void {
940 for (self.lines) |*r| r.deinit(self.alloc);
941 self.alloc.free(self.lines);
942 self.lines = &.{};
943 }
944
945 pub fn deinit(self: *Grid) void {
946 self.freeLines();
947 self.alloc.destroy(self);
948 }
949
950 /// A resize is a blank grid: the snapshot that carries it repaints every row.
951 pub fn resize(self: *Grid, cols: u16, rows: u16) !void {
952 try self.allocLines(cols, rows);
953 self.cursor = .{ .x = 0, .y = 0 };
954 }
955
956 pub fn clear(self: *Grid) void {
957 for (self.lines) |*r| r.blank();
958 self.cursor = .{ .x = 0, .y = 0 };
959 }
960
961 pub fn applyRow(self: *Grid, y: u16, bytes: []const u8) !void {
962 if (y >= self.rows) return error.BadPayload;
963 _ = try decodeRow(self.alloc, &self.lines[y], bytes, self.cols);
964 }
965
966 pub fn row(self: *const Grid, y: u16) *const Row {
967 return &self.lines[y];
968 }
969
970 /// The text Engine.dumpPlain produces for the same screen: a space per
971 /// blank or spacer-less empty cell, nothing for a spacer, trailing spaces
972 /// trimmed per row, rows joined by newlines. The engine.zig oracle test
973 /// is the authority; this is written to match it.
974 pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 {
975 var out: std.ArrayList(u8) = .empty;
976 errdefer out.deinit(alloc);
977 for (self.lines, 0..) |*r, y| {
978 const start = out.items.len;
979 for (r.cells) |c| {
980 switch (c.wide) {
981 .spacer_tail, .spacer_head => continue,
982 .narrow, .wide => {},
983 }
984 if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
985 }
986 while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
987 if (y + 1 < self.lines.len) try out.append(alloc, '\n');
988 }
989 return out.toOwnedSlice(alloc);
990 }
991
992 pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 {
993 return clipColOf(&self.lines[y], self.cols, view);
994 }
995
996 pub fn snapWide(self: *const Grid, y: u16, from: u16, to: u16) ColSpan {
997 return snapWideOf(&self.lines[y], self.cols, from, to);
998 }
999 };
1000 ```
1001
1002 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).
1003
1004 - [ ] **Step 4: Run to see them pass**
1005
1006 Run: `./deps/zig/zig build test 2>&1 | tail -20`
1007 Expected: the five `grid:` tests pass.
1008
1009 - [ ] **Step 5: Write the oracle tests and `mirrorInto` in `engine.zig`**
1010
1011 Add to `Engine` (after `encodeScrollback`):
1012
1013 ```zig
1014 /// Encode every viewport row into `g` — the test bridge between an
1015 /// authored screen and the grid a client would hold, and the only way a
1016 /// harness gets a Grid from bytes without a daemon.
1017 pub fn mirrorInto(self: *Engine, g: *Grid) !void {
1018 if (g.cols != self.term.cols or g.rows != self.term.rows)
1019 try g.resize(@intCast(self.term.cols), @intCast(self.term.rows));
1020 var y: u16 = 0;
1021 while (y < self.term.rows) : (y += 1) {
1022 const row = try self.encodeViewportRow(self.alloc, y);
1023 defer self.alloc.free(row);
1024 try g.applyRow(y, row);
1025 }
1026 const cur = self.cursorPos();
1027 g.cursor = .{ .x = cur.x, .y = cur.y };
1028 }
1029 ```
1030
1031 with `const Grid = @import("grid.zig").Grid;` at the top (Task 6 turns it into `@import("term").grid.Grid`). Then the oracle tests:
1032
1033 ```zig
1034 test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for every screen shape" {
1035 const alloc = std.testing.allocator;
1036 const screens = [_][]const u8{
1037 "plain text\r\nsecond line",
1038 "w\u{6f22}\u{5b57}x e\u{301} \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} end",
1039 "\x1b[1;31mred bold\x1b[0m \x1b[44mblue bg\x1b[0m\x1b[K",
1040 "short\r\n\r\n\r\nafter blanks",
1041 "\x1b[?1049h\x1b[HTUI on alt\x1b[5;10Hcursor here",
1042 "line one\r\nline two\r\n" ** 30, // scrolled: history exists, viewport is the tail
1043 "\x1b[3;1Hcol\x1b[3;40Hfar\x1b[8;1H",
1044 };
1045 for (screens) |s| {
1046 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1047 defer e.deinit();
1048 e.feed(s);
1049 const g = try Grid.init(alloc, 1, 1);
1050 defer g.deinit();
1051 try e.mirrorInto(g);
1052 const want = try e.dumpPlain(alloc);
1053 defer alloc.free(want);
1054 const got = try g.dumpPlain(alloc);
1055 defer alloc.free(got);
1056 try std.testing.expectEqualStrings(want, got);
1057 try std.testing.expectEqual(e.cursorPos().x, g.cursor.x);
1058 try std.testing.expectEqual(e.cursorPos().y, g.cursor.y);
1059 }
1060 }
1061
1062 test "grid oracle: clipCol and snapWide agree with the engine's on a row with wide glyphs" {
1063 const alloc = std.testing.allocator;
1064 var e = try Engine.init(alloc, .{ .cols = 10, .rows = 1 });
1065 defer e.deinit();
1066 e.feed("a\u{6f22}b\u{5b57}");
1067 const g = try Grid.init(alloc, 1, 1);
1068 defer g.deinit();
1069 try e.mirrorInto(g);
1070 var cols: u16 = 0;
1071 while (cols <= 10) : (cols += 1) {
1072 const view = Engine.RowView{ .col_off = 0, .cols = cols };
1073 try std.testing.expectEqual(e.clipCol(0, view), g.clipCol(0, .{ .col_off = 0, .cols = cols }));
1074 }
1075 var from: u16 = 0;
1076 while (from < 6) : (from += 1) {
1077 var to: u16 = from;
1078 while (to < 6) : (to += 1) {
1079 const a = e.snapWide(0, from, to);
1080 const b = g.snapWide(0, from, to);
1081 try std.testing.expectEqual(a.from, b.from);
1082 try std.testing.expectEqual(a.to, b.to);
1083 }
1084 }
1085 }
1086 ```
1087
1088 `Engine.clipCol` and `Engine.snapWide` are private today; make them `pub` for this task (they are deleted in Task 6).
1089
1090 - [ ] **Step 6: Run the oracle**
1091
1092 Run: `./deps/zig/zig build test 2>&1 | tail -30`
1093 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.
1094
1095 - [ ] **Step 7: Commit**
1096
1097 ```bash
1098 ./deps/zig/zig fmt src/engine/grid.zig src/engine/engine.zig src/engine/term.zig src/engine/protocol.zig
1099 make check; echo rc=$?
1100 git add src/engine/grid.zig src/engine/engine.zig src/engine/term.zig src/engine/protocol.zig
1101 git commit -m "feat: the client grid, pinned against the engine as its oracle"
1102 ```
1103
1104 ---
1105
1106 ### Task 4: `term_modes` carries the alternate screen and cursor-keys bits
1107
1108 **Files:**
1109 - Modify: `src/engine/protocol.zig` (`TermModes`), `src/server/server.zig` (`sampleTermModes` ~2581–2610), `src/tui/interact.zig` (~1747)
1110 - Test: `src/engine/protocol.zig`, `src/server/server_test_session.zig`, `src/tui/interact.zig`
1111
1112 **Interfaces:**
1113 - Produces: `proto.TermModes.alt_screen: bool`, `proto.TermModes.cursor_keys: bool`.
1114
1115 - [ ] **Step 1: Write the failing tests**
1116
1117 In `src/engine/protocol.zig`, next to the existing `TermModes` round-trip test:
1118
1119 ```zig
1120 test "term_modes: alt_screen and cursor_keys round-trip and are off by default" {
1121 const on = encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = true });
1122 const back = try decodeTermModes(&on);
1123 try std.testing.expect(back.alt_screen);
1124 try std.testing.expect(back.cursor_keys);
1125 try std.testing.expect(!back.bracketed_paste);
1126 const none = try decodeTermModes(&encodeTermModes(.{ .bracketed_paste = false }));
1127 try std.testing.expect(!none.alt_screen and !none.cursor_keys);
1128 }
1129 ```
1130
1131 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.
1132
1133 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).
1134
1135 - [ ] **Step 2: Run to see them fail**
1136
1137 Run: `./deps/zig/zig build test 2>&1 | tail -20`
1138 Expected: `no field named 'alt_screen'`.
1139
1140 - [ ] **Step 3: Implement**
1141
1142 `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`.
1143
1144 `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.
1145
1146 `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`).
1147
1148 - [ ] **Step 4: Run to see them pass**
1149
1150 Run: `./deps/zig/zig build test 2>&1 | tail -20`
1151 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`.
1152
1153 - [ ] **Step 5: Commit**
1154
1155 ```bash
1156 make check; echo rc=$?
1157 git add src/engine/protocol.zig src/server/server.zig src/server/server_test_session.zig src/tui/interact.zig
1158 git commit -m "feat: term_modes carries the alternate screen and DECCKM, so the wheel rule needs no engine"
1159 ```
1160
1161 ---
1162
1163 ### Task 5: The flip — the wire carries cells, the replica is a grid, every painter reads it
1164
1165 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.
1166
1167 **Files:**
1168 - 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`
1169 - Test: all of the above; `make e2e`
1170
1171 **Interfaces:**
1172 - Consumes: everything from Tasks 1–4.
1173 - Produces:
1174 - `proto.MsgType.snapshot = 0x95`, `.delta = 0x96`, `.scrollback_chunk = 0x97`
1175 - `proto.snapshot_cursor_len = 4`, `proto.readSnapshotCursor(payload) !grid.CursorPos`, `proto.writeSnapshotCursor(buf: *[4]u8, cur)`
1176 - `delta.buildSnapshot(alloc, eng: *Engine, prefix: proto.SnapshotPrefix) ![]u8`
1177 - `Replica { grid: *Grid, ... }` — `init(alloc, g: *Grid)`; `apply` unchanged in signature
1178 - `paint.rowToVtFrom(alloc, r: *const grid.Row, cols: u16, view: grid.RowView, span: ?Span) ![]u8` — the serializer, on a row and its width
1179 - `paint.rowToVt(alloc, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8` — `rowToVtFrom(alloc, g.row(y), g.cols, view, span)`
1180 - `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)`
1181
1182 - [ ] **Step 5.1: Frame numbers and the snapshot cursor (`protocol.zig`)**
1183
1184 Renumber in `MsgType`:
1185
1186 ```zig
1187 // Retired 2026-09-04 with the VT payloads they carried; never reused, so a
1188 // binary from either side of the break drops the other's frames instead
1189 // of painting them. 0x81 snapshot, 0x85 scrollback_chunk, 0x87 delta.
1190 snapshot = 0x95, // payload: SnapshotPrefix ++ u16 LE cursor_x ++ u16 LE cursor_y ++ rows × CellRow
1191 scrollback_chunk = 0x97, // payload: u32 LE start, u16 LE count ++ count × CellRow
1192 delta = 0x96, // payload: DeltaHeader ++ row_count × (u16 LE row ++ u32 LE len ++ CellRow)
1193 ```
1194
1195 Add after `readSnapshotPrefix`:
1196
1197 ```zig
1198 pub const snapshot_cursor_len = 4;
1199
1200 pub fn writeSnapshotCursor(buf: *[snapshot_cursor_len]u8, x: u16, y: u16) void {
1201 std.mem.writeInt(u16, buf[0..2], x, .little);
1202 std.mem.writeInt(u16, buf[2..4], y, .little);
1203 }
1204
1205 pub const SnapshotCursor = struct { x: u16, y: u16 };
1206
1207 pub fn readSnapshotCursor(payload: []const u8) !SnapshotCursor {
1208 if (payload.len < snapshot_prefix_len + snapshot_cursor_len) return error.BadPayload;
1209 const b = payload[snapshot_prefix_len..][0..snapshot_cursor_len];
1210 return .{ .x = std.mem.readInt(u16, b[0..2], .little), .y = std.mem.readInt(u16, b[2..4], .little) };
1211 }
1212 ```
1213
1214 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`.
1215
1216 - [ ] **Step 5.2: The tracker and the snapshot builder (`delta.zig`)**
1217
1218 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:
1219
1220 ```zig
1221 /// The snapshot payload: prefix, cursor, every viewport row dense. The
1222 /// cursor rides here rather than in the prefix so the prefix's golden pin
1223 /// and its readers stay as they were.
1224 pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.SnapshotPrefix) ![]u8 {
1225 var out: std.ArrayList(u8) = .empty;
1226 errdefer out.deinit(alloc);
1227 var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
1228 proto.writeSnapshotPrefix(&pbuf, prefix);
1229 try out.appendSlice(alloc, &pbuf);
1230 var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
1231 const cur = eng.cursorPos();
1232 proto.writeSnapshotCursor(&cbuf, cur.x, cur.y);
1233 try out.appendSlice(alloc, &cbuf);
1234 var y: u16 = 0;
1235 while (y < eng.term.rows) : (y += 1) {
1236 const row = try eng.encodeViewportRow(alloc, y);
1237 defer alloc.free(row);
1238 try out.appendSlice(alloc, row);
1239 }
1240 return out.toOwnedSlice(alloc);
1241 }
1242 ```
1243
1244 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`.
1245
1246 - [ ] **Step 5.3: The daemon's three sites (`server.zig`)**
1247
1248 `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.
1249
1250 `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.
1251
1252 `onFetchScrollback` (~1928): replace `dumpScrollback` with `encodeScrollback`; the header writes `got.first` and `got.count`, then `got.bytes`.
1253
1254 `dumpState` stays for `upgrade.zig`'s manifest and `debug_dump`; do not touch them.
1255
1256 - [ ] **Step 5.4: The replica over a grid (`replica.zig`)**
1257
1258 ```zig
1259 const Grid = @import("grid.zig").Grid;
1260 const grid_mod = @import("grid.zig");
1261
1262 pub const Replica = struct {
1263 alloc: std.mem.Allocator,
1264 /// Borrowed. The grid the frames are copied into.
1265 grid: *Grid,
1266 grid_size: proto.Size,
1267 session_epoch: u64 = 0,
1268 last_seq: u64 = 0,
1269 history_rows: u32 = 0,
1270 state_since_attach: bool = false,
1271
1272 pub const Applied = enum { painted, resync };
1273
1274 pub fn init(alloc: std.mem.Allocator, g: *Grid) Replica {
1275 return .{ .alloc = alloc, .grid = g, .grid_size = .{ .cols = g.cols, .rows = g.rows } };
1276 }
1277
1278 /// `.snapshot`: a short prefix or cursor is error.BadPayload with nothing
1279 /// consumed; a row that will not decode is error.BadPayload too — a
1280 /// snapshot is the resync, so a resync cannot fix one, and the pump ends
1281 /// the tile with the error. `.delta`: a bad row is `.resync`.
1282 pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied {
1283 switch (t) {
1284 .snapshot => {
1285 const prefix = try proto.readSnapshotPrefix(payload);
1286 const cur = try proto.readSnapshotCursor(payload);
1287 self.state_since_attach = true;
1288 self.session_epoch = prefix.epoch;
1289 self.last_seq = prefix.seq;
1290 self.history_rows = prefix.history_rows;
1291 if (prefix.cols != self.grid_size.cols or prefix.rows != self.grid_size.rows) {
1292 try self.grid.resize(prefix.cols, prefix.rows);
1293 self.grid_size = .{ .cols = prefix.cols, .rows = prefix.rows };
1294 }
1295 self.grid.clear();
1296 var rest = payload[proto.snapshot_prefix_len + proto.snapshot_cursor_len ..];
1297 var y: u16 = 0;
1298 while (y < prefix.rows) : (y += 1) {
1299 rest = try grid_mod.decodeRow(self.alloc, &self.grid.lines[y], rest, self.grid.cols);
1300 }
1301 self.grid.cursor = .{ .x = cur.x, .y = cur.y };
1302 return .painted;
1303 },
1304 .delta => {
1305 self.state_since_attach = true;
1306 const hdr = proto.readDeltaHeader(payload) catch return .resync;
1307 var it = proto.deltaRowIterator(payload);
1308 var seen: usize = 0;
1309 while (it.next() catch return .resync) |row| {
1310 self.grid.applyRow(row.row, row.bytes) catch return .resync;
1311 seen += 1;
1312 }
1313 if (seen != hdr.row_count) return .resync;
1314 self.history_rows = hdr.history_rows;
1315 self.last_seq = hdr.seq;
1316 self.grid.cursor = .{ .x = hdr.cursor_x, .y = hdr.cursor_y };
1317 return .painted;
1318 },
1319 else => unreachable,
1320 }
1321 }
1322 // attachArgs and scrollStart unchanged.
1323 };
1324 ```
1325
1326 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"`.
1327
1328 - [ ] **Step 5.5: The server harness and tests**
1329
1330 `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`.
1331
1332 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`).
1333
1334 - [ ] **Step 5.6: The serializer and the painters (`paint.zig`)**
1335
1336 Replace `dumpRow` and the three renders' engine parameters. The serializer:
1337
1338 ```zig
1339 /// One grid row as VT for a terminal: SGR on every style change, a wide
1340 /// glyph once with its spacer skipped, a space for an empty cell, and a
1341 /// stop at the last cell that is not a default blank (the caller's ECH has
1342 /// cleared the rest). `span` (grid columns, inclusive) is painted inverted
1343 /// and PLAIN, snapped outward to whole glyphs, closed by a full reset —
1344 /// the three-piece shape Engine.dumpVtRowSpan had, on cells.
1345 pub fn rowToVtFrom(alloc: std.mem.Allocator, r: *const grid.Row, cols: u16, view: grid.RowView, span: ?Span) ![]u8 {
1346 var out: std.ArrayList(u8) = .empty;
1347 errdefer out.deinit(alloc);
1348 try out.appendSlice(alloc, "\x1b[0m");
1349 const last = grid.clipColOf(r, cols, view) orelse return out.toOwnedSlice(alloc);
1350 const snapped: ?grid.ColSpan = if (span) |s| blk: {
1351 if (s.from > last) break :blk null;
1352 break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last));
1353 } else null;
1354 var end: u16 = last;
1355 // Trailing default blanks are the caller's clear, not ours.
1356 while (end > 0 and r.cells[end].isBlank() and (snapped == null or end > snapped.?.to)) end -= 1;
1357 var cur: proto.CellStyle = .{};
1358 var x: u16 = 0;
1359 var inverted = false;
1360 while (x <= end) : (x += 1) {
1361 const c = r.cells[x];
1362 if (c.wide == .spacer_tail or c.wide == .spacer_head) continue;
1363 const in_span = if (snapped) |s| x >= s.from and x <= s.to else false;
1364 if (in_span and !inverted) {
1365 try out.writer(alloc).print("\x1b[{d}G\x1b[0m\x1b[7m", .{x + 1 + view.col_off});
1366 inverted = true;
1367 cur = .{};
1368 } else if (!in_span and inverted) {
1369 try out.writer(alloc).print("\x1b[0m\x1b[{d}G", .{x + 1 + view.col_off});
1370 inverted = false;
1371 cur = .{};
1372 }
1373 if (!in_span and !c.style.eql(cur)) {
1374 try appendSgr(&out, alloc, c.style);
1375 cur = c.style;
1376 }
1377 if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
1378 }
1379 try out.appendSlice(alloc, "\x1b[0m");
1380 return out.toOwnedSlice(alloc);
1381 }
1382
1383 pub fn rowToVt(alloc: std.mem.Allocator, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8 {
1384 return rowToVtFrom(alloc, g.row(y), g.cols, view, span);
1385 }
1386
1387 fn appendColor(out: *std.ArrayList(u8), alloc: std.mem.Allocator, base: u8, packed_col: u32) !void {
1388 const w = out.writer(alloc);
1389 switch (packed_col >> 24) {
1390 0 => try w.print(";{d}", .{base + 9}), // default: 39 / 49 / 59
1391 1 => {
1392 const idx: u8 = @truncate(packed_col);
1393 if (base != 58 and idx < 8) {
1394 try w.print(";{d}", .{base + idx});
1395 } else if (base != 58 and idx < 16) {
1396 try w.print(";{d}", .{base + 60 + (idx - 8)});
1397 } else try w.print(";{d};5;{d}", .{ base + 8, idx });
1398 },
1399 else => try w.print(";{d};2;{d};{d};{d}", .{ base + 8, (packed_col >> 16) & 0xff, (packed_col >> 8) & 0xff, packed_col & 0xff }),
1400 }
1401 }
1402
1403 /// A full SGR for `s` from a reset: every attribute the wire carries, so
1404 /// a host terminal never inherits a neighbour cell's style.
1405 fn appendSgr(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: proto.CellStyle) !void {
1406 const w = out.writer(alloc);
1407 try w.writeAll("\x1b[0");
1408 if (s.flags & (1 << 0) != 0) try w.writeAll(";1");
1409 if (s.flags & (1 << 2) != 0) try w.writeAll(";2");
1410 if (s.flags & (1 << 1) != 0) try w.writeAll(";3");
1411 switch ((s.flags >> 8) & 0x7) {
1412 0 => {},
1413 1 => try w.writeAll(";4"),
1414 2 => try w.writeAll(";4:2"),
1415 3 => try w.writeAll(";4:3"),
1416 4 => try w.writeAll(";4:4"),
1417 5 => try w.writeAll(";4:5"),
1418 else => try w.writeAll(";4"),
1419 }
1420 if (s.flags & (1 << 3) != 0) try w.writeAll(";5");
1421 if (s.flags & (1 << 4) != 0) try w.writeAll(";7");
1422 if (s.flags & (1 << 5) != 0) try w.writeAll(";8");
1423 if (s.flags & (1 << 6) != 0) try w.writeAll(";9");
1424 if (s.flags & (1 << 7) != 0) try w.writeAll(";53");
1425 if (s.fg != 0) try appendColor(out, alloc, 30, s.fg);
1426 if (s.bg != 0) try appendColor(out, alloc, 40, s.bg);
1427 if (s.ul != 0) try appendColor(out, alloc, 58, s.ul);
1428 try w.writeAll("m");
1429 }
1430 ```
1431
1432 `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`.
1433
1434 `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.
1435
1436 `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.
1437
1438 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.
1439
1440 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.
1441
1442 - [ ] **Step 5.7: The interaction loop (`interact.zig`)**
1443
1444 - `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`.
1445 - 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`.
1446 - `replicaCellChar` and the overlay judge take `*const Grid` and call `dumpPlain` on it.
1447 - `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, ...)`.
1448 - `paintDeltaClipped`/`renderClipped` call sites pass `self.rep.grid`.
1449 - 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:
1450
1451 ```zig
1452 fn authorScreen(alloc: std.mem.Allocator, g: *Grid, bytes: []const u8) !void {
1453 var e = try Engine.init(alloc, .{ .cols = g.cols, .rows = g.rows });
1454 defer e.deinit();
1455 e.feed(bytes);
1456 try e.mirrorInto(g);
1457 }
1458 ```
1459
1460 `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`.
1461
1462 - [ ] **Step 5.8: The wasm core (`wasm_core.zig`)**
1463
1464 - `c.eng: *Engine` → `c.grid: *Grid` (`Grid.init(alloc, cols, rows)`); `c.rep = Replica.init(alloc, c.grid)`.
1465 - `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`.
1466 - `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.
1467 - `mux_dump_plain` → `c.grid.dumpPlain`.
1468 - Remove the `Engine` import and `.max_scrollback`.
1469
1470 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`).
1471
1472 - [ ] **Step 5.9: The browser stand-in (`wsclient.zig`)**
1473
1474 `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`.
1475
1476 - [ ] **Step 5.10: Green**
1477
1478 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).
1479
1480 - [ ] **Step 5.11: Commit (autosquash the fixups)**
1481
1482 ```bash
1483 git add -A src test web
1484 git commit -m "feat: the wire carries cells; the replica is a grid and every painter reads it"
1485 # then fold the --fixup commits made along the way:
1486 GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $(git merge-base HEAD main)
1487 ```
1488
1489 ---
1490
1491 ### Task 6: The module split — the client links no emulator
1492
1493 **Files:**
1494 - 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)
1495
1496 **Interfaces:**
1497 - 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`.
1498
1499 - [ ] **Step 1: Write the failing pins**
1500
1501 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):
1502
1503 ```sh
1504 # The term module is the wasm root and the client's whole view of the wire:
1505 # it spells no emulator. engine.zig is the one file allowed the dependency.
1506 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
1507 fail "a term child imports ghostty-vt"
1508 fi
1509 if grep -q 'engine.zig' src/engine/term.zig; then
1510 fail "term.zig re-exports the engine"
1511 fi
1512 # No client row links the engine outside its tests: the production client
1513 # parses no VT. The table lines are one per row, so a row's .imports is on
1514 # the line that names it.
1515 for row in client wall webhub term; do
1516 if grep -E "\.name = \"$row\"" build.zig | grep -q '\.imports = &\.{[^}]*"engine"'; then
1517 fail "module row $row imports engine"
1518 fi
1519 done
1520 echo "bans ok: the engine is the daemon's alone"
1521 ```
1522
1523 - [ ] **Step 2: Run to see them fail**
1524
1525 Run: `make check 2>&1 | tail -5` — `bans.sh` fails with `term.zig re-exports the engine`.
1526
1527 - [ ] **Step 3: Split**
1528
1529 `term.zig`: remove `pub const engine` and `pub const delta` (and their `_ =` lines); keep `protocol`, `replica`, `grid`.
1530
1531 `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).
1532
1533 `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`.
1534
1535 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`.
1536
1537 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).
1538
1539 `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``.
1540
1541 - [ ] **Step 4: Run to see everything pass**
1542
1543 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.
1544
1545 - [ ] **Step 5: Commit**
1546
1547 ```bash
1548 git add build.zig src test CLAUDE.md
1549 git commit -m "build: the engine is its own module, and no client row links ghostty-vt"
1550 ```
1551
1552 ---
1553
1554 ### Task 7: The gates and the record
1555
1556 **Files:**
1557 - 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
1558
1559 - [ ] **Step 1: The delivery gate**
1560
1561 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).
1562
1563 - [ ] **Step 2: The real bytes**
1564
1565 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.
1566
1567 - [ ] **Step 3: The record**
1568
1569 `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).
1570
1571 `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.
1572
1573 `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.`
1574
1575 - [ ] **Step 4: Commit**
1576
1577 ```bash
1578 make check; echo rc=$?
1579 git add docs/decisions.md CLAUDE.md README.md test/xversion.sh
1580 git commit -m "docs: record cells on the wire, its measurements and the clean break"
1581 ```
1582
1583 - [ ] **Step 5: Autosquash and hand over**
1584
1585 `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.
docs/superpowers/specs/2026-09-04-cells-on-the-wire-design.md
Old New
@@ -0,0 +1,198 @@
1 # Cells on the wire — design
2
3 2026-09-04. Clean break: the replay frames change shape and number, and
4 no daemon or client of an earlier version pairs with one of this. The
5 migration is `mux d upgrade` on every box, which the release note says.
6
7 ## Goal
8
9 The client stops parsing VT. Today a byte of session output is parsed
10 three times before a person sees it: the daemon's ghostty-vt (the
11 authoritative grid), the client's ghostty-vt (the replica, fed the delta's
12 bytes), and the host terminal (fed the client's paint). After this change
13 the daemon's parse is the only one in the system: the wire carries the
14 CELLS the daemon's grid holds, the client copies them into a grid of its
15 own, and paints from that grid — to a terminal today, to a renderer when a
16 native or browser client wants one. The client no longer links ghostty-vt
17 at all; `mux_core.wasm` shrinks by the whole emulator.
18
19 ## Findings the design rests on
20
21 - **The delta row is painted bytes by construction.** `protocol.composeDelta`
22 stamps `CUP` + `EL2` around a row `Engine.dumpVtRow` rendered as SGR, and
23 `replica.apply` FEEDS that to a second engine (`replica.zig:82-87`). The
24 rule-4 exemption on `protocol.zig` says so in its own words. Nothing else
25 makes the client an emulator.
26 - **The terminal client already passes rows through verbatim.** On the common
27 path `paint.paintDeltaClipped` writes the daemon's row bytes to the tty
28 unchanged (`paint.zig:194`) and re-dumps from the replica only for a row
29 under the selection or an over-wide pane. So for the TERMINAL client this
30 is a trade — a stateless serialize replaces a VT state machine — and the
31 over-wide special case (a daemon row is grid-wide; a narrower pane's
32 surplus lands on the neighbour) disappears, because every row now paints
33 from the grid clipped to the pane.
34 - **The browser painter already consumes cells.** `wasm_core.paintRow` reads
35 ghostty's page cells into a 4×u32 record (codepoint, fg, bg, flags|wide)
36 that `web/mux.js` draws on a canvas. The cell the wire carries is that
37 record plus underline colour and the grapheme cluster; the JS side does not
38 change.
39 - **Modes and title are already a side channel.** `term_modes` (a u32 bitset:
40 bracketed paste, the mouse modes) and `term_title` frames carry the state
41 the client acts on; the replica engine is consulted for exactly two mode
42 bits — `onAltScreen` and `cursorKeys` for the wheel-to-arrows rule
43 (`interact.zig:1747`). Those two become bits of `term_modes`.
44 - **The wire size is the open question.** A run-length styled row is roughly
45 SGR-shaped, but dense plain text costs a length byte per cell that SGR does
46 not pay. An `ascii` run form (one byte per cell, no header) closes most of
47 that. The number is measured in Task 2 before the flip is built, and the
48 stats counters (`snapshot_bytes`, `delta_bytes`, `make bench`) grade the
49 real workload after.
50
51 ## Design
52
53 ### The wire
54
55 Frame kinds keep their names and take NEW numbers, so a binary of either
56 side that meets the other shows a blank tile and never garbage: an unknown
57 type is dropped by both readers, while a VT reader fed cell bytes, or a cell
58 reader fed VT, would paint noise.
59
60 | kind | number | payload |
61 |---|---|---|
62 | `snapshot` | `0x95` | `SnapshotPrefix` (24 B, unchanged) ++ `u16 LE cursor_x` ++ `u16 LE cursor_y` ++ `rows` × `CellRow`, row 0 first |
63 | `delta` | `0x96` | `DeltaHeader` (18 B, unchanged) ++ `row_count` × (`u16 LE row` ++ `u32 LE len` ++ `CellRow`) — the delta row header is unchanged |
64 | `scrollback_chunk` | `0x97` | `u32 LE start` ++ `u16 LE count` ++ `count` × `CellRow` |
65
66 `0x81`, `0x85`, `0x87` are retired with a comment and never reused.
67
68 **`CellRow`** — `u16 LE ncells` then runs until `ncells` cells are read.
69 Cells past `ncells` up to the grid width are default-style blanks, so the
70 encoder stops at the last cell that is not one (a cell whose content is a
71 bare background colour is NOT blank).
72
73 A **run** is `RunHeader` (16 B: `u16 LE count`, `u16 LE flags`, `u32 LE fg`,
74 `u32 LE bg`, `u32 LE ul`) followed by `count` cells:
75
76 - `flags` bits 0–10 are ghostty's `Style.Flags` verbatim — bold 0, italic 1,
77 faint 2, blink 3, inverse 4, invisible 5, strikethrough 6, overline 7,
78 underline style 8–10 (none 0, single 1, double 2, curly 3, dotted 4,
79 dashed 5). Bit 15 is `ascii`: every cell of the run is ONE byte in
80 `0x20..0x7E`, narrow, with no per-cell header.
81 - Colours are packed as `wasm_core.packColor` does today: `0` none,
82 `1<<24 | index` palette, `2<<24 | r<<16 | g<<8 | b` RGB.
83 - A non-`ascii` cell is `u8 head` = `wide << 6 | text_len` then `text_len`
84 bytes of UTF-8 — the whole grapheme cluster, capped at 63 bytes (the
85 encoder keeps the leading codepoints that fit). `wide` is 0 narrow, 1
86 wide, 2 spacer_tail, 3 spacer_head; a spacer has `text_len` 0. An empty
87 text is a blank glyph in that style.
88
89 The delta tracker hashes the encoded `CellRow`, so "this row changed" on
90 the daemon means "these bytes differ" on the wire.
91
92 ### `term_modes` gains two bits
93
94 `alt_screen` (the alternate screen is active) and `cursor_keys` (DECCKM).
95 The daemon samples them where it samples bracketed paste
96 (`Server.sampleTermModes`); the client's wheel rule reads them from
97 `client_core`'s modes instead of from an engine.
98
99 ### The client grid — `src/engine/grid.zig`, a child of `term`
100
101 Platform-free like `replica.zig`: no posix, no clocks, compiles for
102 wasm32-freestanding. `Grid` is `cols × rows` of `Cell` (`style`, `wide`,
103 and a slice into the row's own text buffer), a cursor, and:
104
105 - `applyRow(y, bytes)` — decode one `CellRow` into row `y`;
106 - `decodeRows(alloc, bytes, count, cols)` — a dense run of rows (snapshot
107 body, scrollback chunk) as `[]Row`, for the caller to own;
108 - `dumpPlain(alloc)` — the text `Engine.dumpPlain` produces for the same
109 screen, byte for byte, which is what every harness compares;
110 - `clipCol` and `snapWide` — the two wide-glyph rules `engine.zig` holds
111 today, on the `wide` flag instead of a page pin.
112
113 `Replica` holds a `*Grid` instead of an `*Engine`. A snapshot resizes,
114 clears, sets the cursor and applies `rows` dense rows; a delta applies each
115 listed row and sets the cursor. A snapshot whose body will not decode is
116 `error.BadPayload` out of `apply` — the pump ends the tile with it — never a
117 `.resync`, because a resync re-attaches and reads the same bytes again.
118
119 ### Painting from the grid — `src/tui/paint.zig`
120
121 `rowToVt(alloc, row, view, span)` is the one serializer: it walks the
122 row's cells to `clipCol`, emits an SGR when the style changes, writes a
123 wide glyph once and skips its spacer, writes a space for an empty text, and
124 stops at the last non-default cell (the caller's ECH has cleared the rest).
125 A `span` is painted inverted and plain, snapped outward to whole glyphs,
126 exactly as `Engine.dumpVtRowSpan` does today. `renderClipped`,
127 `paintDeltaClipped` and `renderScrollback` call it; the verbatim branch and
128 the over-wide branch of `paintDeltaClipped` go, because there is no
129 daemon-rendered row to pass through any more.
130
131 ### The daemon — `src/engine/engine.zig`, `delta.zig`, `server.zig`
132
133 `Engine.encodeViewportRow(alloc, y)` and `Engine.encodeScrollback(alloc,
134 start, count)` read ghostty's page cells (content, `page.styles`,
135 `lookupGrapheme`, `wide`) into `CellRow` bytes. `delta.buildSnapshot`
136 composes prefix + cursor + rows; `DeltaTracker` hashes encoded rows;
137 `Server.buildSnapshotPayload`, `accrueSnapshotEquiv` and
138 `onFetchScrollback` call them. `dumpState` stays — it is how a daemon
139 carries its own state across `mux d upgrade`'s exec, and what `debug_dump`
140 prints — and so do `dumpVt`/`dumpPlain` (the e2e render oracle, `mux d
141 dump`). `dumpVtRow`, `dumpVtRowClipped`, `dumpVtRowSpan`, `dumpScrollback`
142 and `protocol.composeDelta` are deleted with the exemption line that
143 explained them.
144
145 ### The module table
146
147 `term` (`term.zig` → `protocol`, `replica`, `grid`) no longer imports
148 ghostty-vt and stays the wasm root. A new row `engine` (`engine.zig` →
149 `delta`) imports `term` and links ghostty-vt. `daemon` and the e2e `render`
150 fixture import `engine`; `client`, `wall`, `webhub`, `wsclient` and the wasm
151 core import only `term`. Tests that author a screen still do it through an
152 `Engine` — `Engine.mirrorInto(grid)` encodes every row into a `Grid` — so
153 `wall`, `client` and `wsclient` carry `engine` as a TEST import and the
154 production client links no emulator. The ghostty wasm dependency is removed
155 from `build.zig`.
156
157 ## What this removes
158
159 The client-side VT parse; the `protocol.zig` rule-4 exemption; the
160 verbatim-versus-redump branch in `paintDeltaClipped` and its "only safe
161 while the pane is as wide as the grid" rule; the scratch `Engine` the wasm
162 core keeps for scrollback; ghostty-vt from every client binary and from the
163 wasm build.
164
165 ## The measurement gate
166
167 Task 2 encodes four synthetic screens both ways (dense prose, a vim-like
168 screen, an htop-like screen, a mostly-blank shell) and prints bytes and
169 ratio. The flip (Task 5) is built only if dense prose is ≤ 1.5× the VT rows
170 and the styled screens ≤ 1.2×; otherwise the branch stops there and reports,
171 because the alternative plan — VT frames for terminal clients, cells only
172 for native and browser ones — is a different and smaller project. After the
173 flip, `make bench` on `main` and on this branch records the real numbers in
174 `docs/decisions.md`.
175
176 ## Testing
177
178 - `protocol.zig`: golden bytes for every cell form and every malformed
179 shape.
180 - `engine.zig`: the encoder round-trips through the decoder for ASCII, wide,
181 grapheme, bg-only, styled and trailing-blank rows; the ORACLE test feeds
182 VT into an `Engine`, mirrors it into a `Grid`, and compares `dumpPlain` and
183 the cursor — the engine is the oracle, the grid must agree.
184 - `server_test_*`: unchanged in intent; the harness's replica is a `Grid`.
185 - `paint.zig`, `interact.zig`, `wall_test_*`: the pins that exist, with
186 screens authored through `mirrorInto`. The span-bounded clear and `col_off`
187 pins must each fire once against the new serializer.
188 - `make ci` is the delivery gate. `make xversion` has no old side to grade
189 until the next release and is out of this branch's gate, recorded in
190 `docs/decisions.md`.
191
192 ## Deferred
193
194 - OSC 8 hyperlinks: the wire has no field; today's formatter emits none on
195 the delta path either, so parity holds.
196 - The JS painter draws a cell's first codepoint; the wire now carries the
197 whole cluster, so the canvas can draw it when someone wants to.
198 - A native client. This change is what makes one cheap to write.