941927df
docs: cells on the wire — the design and the plan
a73x 2026-09-04 16:59
Commit message
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. | ||