a73x

1970473f

fix: mux a reads a scrollback chunk as cells, not as VT with SGR in it

a73x   2026-09-04 18:04

Commit message
fix: mux a reads a scrollback chunk as cells, not as VT with SGR in it

`mux a run` reports what a command printed by fetching the rows between its
OSC 133 marks. That fetch still ran the chunk through an SGR stripper written
for VT rows, so once the wire carried cells it copied the row headers into
`output` verbatim: a NUL and a 0x80 ahead of every line. `make agent` caught
it on the one leg that compares the transcript exactly.

The chunk decodes through `grid.decodeRows` and `grid.dumpRowsPlain` now, the
same two calls the terminal client's scrollback page makes, so an agent and a
client read one screen the same way. `dumpRowsPlain` is `Grid.dumpPlain`'s
body lifted out for the second caller, which owns rows without a grid.

`decodeRows` takes an optional width for that caller: `mux a` attaches at 0x0
and holds no grid, so the only width it could name is the protocol maximum,
and a long span at 4096 cells a row is hundreds of megabytes of blanks. Null
sizes each row to its own `ncells`, and the text is the same because the cells
past `ncells` are blanks the dump trims off the row anyway.

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

build.zig
Old New
@@ -253,9 +253,9 @@ const mod_table = [_]ModSpec{
253 // The transport modules are the CLI client's, minus everything that 253 // The transport modules are the CLI client's, minus everything that
254 // renders: `quic` for the remote arm and `xdg` for the one 254 // renders: `quic` for the remote arm and `xdg` for the one
255 // key-resolution rule all three binaries obey. Of `term` it spells the 255 // key-resolution rule all three binaries obey. Of `term` it spells the
256 // wire contract and nothing else — no engine and no replica, muxa having 256 // wire contract and the row decoder — no engine and no replica, muxa
257 // nothing to draw: a fact of muxa.zig itself, which the one-row component 257 // having nothing to draw and no grid to keep, but `mux a run` reports a
258 // no longer refuses on its behalf. 258 // command's output and the rows that carry it are cells like any others.
259 .{ .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 },
260 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" }, .test_imports = &.{"engine"} }, 260 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" }, .test_imports = &.{"engine"} },
261 // 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
src/cli/muxa.zig
Old New
@@ -8,6 +8,7 @@
8 //! this process's exit code. 8 //! this process's exit code.
9 const std = @import("std"); 9 const std = @import("std");
10 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
11 const grid = @import("term").grid;
11 const sockpath = @import("sockpath"); 12 const sockpath = @import("sockpath");
12 const quic = @import("quic"); 13 const quic = @import("quic");
13 const xdg = @import("xdg"); 14 const xdg = @import("xdg");
@@ -1334,53 +1335,40 @@ fn currentSeq(alloc: std.mem.Allocator, conn: *AgentConnection, session: []const
1334 return s.cmd.seq; 1335 return s.cmd.seq;
1335 } 1336 }
1336 1337
1337 /// Strip the styling out of scrollback rows: an agent reading `output` wants 1338 /// A `scrollback_chunk` payload as the text the command printed: an agent
1338 /// what the command printed, not how it was coloured. CSI and OSC go, as does 1339 /// reading `output` wants what was on the screen, not how it was coloured.
1339 /// any other two-byte escape. Deliberately not a VT parser — these rows come 1340 /// The payload leads with the start and count it answers, and the rows after
1340 /// from our own formatter, which emits SGR and nothing more exotic. 1341 /// that are `CellRow`s, so this is a decode. It used to be an SGR stripper
1341 fn stripSgr(alloc: std.mem.Allocator, s: []const u8) ![]u8 { 1342 /// over VT rows, and when the wire started carrying cells that stripper
1342 var out: std.ArrayList(u8) = .empty; 1343 /// passed the row headers straight through — a NUL and a 0x80 ahead of every
1343 errdefer out.deinit(alloc); 1344 /// line of `output`, which `make agent` caught on 2026-09-04.
1344 var i: usize = 0; 1345 ///
1345 while (i < s.len) { 1346 /// The COUNT is the chunk's own, not the request's: the daemon clamps a span
1346 if (s[i] != 0x1b or i + 1 >= s.len) { 1347 /// that runs past what it still holds, and reading more rows than it sent
1347 try out.append(alloc, s[i]); 1348 /// would decode whatever followed in the frame.
1348 i += 1; 1349 fn spanText(alloc: std.mem.Allocator, payload: []const u8) !?[]u8 {
1349 continue; 1350 if (payload.len <= 6) return null;
1350 } 1351 const count = std.mem.readInt(u16, payload[4..6], .little);
1351 switch (s[i + 1]) { 1352 if (count == 0) return null;
1352 '[' => { 1353 const rows = try grid.decodeRows(alloc, payload[6..], count, null);
1353 i += 2; 1354 defer grid.freeRows(alloc, rows);
1354 // Parameter and intermediate bytes, then one final byte in 1355 return try grid.dumpRowsPlain(alloc, rows);
1355 // 0x40..0x7e that ends the sequence.
1356 while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1;
1357 if (i < s.len) i += 1;
1358 },
1359 ']' => {
1360 i += 2;
1361 while (i < s.len) : (i += 1) {
1362 if (s[i] == 0x07) {
1363 i += 1;
1364 break;
1365 }
1366 if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') {
1367 i += 2;
1368 break;
1369 }
1370 }
1371 },
1372 // ESC 7, ESC M and friends: two bytes, both dropped.
1373 else => i += 2,
1374 }
1375 }
1376 return out.toOwnedSlice(alloc);
1377 } 1356 }
1378 1357
1379 test "stripSgr leaves text, drops SGR and OSC" { 1358 test "spanText decodes the chunk's rows and joins them as text" {
1380 const alloc = std.testing.allocator; 1359 const alloc = std.testing.allocator;
1381 const got = try stripSgr(alloc, "\x1b[0m\x1b[1;31mred\x1b[0m ok\n\x1b]0;title\x07plain"); 1360 var payload: std.ArrayList(u8) = .empty;
1361 defer payload.deinit(alloc);
1362 try payload.appendSlice(alloc, &.{ 3, 0, 0, 0, 2, 0 }); // start 3, count 2
1363 for ([_][]const u8{ "out-42", "second" }) |line| {
1364 var w = try proto.CellRowWriter.begin(&payload, alloc);
1365 defer w.deinit();
1366 for (line) |ch| try w.cell(.{}, .narrow, &.{ch});
1367 w.finish();
1368 }
1369 const got = (try spanText(alloc, payload.items)).?;
1382 defer alloc.free(got); 1370 defer alloc.free(got);
1383 try std.testing.expectEqualStrings("red ok\nplain", got); 1371 try std.testing.expectEqualStrings("out-42\nsecond", got);
1384 } 1372 }
1385 1373
1386 /// Rows go stale between reply and fetch, so failure here is a null 1374 /// Rows go stale between reply and fetch, so failure here is a null
@@ -1397,9 +1385,7 @@ fn fetchSpan(
1397 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline); 1385 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline);
1398 const frame = try conn.awaitFrame(.scrollback_chunk, deadline); 1386 const frame = try conn.awaitFrame(.scrollback_chunk, deadline);
1399 defer frame.deinit(alloc); 1387 defer frame.deinit(alloc);
1400 // The chunk leads with the request it answers; the rows follow. 1388 return try spanText(alloc, frame.payload);
1401 if (frame.payload.len <= 6) return null;
1402 return try stripSgr(alloc, frame.payload[6..]);
1403 } 1389 }
1404 1390
1405 /// `output` is absent when there is no transcript: an absent key and 1391 /// `output` is absent when there is no transcript: an absent key and
src/engine/grid.zig
Old New
@@ -103,7 +103,15 @@ pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan {
103 return .{ .from = lo, .to = hi }; 103 return .{ .from = lo, .to = hi };
104 } 104 }
105 105
106 pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: u16) ![]Row { 106 /// `cols` is the width every row is sized to — a grid's own width, so a
107 /// decoded row drops straight into it. `null` sizes each row to its own
108 /// `ncells` instead, for a reader that holds no grid: `mux a` attaches at 0x0
109 /// and never paints, so the only width it could otherwise name is the
110 /// protocol maximum, and a long span at 4096 cells a row is hundreds of
111 /// megabytes for text that is mostly blank. The text is the same either way,
112 /// because the cells past `ncells` are blanks `dumpRowsPlain` trims off the
113 /// end of the row.
114 pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: ?u16) ![]Row {
107 const rows = try alloc.alloc(Row, count); 115 const rows = try alloc.alloc(Row, count);
108 var made: usize = 0; 116 var made: usize = 0;
109 errdefer { 117 errdefer {
@@ -112,10 +120,11 @@ pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols:
112 } 120 }
113 var rest = bytes; 121 var rest = bytes;
114 for (rows) |*r| { 122 for (rows) |*r| {
115 r.* = .{ .cells = try alloc.alloc(Cell, cols) }; 123 const w = cols orelse (try proto.CellRowReader.init(rest)).ncells;
124 r.* = .{ .cells = try alloc.alloc(Cell, w) };
116 made += 1; 125 made += 1;
117 @memset(r.cells, .{}); 126 @memset(r.cells, .{});
118 rest = try decodeRow(alloc, r, rest, cols); 127 rest = try decodeRow(alloc, r, rest, w);
119 } 128 }
120 return rows; 129 return rows;
121 } 130 }
@@ -125,6 +134,42 @@ pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void {
125 alloc.free(rows); 134 alloc.free(rows);
126 } 135 }
127 136
137 /// Rows as text: a space per blank or spacer-less empty cell, nothing for a
138 /// spacer, rows joined by newlines, the trailing blank ROWS dropped along with
139 /// the newlines that would have separated them (a screen is 24 rows tall
140 /// whatever is on it, so an unwritten tail is not text), and every row's
141 /// trailing spaces trimmed.
142 ///
143 /// Interior blank rows stay: they are a gap the user typed.
144 ///
145 /// The trailing-space trim is where this differs from Engine.dumpPlain, which
146 /// is ghostty's plainString and dumps with `trim = false`, so a space a
147 /// program wrote at the end of a row survives it. A client never holds such a
148 /// space, and never did: the VT formatter that fed the old wire trims trailing
149 /// whitespace before a row leaves the daemon, and the e2e convergence diff
150 /// strips trailing whitespace on both sides as a formatting difference between
151 /// two correct grids. Trimming here makes the two agree on everything a client
152 /// can observe. The engine.zig oracle test compares through the same trim and
153 /// is the authority.
154 pub fn dumpRowsPlain(alloc: std.mem.Allocator, rows: []const Row) ![]u8 {
155 var out: std.ArrayList(u8) = .empty;
156 errdefer out.deinit(alloc);
157 for (rows, 0..) |*r, y| {
158 const start = out.items.len;
159 for (r.cells) |c| {
160 switch (c.wide) {
161 .spacer_tail, .spacer_head => continue,
162 .narrow, .wide => {},
163 }
164 if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
165 }
166 while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
167 if (y + 1 < rows.len) try out.append(alloc, '\n');
168 }
169 while (out.items.len > 0 and out.items[out.items.len - 1] == '\n') out.items.len -= 1;
170 return out.toOwnedSlice(alloc);
171 }
172
128 pub const Grid = struct { 173 pub const Grid = struct {
129 alloc: std.mem.Allocator, 174 alloc: std.mem.Allocator,
130 cols: u16, 175 cols: u16,
@@ -189,40 +234,11 @@ pub const Grid = struct {
189 return &self.lines[y]; 234 return &self.lines[y];
190 } 235 }
191 236
192 /// The screen as text: a space per blank or spacer-less empty cell, 237 /// The screen as text. See `dumpRowsPlain`, which this is over the grid's
193 /// nothing for a spacer, rows joined by newlines, the trailing blank ROWS 238 /// own rows; a scrollback span decoded with `decodeRows` goes through the
194 /// dropped along with the newlines that would have separated them (a 239 /// same function, so a client and an agent read one screen the same way.
195 /// screen is 24 rows tall whatever is on it, so an unwritten tail is not
196 /// text), and every row's trailing spaces trimmed.
197 ///
198 /// Interior blank rows stay: they are a gap the user typed.
199 ///
200 /// The trailing-space trim is where this differs from Engine.dumpPlain,
201 /// which is ghostty's plainString and dumps with `trim = false`, so a
202 /// space a program wrote at the end of a row survives it. A client never
203 /// holds such a space, and never did: the VT formatter that fed the old
204 /// wire trims trailing whitespace before a row leaves the daemon, and the
205 /// e2e convergence diff strips trailing whitespace on both sides as a
206 /// formatting difference between two correct grids. Trimming here makes
207 /// the two agree on everything a client can observe. The engine.zig
208 /// oracle test compares through the same trim and is the authority.
209 pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 { 240 pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 {
210 var out: std.ArrayList(u8) = .empty; 241 return dumpRowsPlain(alloc, self.lines);
211 errdefer out.deinit(alloc);
212 for (self.lines, 0..) |*r, y| {
213 const start = out.items.len;
214 for (r.cells) |c| {
215 switch (c.wide) {
216 .spacer_tail, .spacer_head => continue,
217 .narrow, .wide => {},
218 }
219 if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
220 }
221 while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
222 if (y + 1 < self.lines.len) try out.append(alloc, '\n');
223 }
224 while (out.items.len > 0 and out.items[out.items.len - 1] == '\n') out.items.len -= 1;
225 return out.toOwnedSlice(alloc);
226 } 242 }
227 243
228 pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 { 244 pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 {