a73x

12cb9419

build: the engine is its own module, and no client row links ghostty-vt

a73x   2026-09-04 18:04

Commit message
build: the engine is its own module, and no client row links ghostty-vt

`term` was one row over four files, and one of them imported ghostty-vt.
Every module that wanted the wire contract or the replay core therefore
linked a terminal emulator, the browser core and the CLI wall included. The
client parses no VT since the cell wire landed, so the dependency was paid
for nothing.

`term` is now the wire contract, the client-side grid and the one replay
core, and it imports nothing. A new row `engine` holds engine.zig with
delta.zig as its child; it imports `term` and is the only row that links
ghostty-vt. `daemon` and the `render` fixture import it in production;
`wall` and `wsclient` carry it as a TEST import, for the tests that author a
screen by feeding VT to an engine and mirroring it into a grid. The wasm
build drops the ghostty dependency outright.

Two pins in test/bans.sh, because no folder rule can express a module
boundary: term.zig must not re-export the engine or import ghostty-vt in
any of its children, and no client row may name `engine` in its production
`.imports`. Both were watched to fire before the split.

The VT row dumps the old wire was built from go with it: dumpVtRow,
dumpVtRowClipped, dumpVtRowSpan, dumpScrollback, RowView, clipCol, snapWide
and viewportSpan had no callers outside engine.zig's own tests. clipCol and
snapWide live on in grid.zig with their own unit test; the oracle test that
compared the two pairs goes with the engine's copies. The Task 2
measurement test keeps its cell side only — the VT figures it was decided
against are recorded in docs/decisions.md, 2026-09-04.

`term` gains `link_libc` for its tests, not its code: two of protocol.zig's
tests call std.c's socketpair, which std.posix lacks on the pinned 0.15.2.
The row used to get libc for free through ghostty-vt, so the dependency is
newly visible rather than new.

mux_core.wasm: 19080 bytes before, 19080 bytes after. Unchanged on purpose
— Task 5 had already removed the browser core's last reference to Engine,
so the emulator was dead-code-eliminated out of the artifact already. What
this removes is the dependency itself: the wasm build no longer fetches or
compiles ghostty-vt at all.

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

CLAUDE.md
Old New
@@ -73,7 +73,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
73 73
74 | Folder | Row — its child files | 74 | Folder | Row — its child files |
75 |---|---| 75 |---|---|
76 | `src/engine/` | `term`(`term.zig`) — `protocol` `engine` `delta` `replica` | 76 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
77 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | 77 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
78 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 78 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
79 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 79 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
build.zig
Old New
@@ -140,17 +140,28 @@ const ModSpec = struct {
140 140
141 const mod_table = [_]ModSpec{ 141 const mod_table = [_]ModSpec{
142 // ---- the leaves: they import nothing internal in production ---- 142 // ---- the leaves: they import nothing internal in production ----
143 // The terminal component, one row over four files: the wire contract, 143 // The wire component, one row over three files: the wire contract, the
144 // the authoritative engine, the daemon-side delta minting and the one 144 // client-side grid a payload decodes into and the one replay core,
145 // replay core, reached as `term.protocol`, `.engine`, `.delta`, 145 // reached as `term.protocol`, `.grid`, `.replica`. They are one owner —
146 // `.replica`. They are one owner — protocol.zig owns the bytes a delta 146 // protocol.zig owns the bytes a row is made of and grid.zig owns the
147 // payload is made of and delta.zig owns which rows go into one — and 147 // rows — and only this root is a seam, so a second module claiming any
148 // only this root is a seam, so a second module claiming any of the four 148 // of the three is a file-in-multiple-modules compile error. It imports
149 // is a file-in-multiple-modules compile error. It imports nothing 149 // nothing internal and links no terminal emulator, which is what lets
150 // internal, which is what lets the tracker and the replay core be 150 // every client hold a replica without ghostty-vt in the binary, and what
151 // driven by a test holding an engine and no socket; and the whole 151 // lets `mux_core.wasm` compile the whole component.
152 // component is platform-free, so `mux_core.wasm` compiles it. 152 // `link_libc` for its TESTS, not its code: two of protocol.zig's tests
153 .{ .name = "term", .path = "src/engine/term.zig", .wasm = true }, 153 // need a socket whose peer refuses to read, and std.posix has no
154 // socketpair on the pinned 0.15.2, so they call std.c's. The row used to
155 // get libc for free through ghostty-vt; dropping the emulator made the
156 // dependency visible rather than new.
157 .{ .name = "term", .path = "src/engine/term.zig", .link_libc = true, .wasm = true },
158 // The authoritative emulator and the daemon-side delta minting, reached
159 // as `Engine` and `engine.delta`. The ONE row that links ghostty-vt: a
160 // client parses no VT, so this sits above `term` rather than inside it,
161 // and the client rows below carry it as a TEST import only — for the
162 // tests that author a screen by feeding VT to an engine and mirroring it
163 // into a grid.
164 .{ .name = "engine", .path = "src/engine/engine.zig", .imports = &.{"term"} },
154 // The platform layer, one row per side (docs/superpowers/specs/ 165 // The platform layer, one row per side (docs/superpowers/specs/
155 // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours, 166 // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours,
156 // and the raw OS spellings are meant to end up here rather than in the 167 // and the raw OS spellings are meant to end up here rather than in the
@@ -217,9 +228,10 @@ const mod_table = [_]ModSpec{
217 .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true }, 228 .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true },
218 // Replays a captured client stdout stream and prints the final grid in 229 // Replays a captured client stdout stream and prints the final grid in
219 // `mux d dump`'s formats — the client half of the M11 render-vs-dump 230 // `mux d dump`'s formats — the client half of the M11 render-vs-dump
220 // convergence check. Imports term so both sides of the diff go through 231 // convergence check. It plays the TERMINAL the client painted onto
221 // the same ghostty-vt and the same formatter. 232 // rather than a client, so it imports the engine, and both sides of the
222 .{ .name = "render", .path = "test/render.zig", .imports = &.{"term"} }, 233 // diff go through the same ghostty-vt and the same formatter.
234 .{ .name = "render", .path = "test/render.zig", .imports = &.{"engine"} },
223 // The pty-driving e2e fixture: real client on a pty slave, scripted 235 // The pty-driving e2e fixture: real client on a pty slave, scripted
224 // from stdin (M12). Imports pty so the product's own module is the one 236 // from stdin (M12). Imports pty so the product's own module is the one
225 // under it. 237 // under it.
@@ -235,7 +247,7 @@ const mod_table = [_]ModSpec{
235 // daemon itself when nobody handed it a --key — and for the shim 247 // daemon itself when nobody handed it a --key — and for the shim
236 // directory shell integration writes under the same 0700 policy. 248 // directory shell integration writes under the same 0700 policy.
237 // `pty` stays a row of its own: the ptyclient fixture consumes it. 249 // `pty` stays a row of its own: the ptyclient fixture consumes it.
238 .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "serve", "quic", "xdg", "proxy", "server_os" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true }, 250 .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "engine", "pty", "sockpath", "serve", "quic", "xdg", "proxy", "server_os" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true },
239 // The agent-facing client. It speaks frames and owns no terminal, which 251 // The agent-facing client. It speaks frames and owns no terminal, which
240 // is the whole point — it attaches at 0x0 and never claims the grid. 252 // is the whole point — it attaches at 0x0 and never claims the grid.
241 // The transport modules are the CLI client's, minus everything that 253 // The transport modules are the CLI client's, minus everything that
@@ -245,7 +257,7 @@ const mod_table = [_]ModSpec{
245 // nothing to draw: a fact of muxa.zig itself, which the one-row component 257 // nothing to draw: a fact of muxa.zig itself, which the one-row component
246 // no longer refuses on its behalf. 258 // no longer refuses on its behalf.
247 .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial", "link" }, .quic_tests = true }, 259 .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial", "link" }, .quic_tests = true },
248 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } }, 260 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" }, .test_imports = &.{"engine"} },
249 // Dialling, and what a chord means. The client is the only thing that 261 // Dialling, and what a chord means. The client is the only thing that
250 // predicts — the overlay is a local display decision and never becomes 262 // predicts — the overlay is a local display decision and never becomes
251 // state anybody else can see — but the predicting itself is interact's 263 // state anybody else can see — but the predicting itself is interact's
@@ -280,9 +292,12 @@ const mod_table = [_]ModSpec{
280 // of those files is a file-in-multiple-modules compile error. `term` is 292 // of those files is a file-in-multiple-modules compile error. `term` is
281 // the children's as much as the root's: `term.replica` is the keyboard 293 // the children's as much as the root's: `term.replica` is the keyboard
282 // loop's alone, which the root never spells, and the painter takes 294 // loop's alone, which the root never spells, and the painter takes
283 // `term.engine` and `term.protocol`; the decoder and the key table they 295 // `term.grid` and `term.protocol`; the decoder and the key table they
284 // also want reach them through `client`'s seams. 296 // also want reach them through `client`'s seams. `engine` is a TEST
285 .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 297 // grant only — the painter's and the loop's tests author a screen by
298 // feeding VT to an engine and mirroring it into a grid, and no
299 // production line here parses VT at all.
300 .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{ "testtmp", "engine" }, .quic_tests = true },
286 // ---- the one binary ---- 301 // ---- the one binary ----
287 // Four words, one image — and one row: the daemon's entrypoint, the 302 // Four words, one image — and one row: the daemon's entrypoint, the
288 // client's and the hub's are CHILD FILES of the dispatcher, so a second 303 // client's and the hub's are CHILD FILES of the dispatcher, so a second
@@ -797,11 +812,11 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
797 /// of all: it carries every argument parser but muxa's, its mains being 812 /// of all: it carries every argument parser but muxa's, its mains being
798 /// child files — a test that is never built is not a test (decisions.md). 813 /// child files — a test that is never built is not a test (decisions.md).
799 const test_order = [_][]const u8{ 814 const test_order = [_][]const u8{
800 "script", "cliflags", "testtmp", "server_os", "client_os", "spawn", 815 "script", "cliflags", "testtmp", "server_os", "client_os", "spawn",
801 "dial", "link", "quic", "webhub", "agent", "term", 816 "dial", "link", "quic", "webhub", "agent", "term",
802 "rawmode", "delaypipe", "render", "wsclient", "ptyclient", "pty", 817 "engine", "rawmode", "delaypipe", "render", "wsclient", "ptyclient",
803 "sockpath", "serve", "xdg", "proxy", "wall", "client", 818 "pty", "sockpath", "serve", "xdg", "proxy", "wall",
804 "daemon", "mux", 819 "client", "daemon", "mux",
805 }; 820 };
806 821
807 comptime { 822 comptime {
@@ -897,7 +912,6 @@ pub fn build(b: *std.Build) void {
897 // that wiring actually uses (an unused local is a compile error). 912 // that wiring actually uses (an unused local is a compile error).
898 // Dep edges (ghostty) and build_options stay outside the table's 913 // Dep edges (ghostty) and build_options stay outside the table's
899 // jurisdiction, explicit. 914 // jurisdiction, explicit.
900 const term_mod = mods[comptime idxOf("term")];
901 const mux_mod = mods[comptime idxOf("mux")]; 915 const mux_mod = mods[comptime idxOf("mux")];
902 const agent_mod = mods[comptime idxOf("agent")]; 916 const agent_mod = mods[comptime idxOf("agent")];
903 const rawmode_mod = mods[comptime idxOf("rawmode")]; 917 const rawmode_mod = mods[comptime idxOf("rawmode")];
@@ -906,11 +920,12 @@ pub fn build(b: *std.Build) void {
906 const ptyclient_mod = mods[comptime idxOf("ptyclient")]; 920 const ptyclient_mod = mods[comptime idxOf("ptyclient")];
907 const wsclient_mod = mods[comptime idxOf("wsclient")]; 921 const wsclient_mod = mods[comptime idxOf("wsclient")];
908 922
909 // The dep is named by engine.zig, a file of the term module: an import 923 // The dep is named by engine.zig, the engine module's root: an import
910 // name is resolved in the module that owns the spelling file, so the 924 // name is resolved in the module that owns the spelling file, so the
911 // handle belongs on the component's root and nowhere else. 925 // handle belongs on that row and nowhere else. `term` used to carry it,
926 // which put a terminal emulator in every binary that held a replica.
912 if (ghostty_dep) |dep| { 927 if (ghostty_dep) |dep| {
913 term_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); 928 mods[comptime idxOf("engine")].addImport("ghostty-vt", dep.module("ghostty-vt"));
914 } 929 }
915 // ONE module object, shared: the two rows link into one binary, and a 930 // ONE module object, shared: the two rows link into one binary, and a
916 // second instance of the same root file in one compilation is "file 931 // second instance of the same root file in one compilation is "file
@@ -953,8 +968,8 @@ pub fn build(b: *std.Build) void {
953 968
954 // ---- The wasm core (M-web Task 4) ---- 969 // ---- The wasm core (M-web Task 4) ----
955 // A SECOND resolved target: modules are target-bound, so the wasm-clean 970 // A SECOND resolved target: modules are target-bound, so the wasm-clean
956 // row — the term component — and the ghostty dependency are 971 // row — the term component — is instantiated again against wasm32. It
957 // instantiated again against wasm32. Always ReleaseSmall — the artifact 972 // is the only dependency the core has. Always ReleaseSmall — the artifact
958 // is @embedFile'd into the one binary, and its Debug build is 3.7MB 973 // is @embedFile'd into the one binary, and its Debug build is 3.7MB
959 // against ReleaseSmall's 345KB (spike-measured). 974 // against ReleaseSmall's 345KB (spike-measured).
960 // Safety checks are the price; the native test suite runs the same 975 // Safety checks are the price; the native test suite runs the same
@@ -963,10 +978,6 @@ pub fn build(b: *std.Build) void {
963 .cpu_arch = .wasm32, 978 .cpu_arch = .wasm32,
964 .os_tag = .freestanding, 979 .os_tag = .freestanding,
965 }); 980 });
966 const ghostty_wasm_dep = b.lazyDependency("ghostty", .{
967 .target = wasm_target,
968 .optimize = .ReleaseSmall,
969 });
970 // Rows with .wasm get wasm32 twins, imports rewired from the SAME table 981 // Rows with .wasm get wasm32 twins, imports rewired from the SAME table
971 // rows — one source of truth for both instantiations. wasm_core itself 982 // rows — one source of truth for both instantiations. wasm_core itself
972 // stays explicit: it is wasm-only, never in the native test loop. 983 // stays explicit: it is wasm-only, never in the native test loop.
@@ -982,10 +993,10 @@ pub fn build(b: *std.Build) void {
982 } 993 }
983 } 994 }
984 } 995 }
996 // No ghostty-vt twin at all: `term` links no emulator, so the browser
997 // core decodes cells and the wasm build has one fewer dependency to
998 // fetch and compile.
985 const term_wasm_mod = wasm_mods[comptime idxOf("term")].?; 999 const term_wasm_mod = wasm_mods[comptime idxOf("term")].?;
986 if (ghostty_wasm_dep) |dep| {
987 term_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
988 }
989 const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig"); 1000 const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig");
990 wasm_core_mod.addImport("term", term_wasm_mod); 1001 wasm_core_mod.addImport("term", term_wasm_mod);
991 // Compile a tiny, never-embedded canary that calls the semantic decoder 1002 // Compile a tiny, never-embedded canary that calls the semantic decoder
src/engine/delta.zig
Old New
@@ -5,7 +5,7 @@
5 //! nothing but an engine. 5 //! nothing but an engine.
6 const std = @import("std"); 6 const std = @import("std");
7 const Engine = @import("engine.zig").Engine; 7 const Engine = @import("engine.zig").Engine;
8 const proto = @import("protocol.zig"); 8 const proto = @import("term").protocol;
9 9
10 const Wyhash = std.hash.Wyhash; 10 const Wyhash = std.hash.Wyhash;
11 11
@@ -230,7 +230,7 @@ pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.Snaps
230 // Tests. A delta payload is rows of cells now, so the assertions decode it the 230 // Tests. A delta payload is rows of cells now, so the assertions decode it the
231 // way a client does — through grid.decodeRow — rather than searching bytes. 231 // way a client does — through grid.decodeRow — rather than searching bytes.
232 232
233 const grid = @import("grid.zig"); 233 const grid = @import("term").grid;
234 234
235 /// Apply every row of a delta payload into `g` and return the plain text, so 235 /// Apply every row of a delta payload into `g` and return the plain text, so
236 /// a test can say what the far side would be SHOWING after the frame. 236 /// a test can say what the far side would be SHOWING after the frame.
src/engine/engine.zig
Old New
@@ -1,13 +1,22 @@
1 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal 1 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal
2 //! and TerminalStream behind the small surface mux needs. 2 //! and TerminalStream behind the small surface mux needs.
3 //!
4 //! This is the root of the `engine` module and the ONE file under src/ that
5 //! imports ghostty-vt. It sits above `term` rather than inside it so that a
6 //! client — the CLI wall, the browser core, the fixtures — can hold a
7 //! replica of a session without linking a terminal emulator. `delta.zig` is
8 //! its child: deciding which rows a client is missing needs the engine, and
9 //! nothing else does.
3 // folder rule 4 exemption: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR. 10 // folder rule 4 exemption: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR.
4 const std = @import("std"); 11 const std = @import("std");
5 const vt = @import("ghostty-vt"); 12 const vt = @import("ghostty-vt");
6 const proto = @import("protocol.zig"); 13 const proto = @import("term").protocol;
7 /// The client-side grid this engine is the oracle for. A direct file import 14 /// The client-side grid this engine is the oracle for.
8 /// rather than the `term` root, so `grid.zig` never pulls ghostty into the 15 const Grid = @import("term").grid.Grid;
9 /// wasm build by importing back. 16
10 const Grid = @import("grid.zig").Grid; 17 /// The daemon's row-diffing, re-exported so `engine` is the whole of what a
18 /// daemon needs from this folder.
19 pub const delta = @import("delta.zig");
11 20
12 /// MuxHandler's own `vt` method shadows the `vt` import inside its body, 21 /// MuxHandler's own `vt` method shadows the `vt` import inside its body,
13 /// so the dep types it names are spelled through these aliases. 22 /// so the dep types it names are spelled through these aliases.
@@ -282,16 +291,6 @@ pub const Engine = struct {
282 return self.viewportRows(0, @intCast(self.term.rows - 1)); 291 return self.viewportRows(0, @intCast(self.term.rows - 1));
283 } 292 }
284 293
285 /// A selection spanning columns [x0, x1] of ONE viewport row. One row at a
286 /// time on purpose: a selection is a text RANGE, which is what a copy wants
287 /// and the opposite of what a per-row highlight does.
288 fn viewportSpan(self: *Engine, y: u16, x0: u16, x1: u16) ?vt.Selection {
289 const screen = self.term.screens.active;
290 const tl = screen.pages.pin(.{ .viewport = .{ .x = x0, .y = y } }) orelse return null;
291 const br = screen.pages.pin(.{ .viewport = .{ .x = x1, .y = y } }) orelse return null;
292 return vt.Selection.init(tl, br, false);
293 }
294
295 /// No palette/mode side effects, so a host terminal keeps its theme. 294 /// No palette/mode side effects, so a host terminal keeps its theme.
296 /// Null writes nothing. 295 /// Null writes nothing.
297 fn writeSelection( 296 fn writeSelection(
@@ -325,7 +324,7 @@ pub const Engine = struct {
325 } 324 }
326 325
327 /// Viewport only, SGR preserved, no palette/mode side effects. History 326 /// Viewport only, SGR preserved, no palette/mode side effects. History
328 /// stays daemon-side, fetched by `dumpScrollback`. 327 /// stays daemon-side, fetched by `encodeScrollback`.
329 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { 328 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
330 return self.formatSelection(alloc, "", self.viewportSelection()); 329 return self.formatSelection(alloc, "", self.viewportSelection());
331 } 330 }
@@ -339,107 +338,6 @@ pub const Engine = struct {
339 return self.formatSelection(alloc, "", self.viewportRows(y0, @intCast(self.term.rows - 1))); 338 return self.formatSelection(alloc, "", self.viewportRows(y0, @intCast(self.term.rows - 1)));
340 } 339 }
341 340
342 /// One viewport row (0-based), self-contained: leading SGR reset, no
343 /// trailing newline. Delta payloads are built from these.
344 pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
345 std.debug.assert(y < self.term.rows);
346 return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y));
347 }
348
349 /// Where a row is painted: `col_off` is the screen column its grid column
350 /// zero lands on, `cols` how many the pane shows. Neither is optional — the
351 /// dumps address columns with CHA, which is screen-absolute.
352 pub const RowView = struct { col_off: u16, cols: u16 };
353
354 /// The last grid column of row `y` that fits in `view`, or null when not one
355 /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and
356 /// half a wide glyph past it is a column stolen from the neighbour.
357 pub fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 {
358 if (view.cols == 0 or self.term.cols == 0) return null;
359 var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1)));
360 const screen = self.term.screens.active;
361 if (screen.pages.pin(.{ .viewport = .{ .x = hi, .y = y } })) |p| {
362 if (p.rowAndCell().cell.wide == .wide) {
363 if (hi == 0) return null;
364 hi -= 1;
365 }
366 }
367 return hi;
368 }
369
370 /// `dumpVtRow` bounded to the pane's own columns.
371 pub fn dumpVtRowClipped(self: *Engine, alloc: std.mem.Allocator, y: u16, view: RowView) ![]u8 {
372 std.debug.assert(y < self.term.rows);
373 const hi = self.clipCol(y, view) orelse return alloc.dupe(u8, "\x1b[0m");
374 return self.formatSelection(alloc, "\x1b[0m", self.viewportSpan(y, 0, hi));
375 }
376
377 /// Widen a column span to whole characters. A wide cell is two columns and a
378 /// drag stops where the hand stopped, so a span may cut one in half —
379 /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three
380 /// pieces emits a straddling one TWICE. Outwards: half a character under the
381 /// pointer means the character is under the pointer.
382 pub fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } {
383 const screen = self.term.screens.active;
384 const last: u16 = @intCast(self.term.cols - 1);
385 var lo = from;
386 var hi = to;
387 if (lo > 0) {
388 if (screen.pages.pin(.{ .viewport = .{ .x = lo, .y = y } })) |p| {
389 if (p.rowAndCell().cell.wide == .spacer_tail) lo -= 1;
390 }
391 }
392 if (hi < last) {
393 if (screen.pages.pin(.{ .viewport = .{ .x = hi, .y = y } })) |p| {
394 if (p.rowAndCell().cell.wide == .wide) hi += 1;
395 }
396 }
397 return .{ .from = lo, .to = hi };
398 }
399
400 /// `dumpVtRow`, with grid columns [from, to] painted inverted. No VT
401 /// sequence inverts part of a row already on screen, so a highlighted row is
402 /// re-emitted rather than decorated.
403 ///
404 /// Three pieces joined by CHA rather than by counting characters: the
405 /// formatter trims trailing whitespace, so the head's byte length says
406 /// nothing about where it left the cursor. CHA is SCREEN-absolute, hence
407 /// `view.col_off`. The span is emitted PLAIN, or a cell that kept its colour
408 /// reads as a hole; the inversion closes with a full reset, which is what
409 /// makes the tail's from-default assumption true.
410 pub fn dumpVtRowSpan(self: *Engine, alloc: std.mem.Allocator, y: u16, from: u16, to: u16, view: RowView) ![]u8 {
411 std.debug.assert(y < self.term.rows);
412 std.debug.assert(from <= to);
413 std.debug.assert(to < self.term.cols);
414 const last = self.clipCol(y, view) orelse return alloc.dupe(u8, "\x1b[0m");
415 // The whole highlight fell outside the pane: what is left of the row
416 // inside it carries no inversion at all.
417 if (from > last) return self.dumpVtRowClipped(alloc, y, view);
418 const snapped = self.snapWide(y, from, @min(to, last));
419 const lo = snapped.from;
420 const hi = snapped.to;
421
422 var aw: std.Io.Writer.Allocating = .init(alloc);
423 defer aw.deinit();
424 try aw.writer.writeAll("\x1b[0m");
425 if (lo > 0) try self.writeSelection(&aw.writer, .vt, self.viewportSpan(y, 0, lo - 1));
426 try aw.writer.print("\x1b[{d}G\x1b[0m\x1b[7m", .{lo + 1 + view.col_off});
427 // `trim = false`: a drag past the end of a short line selects the
428 // blanks after it, and a highlight that stopped at the last glyph
429 // would be narrower than the one the hand made.
430 try self.writeSelection(
431 &aw.writer,
432 .{ .emit = .plain, .trim = false },
433 self.viewportSpan(y, lo, hi),
434 );
435 try aw.writer.writeAll("\x1b[0m");
436 if (hi < last) {
437 try aw.writer.print("\x1b[{d}G", .{hi + 2 + view.col_off});
438 try self.writeSelection(&aw.writer, .vt, self.viewportSpan(y, hi + 1, last));
439 }
440 return try aw.toOwnedSlice();
441 }
442
443 /// Full terminal state as a canonical VT byte sequence — the Snapshot 341 /// Full terminal state as a canonical VT byte sequence — the Snapshot
444 /// payload body. Feeding it into a fresh engine of the same size 342 /// payload body. Feeding it into a fresh engine of the same size
445 /// reconstructs the state. 343 /// reconstructs the state.
@@ -541,27 +439,6 @@ pub const Engine = struct {
541 return @intCast(pt.screen.y); 439 return @intCast(pt.screen.y);
542 } 440 }
543 441
544 /// Styled dump of screen-space rows [start, start+count) on the active
545 /// screen (row 0 = oldest retained history row). Ranges are clamped to
546 /// what exists. Begins with an SGR reset so chunks are self-contained.
547 pub fn dumpScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) ![]u8 {
548 const screen = self.term.screens.active;
549 const total: u32 = self.historyRows() + self.term.rows;
550 const first = @min(start, total -| 1);
551 const last = @min(first + count -| 1, total -| 1);
552
553 const sel: ?vt.Selection = sel: {
554 const tl = screen.pages.pin(.{ .screen = .{ .x = 0, .y = first } }) orelse
555 break :sel null;
556 const br = screen.pages.pin(.{ .screen = .{
557 .x = @intCast(self.term.cols - 1),
558 .y = last,
559 } }) orelse break :sel null;
560 break :sel vt.Selection.init(tl, br, false);
561 };
562 return self.formatSelection(alloc, "\x1b[0m", sel);
563 }
564
565 pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 }; 442 pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 };
566 443
567 fn packColor(col: anytype) u32 { 444 fn packColor(col: anytype) u32 {
@@ -1258,32 +1135,6 @@ test "Engine: historyRows counts scrolled-off lines, zero on alt screen" {
1258 try std.testing.expectEqual(@as(u32, 77), e.historyRows()); 1135 try std.testing.expectEqual(@as(u32, 77), e.historyRows());
1259 } 1136 }
1260 1137
1261 test "Engine: dumpScrollback serves styled history rows by screen-space range" {
1262 const alloc = std.testing.allocator;
1263 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1264 defer e.deinit();
1265
1266 var i: usize = 1;
1267 while (i <= 100) : (i += 1) {
1268 var line: [48]u8 = undefined;
1269 e.feed(std.fmt.bufPrint(&line, "\x1b[3{d}mline-{d}\x1b[0m\r\n", .{ i % 8, i }) catch unreachable);
1270 }
1271
1272 // Rows 0..23 in screen space are the oldest 24 rows: line-1..line-24.
1273 const chunk = try e.dumpScrollback(alloc, 0, 24);
1274 defer alloc.free(chunk);
1275 try std.testing.expect(std.mem.indexOf(u8, chunk, "line-1\x1b") != null);
1276 try std.testing.expect(std.mem.indexOf(u8, chunk, "line-24") != null);
1277 try std.testing.expect(std.mem.indexOf(u8, chunk, "line-25") == null);
1278 // Styled: SGR survives (the formatter canonicalizes 31 -> 38;5;1).
1279 try std.testing.expect(std.mem.indexOf(u8, chunk, "\x1b[38;5;1m") != null);
1280
1281 // A range reaching past the end clamps instead of erroring.
1282 const tail = try e.dumpScrollback(alloc, 77 + 20, 24);
1283 defer alloc.free(tail);
1284 try std.testing.expect(std.mem.indexOf(u8, tail, "line-100") != null);
1285 }
1286
1287 test "Engine: dumps and snapshots cover the viewport, never scrollback" { 1138 test "Engine: dumps and snapshots cover the viewport, never scrollback" {
1288 const alloc = std.testing.allocator; 1139 const alloc = std.testing.allocator;
1289 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 1140 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
@@ -1312,145 +1163,6 @@ test "Engine: dumps and snapshots cover the viewport, never scrollback" {
1312 try std.testing.expect(std.mem.indexOf(u8, plain, "line-100") != null); 1163 try std.testing.expect(std.mem.indexOf(u8, plain, "line-100") != null);
1313 } 1164 }
1314 1165
1315 test "Engine: dumpVtRow dumps one styled viewport row, self-contained" {
1316 const alloc = std.testing.allocator;
1317 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1318 defer e.deinit();
1319
1320 e.feed("plain row\r\n\x1b[1;31mred row\x1b[0m\r\nthird");
1321
1322 const r0 = try e.dumpVtRow(alloc, 0);
1323 defer alloc.free(r0);
1324 try std.testing.expect(std.mem.indexOf(u8, r0, "plain row") != null);
1325 try std.testing.expect(std.mem.indexOf(u8, r0, "red row") == null);
1326 try std.testing.expect(std.mem.startsWith(u8, r0, "\x1b[0m"));
1327
1328 const r1 = try e.dumpVtRow(alloc, 1);
1329 defer alloc.free(r1);
1330 // Golden bytes: explicit reset, the formatter's own reset, then bold +
1331 // red (canonicalized 31 -> 38;5;1), and no row terminator.
1332 try std.testing.expectEqualStrings("\x1b[0m\x1b[0m\x1b[1m\x1b[38;5;1mred row\x1b[0m", r1);
1333 try std.testing.expect(std.mem.indexOfAny(u8, r1, "\r\n") == null);
1334
1335 // A row past the content is empty (just the reset prefix).
1336 const r9 = try e.dumpVtRow(alloc, 9);
1337 defer alloc.free(r9);
1338 try std.testing.expectEqualStrings("\x1b[0m", r9);
1339 }
1340
1341 test "Engine: dumpVtRowSpan inverts the columns asked for and no others" {
1342 const alloc = std.testing.allocator;
1343 var e = try Engine.init(alloc, .{ .cols = 20, .rows = 4 });
1344 defer e.deinit();
1345 e.feed("abcdefghij");
1346
1347 const row = try e.dumpVtRowSpan(alloc, 0, 2, 5, .{ .col_off = 0, .cols = @intCast(e.term.cols) });
1348 defer alloc.free(row);
1349 // Self-contained, exactly like `dumpVtRow`: the painters emit this
1350 // after a `\x1b[2K` and nothing else sets the row's state.
1351 try std.testing.expect(std.mem.startsWith(u8, row, "\x1b[0m"));
1352 // The three pieces, in order, each addressed by COLUMN rather than
1353 // reached by counting characters.
1354 const inv = std.mem.indexOf(u8, row, "\x1b[7m").?;
1355 try std.testing.expect(std.mem.indexOf(u8, row, "ab").? < inv);
1356 try std.testing.expect(std.mem.indexOf(u8, row, "cdef").? > inv);
1357 try std.testing.expect(std.mem.indexOf(u8, row, "ghij").? > inv);
1358 try std.testing.expect(std.mem.indexOf(u8, row, "\x1b[3G") != null);
1359 try std.testing.expect(std.mem.indexOf(u8, row, "\x1b[7G") != null);
1360 // The inversion ends before the tail does.
1361 try std.testing.expect(std.mem.indexOf(u8, row, "\x1b[0m\x1b[7G").? <
1362 std.mem.indexOf(u8, row, "ghij").?);
1363 // One row, no terminator — the caller placed the cursor.
1364 try std.testing.expect(std.mem.indexOfAny(u8, row, "\r\n") == null);
1365 }
1366
1367 test "Engine: dumpVtRowSpan neutralises the row's own styling inside the span" {
1368 const alloc = std.testing.allocator;
1369 var e = try Engine.init(alloc, .{ .cols = 20, .rows = 4 });
1370 defer e.deinit();
1371 // Columns 0-1 plain, 2-5 bold red, 6-7 plain again.
1372 e.feed("aa\x1b[1;31mRRRR\x1b[0mbb");
1373
1374 // A span cutting THROUGH the styled run: two of its cells inside the
1375 // inversion, one on either side.
1376 const row = try e.dumpVtRowSpan(alloc, 0, 3, 4, .{ .col_off = 0, .cols = @intCast(e.term.cols) });
1377 defer alloc.free(row);
1378 const inv = std.mem.indexOf(u8, row, "\x1b[7m").? + "\x1b[7m".len;
1379 const close = std.mem.indexOfPos(u8, row, inv, "\x1b[0m").?;
1380 // Inside: the cells' text and NOT one byte of their SGR. A styled cell
1381 // that kept its own colour under the inversion would read as a
1382 // differently-coloured hole in the highlight.
1383 try std.testing.expectEqualStrings("RR", row[inv..close]);
1384 // Outside: the same run still carries its colour, on both sides.
1385 try std.testing.expect(std.mem.indexOf(u8, row[0..inv], "\x1b[38;5;1m") != null);
1386 try std.testing.expect(std.mem.indexOf(u8, row[close..], "\x1b[38;5;1m") != null);
1387 try std.testing.expect(std.mem.indexOf(u8, row[close..], "bb") != null);
1388 }
1389
1390 test "Engine: dumpVtRowSpan over a whole row has no unhighlighted piece" {
1391 const alloc = std.testing.allocator;
1392 var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 });
1393 defer e.deinit();
1394 e.feed("whole");
1395
1396 const row = try e.dumpVtRowSpan(alloc, 0, 0, 9, .{ .col_off = 0, .cols = @intCast(e.term.cols) });
1397 defer alloc.free(row);
1398 // Golden bytes: no head, no tail, and no CHA back out to a tail that
1399 // is not there. The trailing five columns of this row were never
1400 // written, so there are no cells to invert past the text — see the
1401 // blank-cell test for the half of that rule which is not obvious.
1402 try std.testing.expectEqualStrings("\x1b[0m\x1b[1G\x1b[0m\x1b[7mwhole\x1b[0m", row);
1403 }
1404
1405 test "Engine: dumpVtRowSpan leaves wide cells to the machinery that owns them" {
1406 const alloc = std.testing.allocator;
1407 var e = try Engine.init(alloc, .{ .cols = 20, .rows = 2 });
1408 defer e.deinit();
1409 // Each glyph is two columns wide, so 漢 is columns 0-1 and 字 is 2-3.
1410 e.feed("漢字tail");
1411
1412 // Half of 字, which ghostty's Selection resolves to the whole glyph —
1413 // the reason this reuses it rather than walking cells here.
1414 const row = try e.dumpVtRowSpan(alloc, 0, 3, 3, .{ .col_off = 0, .cols = @intCast(e.term.cols) });
1415 defer alloc.free(row);
1416 const inv = std.mem.indexOf(u8, row, "\x1b[7m").? + "\x1b[7m".len;
1417 const close = std.mem.indexOfPos(u8, row, inv, "\x1b[0m").?;
1418 try std.testing.expectEqualStrings("字", row[inv..close]);
1419 try std.testing.expect(std.mem.indexOf(u8, row[0..inv], "漢") != null);
1420 try std.testing.expect(std.mem.indexOf(u8, row[close..], "tail") != null);
1421 }
1422
1423 test "Engine: dumpVtRowSpan highlights blank cells, and stops where the row does" {
1424 const alloc = std.testing.allocator;
1425 var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
1426 defer e.deinit();
1427 e.feed("ab cd");
1428
1429 // A drag across the gap in a line: the formatter trims trailing
1430 // whitespace BY DEFAULT, so without `trim = false` the inversion would
1431 // stop at the `b` and show a selection narrower than the hand made.
1432 const gap = try e.dumpVtRowSpan(alloc, 0, 0, 5, .{ .col_off = 0, .cols = @intCast(e.term.cols) });
1433 defer alloc.free(gap);
1434 const inv = std.mem.indexOf(u8, gap, "\x1b[7m").? + "\x1b[7m".len;
1435 const close = std.mem.indexOfPos(u8, gap, inv, "\x1b[0m").?;
1436 try std.testing.expectEqualStrings("ab ", gap[inv..close]);
1437
1438 // The other half, measured rather than assumed: past the last cell the
1439 // row HAS, there is nothing to invert — a screen row is only as wide
1440 // as what was written into it. So a drag off the end of a line shows a
1441 // highlight that ends at the text, whatever column the pointer reached.
1442 var short = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
1443 defer short.deinit();
1444 short.feed("ab");
1445 const past = try short.dumpVtRowSpan(alloc, 0, 0, 5, .{ .col_off = 0, .cols = @intCast(short.term.cols) });
1446 defer alloc.free(past);
1447 try std.testing.expectEqualStrings("\x1b[0m\x1b[1G\x1b[0m\x1b[7mab\x1b[0m\x1b[7G", past);
1448 // ...and a row with nothing in it at all inverts nothing.
1449 const blank = try short.dumpVtRowSpan(alloc, 1, 0, 5, .{ .col_off = 0, .cols = @intCast(short.term.cols) });
1450 defer alloc.free(blank);
1451 try std.testing.expectEqualStrings("\x1b[0m\x1b[1G\x1b[0m\x1b[7m\x1b[0m\x1b[7G", blank);
1452 }
1453
1454 test "Engine: onAltScreen reflects 1049 switches" { 1166 test "Engine: onAltScreen reflects 1049 switches" {
1455 const alloc = std.testing.allocator; 1167 const alloc = std.testing.allocator;
1456 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 1168 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
@@ -1744,61 +1456,10 @@ test "Engine: clearing side events frees their payloads" {
1744 // codegen) that have nothing to do with mux's own code. 1456 // codegen) that have nothing to do with mux's own code.
1745 test { 1457 test {
1746 std.testing.refAllDecls(@This()); 1458 std.testing.refAllDecls(@This());
1747 } 1459 // The child file's own tests. `refAllDecls` names a decl without
1748 1460 // reaching its tests, so without this line every test in delta.zig
1749 test "engine: a span boundary inside a wide cell does not shift the row" { 1461 // passes by not running.
1750 const alloc = std.testing.allocator; 1462 _ = delta;
1751 var a = try Engine.init(alloc, .{ .cols = 20, .rows = 2 });
1752 defer a.deinit();
1753 // Two wide cells with narrow text either side: every boundary in the
1754 // middle of the row lands either on a wide cell's first half or on its
1755 // spacer tail, which is the distinction under test.
1756 a.feed("ab\u{6f22}\u{5b57}cd");
1757
1758 try expectSpansAgree(alloc, a, 20);
1759
1760 // The third `Wide` variant: a wide character that does not fit leaves a
1761 // SPACER HEAD in the last column and moves to the next row. The formatter
1762 // skips a row whose span starts on one, so a boundary there blanks it.
1763 var c = try Engine.init(alloc, .{ .cols = 20, .rows = 3 });
1764 defer c.deinit();
1765 c.feed("aaaaaaaaaaaaaaaaaaa\u{6f22}");
1766 try expectSpansAgree(alloc, c, 20);
1767 }
1768
1769 /// Every span of row 0 must repaint that row exactly as it stands: a span
1770 /// changes which columns are INVERTED and nothing else, so the row's plain text
1771 /// through a fresh engine comes back identical whatever the boundaries were.
1772 fn expectSpansAgree(alloc: std.mem.Allocator, eng: *Engine, cols: u16) !void {
1773 const full = try eng.dumpPlain(alloc);
1774 defer alloc.free(full);
1775 const want = firstLine(full);
1776
1777 var bad: usize = 0;
1778 var from: u16 = 0;
1779 while (from < cols) : (from += 1) {
1780 var to: u16 = from;
1781 while (to < cols) : (to += 1) {
1782 const span = try eng.dumpVtRowSpan(alloc, 0, from, to, .{ .col_off = 0, .cols = @intCast(eng.term.cols) });
1783 defer alloc.free(span);
1784 var b = try Engine.init(alloc, .{ .cols = cols, .rows = @intCast(eng.term.rows) });
1785 defer b.deinit();
1786 b.feed("\x1b[1;1H\x1b[2K");
1787 b.feed(span);
1788 const got_full = try b.dumpPlain(alloc);
1789 defer alloc.free(got_full);
1790 const got = firstLine(got_full);
1791 if (!std.mem.eql(u8, want, got)) {
1792 bad += 1;
1793 std.debug.print("from={d} to={d}\n want |{s}|\n got |{s}|\n", .{ from, to, want, got });
1794 }
1795 }
1796 }
1797 try std.testing.expectEqual(@as(usize, 0), bad);
1798 }
1799
1800 fn firstLine(text: []const u8) []const u8 {
1801 return text[0 .. std.mem.indexOfScalar(u8, text, '\n') orelse text.len];
1802 } 1463 }
1803 1464
1804 test "mode 2048: setting in-band size reports answers with the size at once" { 1465 test "mode 2048: setting in-band size reports answers with the size at once" {
@@ -1911,7 +1572,7 @@ test "encodeViewportRow: an empty row is ncells 0 and nothing else" {
1911 try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row); 1572 try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row);
1912 } 1573 }
1913 1574
1914 test "encodeScrollback: clamps like dumpScrollback and returns dense rows" { 1575 test "encodeScrollback: clamps a range past the end and returns dense rows" {
1915 const alloc = std.testing.allocator; 1576 const alloc = std.testing.allocator;
1916 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 }); 1577 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 });
1917 defer e.deinit(); 1578 defer e.deinit();
@@ -1930,17 +1591,6 @@ test "encodeScrollback: clamps like dumpScrollback and returns dense rows" {
1930 try std.testing.expectEqual(@as(u16, 1), tail.count); 1591 try std.testing.expectEqual(@as(u16, 1), tail.count);
1931 } 1592 }
1932 1593
1933 fn vtBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
1934 var n: usize = 0;
1935 var y: u16 = 0;
1936 while (y < e.term.rows) : (y += 1) {
1937 const b = try e.dumpVtRow(alloc, y);
1938 defer alloc.free(b);
1939 n += b.len;
1940 }
1941 return n;
1942 }
1943
1944 fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize { 1594 fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
1945 var n: usize = 0; 1595 var n: usize = 0;
1946 var y: u16 = 0; 1596 var y: u16 = 0;
@@ -1952,7 +1602,11 @@ fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
1952 return n; 1602 return n;
1953 } 1603 }
1954 1604
1955 test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" { 1605 // The VT half of this measurement is gone with the VT row dumps it called.
1606 // Its figures, and the ratios the spec's gate was decided on, were recorded
1607 // in docs/decisions.md on 2026-09-04; what stays is the cell size itself, so
1608 // a change to the encoder that doubles a screen still shows up in a run.
1609 test "cells: wire size per screen (measurement; the VT comparison it was decided against is in decisions.md, 2026-09-04)" {
1956 const alloc = std.testing.allocator; 1610 const alloc = std.testing.allocator;
1957 const Screen = struct { name: []const u8, feed: []const u8 }; 1611 const Screen = struct { name: []const u8, feed: []const u8 };
1958 const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24; 1612 const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24;
@@ -1991,9 +1645,8 @@ test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
1991 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 1645 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1992 defer e.deinit(); 1646 defer e.deinit();
1993 e.feed(s.feed); 1647 e.feed(s.feed);
1994 const v = try vtBytes(alloc, e);
1995 const c = try cellBytes(alloc, e); 1648 const c = try cellBytes(alloc, e);
1996 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)) }); 1649 std.debug.print("\ncells-measure {s}: cells={d}\n", .{ s.name, c });
1997 } 1650 }
1998 } 1651 }
1999 1652
@@ -2001,9 +1654,9 @@ test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
2001 /// the form a client can hold. ghostty dumps with `trim = false`, so a space 1654 /// the form a client can hold. ghostty dumps with `trim = false`, so a space
2002 /// a program actually wrote at the end of a row survives into 1655 /// a program actually wrote at the end of a row survives into
2003 /// `Engine.dumpPlain`; no replica ever held one, because the VT formatter 1656 /// `Engine.dumpPlain`; no replica ever held one, because the VT formatter
2004 /// that fed the old wire trims trailing whitespace (see `dumpVtRowSpan`) and 1657 /// that fed the old wire trimmed trailing whitespace, and the e2e convergence
2005 /// the e2e convergence diff strips it as a formatting difference between two 1658 /// diff strips it as a formatting difference between two correct grids. The
2006 /// correct grids. The grid trims per row for the same reason, so the oracle 1659 /// grid trims per row for the same reason, so the oracle
2007 /// compares through the same trim rather than pretending the two dumps agree 1660 /// compares through the same trim rather than pretending the two dumps agree
2008 /// on bytes nothing downstream distinguishes. 1661 /// on bytes nothing downstream distinguishes.
2009 fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 { 1662 fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 {
@@ -2051,36 +1704,3 @@ test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for e
2051 try std.testing.expectEqual(e.cursorPos().y, g.cursor.y); 1704 try std.testing.expectEqual(e.cursorPos().y, g.cursor.y);
2052 } 1705 }
2053 } 1706 }
2054
2055 test "grid oracle: clipCol and snapWide agree with the engine's on rows with wide glyphs" {
2056 const alloc = std.testing.allocator;
2057 var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 });
2058 defer e.deinit();
2059 // Two rows whose wide glyphs sit in different columns, so an answer read
2060 // off row 0 is wrong for row 1 and the sweep says which row it asked.
2061 e.feed("\u{6f22}a\u{5b57}b\r\na\u{6f22}b\u{5b57}");
2062 const g = try Grid.init(alloc, 1, 1);
2063 defer g.deinit();
2064 try e.mirrorInto(g);
2065 // A pane that starts at screen column 7: both sides answer in GRID
2066 // columns, so the offset must not reach the answer.
2067 const col_off: u16 = 7;
2068 var y: u16 = 0;
2069 while (y < 2) : (y += 1) {
2070 var cols: u16 = 0;
2071 while (cols <= 10) : (cols += 1) {
2072 const view = Engine.RowView{ .col_off = col_off, .cols = cols };
2073 try std.testing.expectEqual(e.clipCol(y, view), g.clipCol(y, .{ .col_off = col_off, .cols = cols }));
2074 }
2075 var from: u16 = 0;
2076 while (from < 6) : (from += 1) {
2077 var to: u16 = from;
2078 while (to < 6) : (to += 1) {
2079 const a = e.snapWide(y, from, to);
2080 const b = g.snapWide(y, from, to);
2081 try std.testing.expectEqual(a.from, b.from);
2082 try std.testing.expectEqual(a.to, b.to);
2083 }
2084 }
2085 }
2086 }
src/engine/term.zig
Old New
@@ -1,18 +1,17 @@
1 //! The terminal component: the wire contract, the authoritative engine, 1 //! The wire contract, the client-side grid it decodes into, and the one
2 //! the delta minting that feeds replicas, and the one replay core. One 2 //! replay core. One table row — every file below is one owner, and only this
3 //! table row — every file below is one owner, and only this root is a seam, 3 //! root is a seam, so a second module claiming any of them is a
4 //! so a second module claiming any of them is a file-in-multiple-modules 4 //! file-in-multiple-modules compile error.
5 //! compile error. 5 //!
6 //! It links no terminal emulator. That is what lets the browser core and
7 //! every client binary hold a replica without ghostty-vt in them; the
8 //! emulator is the `engine` module's, which imports this one.
6 pub const protocol = @import("protocol.zig"); 9 pub const protocol = @import("protocol.zig");
7 pub const engine = @import("engine.zig");
8 pub const delta = @import("delta.zig");
9 pub const replica = @import("replica.zig"); 10 pub const replica = @import("replica.zig");
10 pub const grid = @import("grid.zig"); 11 pub const grid = @import("grid.zig");
11 12
12 test { 13 test {
13 _ = protocol; 14 _ = protocol;
14 _ = engine;
15 _ = delta;
16 _ = replica; 15 _ = replica;
17 _ = grid; 16 _ = grid;
18 } 17 }
src/server/cmd.zig
Old New
@@ -10,7 +10,7 @@
10 //! prompt does nothing. 10 //! prompt does nothing.
11 const std = @import("std"); 11 const std = @import("std");
12 const proto = @import("term").protocol; 12 const proto = @import("term").protocol;
13 const Engine = @import("term").engine.Engine; 13 const Engine = @import("engine").Engine;
14 14
15 pub const LiveCommand = struct { 15 pub const LiveCommand = struct {
16 phase: proto.CmdPhase = .at_prompt, 16 phase: proto.CmdPhase = .at_prompt,
src/server/server.zig
Old New
@@ -5,10 +5,10 @@
5 //! most recently active one (latest wins). Single-threaded: `pumpOnce` is one 5 //! most recently active one (latest wins). Single-threaded: `pumpOnce` is one
6 //! poll iteration, so tests can drive the loop. 6 //! poll iteration, so tests can drive the loop.
7 const std = @import("std"); 7 const std = @import("std");
8 const Engine = @import("term").engine.Engine; 8 const Engine = @import("engine").Engine;
9 const Pty = @import("pty").Pty; 9 const Pty = @import("pty").Pty;
10 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
11 const delta_mod = @import("term").delta; 11 const delta_mod = @import("engine").delta;
12 const DeltaTracker = delta_mod.DeltaTracker; 12 const DeltaTracker = delta_mod.DeltaTracker;
13 const cmdmod = @import("cmd.zig"); 13 const cmdmod = @import("cmd.zig");
14 const shellint = @import("shellint.zig"); 14 const shellint = @import("shellint.zig");
src/server/server_sessions.zig
Old New
@@ -11,7 +11,7 @@
11 11
12 const std = @import("std"); 12 const std = @import("std");
13 const proto = @import("term").protocol; 13 const proto = @import("term").protocol;
14 const Engine = @import("term").engine.Engine; 14 const Engine = @import("engine").Engine;
15 const Pty = @import("pty").Pty; 15 const Pty = @import("pty").Pty;
16 const srv_mod = @import("server.zig"); 16 const srv_mod = @import("server.zig");
17 const Server = srv_mod.Server; 17 const Server = srv_mod.Server;
src/server/server_test_clipboard.zig
Old New
@@ -1,5 +1,5 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const Engine = @import("term").engine.Engine; 2 const Engine = @import("engine").Engine;
3 const proto = @import("term").protocol; 3 const proto = @import("term").protocol;
4 const TmpDir = @import("testtmp").TmpDir; 4 const TmpDir = @import("testtmp").TmpDir;
5 const h = @import("server_test_harness.zig"); 5 const h = @import("server_test_harness.zig");
src/server/server_test_deliver.zig
Old New
@@ -1,5 +1,5 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const delta_mod = @import("term").delta; 2 const delta_mod = @import("engine").delta;
3 const proto = @import("term").protocol; 3 const proto = @import("term").protocol;
4 const h = @import("server_test_harness.zig"); 4 const h = @import("server_test_harness.zig");
5 const dial = h.dial; 5 const dial = h.dial;
src/server/server_test_harness.zig
Old New
@@ -1,5 +1,5 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const Engine = @import("term").engine.Engine; 2 const Engine = @import("engine").Engine;
3 const Grid = @import("term").grid.Grid; 3 const Grid = @import("term").grid.Grid;
4 pub const Pty = @import("pty").Pty; 4 pub const Pty = @import("pty").Pty;
5 const proto = @import("term").protocol; 5 const proto = @import("term").protocol;
src/tui/paint.zig
Old New
@@ -380,7 +380,7 @@ pub fn renderScrollback(
380 // under test is AUTHORED through an engine and mirrored in — the same path the 380 // under test is AUTHORED through an engine and mirrored in — the same path the
381 // daemon's encoder and the replica's decoder take on the wire. 381 // daemon's encoder and the replica's decoder take on the wire.
382 382
383 const Engine = @import("term").engine.Engine; 383 const Engine = @import("engine").Engine;
384 384
385 /// A grid holding what an engine that size shows after `bytes`, cursor 385 /// A grid holding what an engine that size shows after `bytes`, cursor
386 /// included. The bridge between a screen a test wants to describe in VT and 386 /// included. The bridge between a screen a test wants to describe in VT and
src/tui/wall_test_harness.zig
Old New
@@ -3,7 +3,7 @@
3 const std = @import("std"); 3 const std = @import("std");
4 const proto = @import("term").protocol; 4 const proto = @import("term").protocol;
5 const client = @import("client"); 5 const client = @import("client");
6 const Engine = @import("term").engine.Engine; 6 const Engine = @import("engine").Engine;
7 const layout = @import("client").layout; 7 const layout = @import("client").layout;
8 const wall_host = @import("wall_host.zig"); 8 const wall_host = @import("wall_host.zig");
9 const wall_picker = @import("wall_picker.zig"); 9 const wall_picker = @import("wall_picker.zig");
test/bans.sh
Old New
@@ -233,5 +233,39 @@ else
233 FAILED=1 233 FAILED=1
234 fi 234 fi
235 235
236 # The module split, which no folder rule can express: a folder rule reads
237 # bytes inside a file, and this is about which module a file belongs to.
238 # `checkGrantsUsed` refuses a grant nobody spends, but nothing refuses a
239 # grant somebody adds — a client row that reached for the engine again
240 # would build green and put ghostty-vt back in every client binary.
241 split_fail() {
242 echo "bans FAIL: $1"
243 FAILED=1
244 }
245
246 # The term module is the wasm root and the client's whole view of the wire,
247 # so it spells no emulator. engine.zig is the one file allowed the
248 # dependency, and term.zig must not re-export it: a re-export would hand
249 # every term importer the engine back without touching the table.
250 if grep -l '"ghostty-vt"' src/engine/term.zig src/engine/protocol.zig \
251 src/engine/replica.zig src/engine/grid.zig >/dev/null 2>&1; then
252 split_fail "a term child imports ghostty-vt"
253 fi
254 if grep -q 'engine.zig' src/engine/term.zig; then
255 split_fail "term.zig re-exports the engine"
256 fi
257
258 # No client row links the engine in production: the client parses no VT and
259 # authors screens through an engine only under test. The table writes one
260 # row per line, so a row's `.imports` list is on the line that names it, and
261 # `.test_imports` cannot match because the character before `imports` there
262 # is an underscore rather than a dot.
263 for row in client wall webhub term mux; do
264 if grep -E "\.name = \"$row\"" build.zig | grep -qE '\.imports = &\.[{][^}]*"engine"'; then
265 split_fail "module row $row imports engine outside its tests"
266 fi
267 done
268 [ "$FAILED" -eq 0 ] && echo "bans ok: the engine is the daemon's alone"
269
236 [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; } 270 [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; }
237 echo "bans: every folder rule bit, the test skip held, and no heredoc runs its own comments" 271 echo "bans: every folder rule bit, the test skip held, and no heredoc runs its own comments"
test/render.zig
Old New
@@ -3,7 +3,7 @@
3 //! text format, so the suite can diff what the client painted against 3 //! text format, so the suite can diff what the client painted against
4 //! what the daemon holds. Third helper beside rawmode/delaypipe. 4 //! what the daemon holds. Third helper beside rawmode/delaypipe.
5 const std = @import("std"); 5 const std = @import("std");
6 const Engine = @import("term").engine.Engine; 6 const Engine = @import("engine").Engine;
7 7
8 const alt_exit = "\x1b[?1049l"; 8 const alt_exit = "\x1b[?1049l";
9 9
test/wsclient.zig
Old New
@@ -26,9 +26,7 @@
26 //! Usage: wsclient --port N --tile IDX --out FILE --err FILE 26 //! Usage: wsclient --port N --tile IDX --out FILE --err FILE
27 //! [--origin STR] < script 27 //! [--origin STR] < script
28 const std = @import("std"); 28 const std = @import("std");
29 const Engine = @import("term").engine.Engine;
30 const Grid = @import("term").grid.Grid; 29 const Grid = @import("term").grid.Grid;
31 const delta_mod = @import("term").delta;
32 const Replica = @import("term").replica.Replica; 30 const Replica = @import("term").replica.Replica;
33 const proto = @import("term").protocol; 31 const proto = @import("term").protocol;
34 // The script dialect this fixture and ptyclient both speak: the escape 32 // The script dialect this fixture and ptyclient both speak: the escape
@@ -592,6 +590,13 @@ pub fn main() !void {
592 } 590 }
593 591
594 // --------------------------------------------------------------------------- 592 // ---------------------------------------------------------------------------
593 // Tests. The fixture itself holds a replica and parses no VT, exactly like
594 // the browser it stands in for; only the tests below reach for the engine,
595 // to build the daemon's own frames rather than hand-write bytes the wire
596 // would have to be trusted to match.
597
598 const Engine = @import("engine").Engine;
599 const delta_mod = @import("engine").delta;
595 600
596 test "masked message: header layout and mask application, all three length forms" { 601 test "masked message: header layout and mask application, all three length forms" {
597 const alloc = std.testing.allocator; 602 const alloc = std.testing.allocator;