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
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 { |