a73x

632d1166

feat: the daemon encodes a grid row as cells, and the size is measured

a73x   2026-09-04 17:00

Commit message
feat: the daemon encodes a grid row as cells, and the size is measured

encodeViewportRow and encodeScrollback read ghostty's page cells straight
into CellRow bytes: style, wide state, and the cell's text including its
grapheme continuations. The row ends at the last cell that is not a default
blank, and a cell carrying only a background counts as content — a coloured
EL would otherwise vanish. encodeScrollback clamps the way dumpScrollback
does and returns the rows dense with the window it actually encoded.

A space is sent as a space rather than as the empty text a blank cell
carries. Both paint the same, but only a one-byte narrow cell qualifies for
the run's ascii form, so one empty cell drops the whole run out of it and
every other cell then pays a head byte: blanking interior spaces cost 36%
of a prose screen.

The measurement the design's gate reads is in decisions.md. The gate is NOT
met — prose 1.32 against its 1.5, but vim 1.45 and htop 1.56 against their
1.2, because a run header is 16 absolute bytes where SGR spends four to
seven on a delta, and a highlighted source line changes style ten times.

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

docs/decisions.md
Old New
@@ -8897,3 +8897,40 @@ because it carries an mtime — and never `--force` on the strength of a
8897 matching sha alone. That rule was established at v0.0.1-16 (2026-08-29), 8897 matching sha alone. That rule was established at v0.0.1-16 (2026-08-29),
8898 where a publish was refused against an identical binary built 76 seconds 8898 where a publish was refused against an identical binary built 76 seconds
8899 earlier and the download-and-cmp is what established it was identical. 8899 earlier and the download-and-cmp is what established it was identical.
8900
8901 ## 2026-09-04 — cells on the wire: the size measurement
8902
8903 The design's go/no-go gate is what a cell row costs against the VT row it
8904 replaces. `engine.zig`'s test "cells: wire size vs VT rows" measures it on
8905 four 80x24 screens fed real output shapes, comparing `dumpVtRow` against
8906 `encodeViewportRow` over the whole viewport:
8907
8908 ```
8909 cells-measure prose: vt=988 cells=1308 ratio=1.32
8910 cells-measure vim: vt=3132 cells=4533 ratio=1.45
8911 cells-measure htop: vt=4167 cells=6488 ratio=1.56
8912 cells-measure shell: vt=128 cells=127 ratio=0.99
8913 ```
8914
8915 **Gate verdict: NOT met.** The gate asked for prose <= 1.5 and vim/htop
8916 <= 1.2. Prose passes at 1.32 and the shell screen is free at 0.99, but the
8917 two style-dense screens are 1.45 and 1.56, well over their 1.2. The cost is
8918 the run header: 16 bytes of style per run, and a syntax-highlighted source
8919 line or an htop row changes style eight to ten times, so a row pays 130-160
8920 bytes of header for 70 bytes of text. VT spends four to seven bytes on the
8921 same style change because SGR is a delta against the previous style and a
8922 run header is absolute. Whether that is worth paying for is the design's
8923 call, not the encoder's — the alternative the spec names is VT frames for
8924 terminal clients and cells for native and browser ones.
8925
8926 The first measurement of the same encoder read 2.08 / 1.67 / 1.80 / 1.17.
8927 The difference was one line: the encoder sent a space cell as the EMPTY text
8928 a blank cell carries, on the reasoning that both paint the same and the empty
8929 form is shorter. It is shorter for one cell and much longer for the row.
8930 Only a one-byte narrow cell qualifies for the run's ascii form, in which a
8931 cell is its single byte and nothing else, so one empty cell drops the whole
8932 run out of that form and every remaining cell then pays a head byte as well
8933 as its text. Prose is a third spaces, so blanking them cost 36% of the
8934 screen. Sending the space as a space is also the better answer for copy:
8935 an interior space is a space. Trailing blanks were never the question —
8936 the row already ends at the last cell that is not one.
src/engine/engine.zig
Old New
@@ -3,6 +3,7 @@
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. 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.
4 const std = @import("std"); 4 const std = @import("std");
5 const vt = @import("ghostty-vt"); 5 const vt = @import("ghostty-vt");
6 const proto = @import("protocol.zig");
6 7
7 /// MuxHandler's own `vt` method shadows the `vt` import inside its body, 8 /// MuxHandler's own `vt` method shadows the `vt` import inside its body,
8 /// so the dep types it names are spelled through these aliases. 9 /// so the dep types it names are spelled through these aliases.
@@ -557,6 +558,132 @@ pub const Engine = struct {
557 return self.formatSelection(alloc, "\x1b[0m", sel); 558 return self.formatSelection(alloc, "\x1b[0m", sel);
558 } 559 }
559 560
561 pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 };
562
563 fn packColor(col: anytype) u32 {
564 return switch (col) {
565 .none => proto.color_none,
566 .palette => |p| proto.colorPalette(p),
567 .rgb => |c| proto.colorRgb(c.r, c.g, c.b),
568 };
569 }
570
571 fn packStyle(style: vt.Style) proto.CellStyle {
572 return .{
573 .fg = packColor(style.fg_color),
574 .bg = packColor(style.bg_color),
575 .ul = packColor(style.underline_color),
576 .flags = @bitCast(style.flags),
577 };
578 }
579
580 /// One row of the active screen at `pt` as a CellRow. The last cell sent
581 /// is the last one that is not a default blank; a bare-background cell
582 /// counts as content, or a coloured EL would vanish.
583 fn encodeRowAt(self: *Engine, alloc: std.mem.Allocator, pt: vt.point.Point) ![]u8 {
584 const screen = self.term.screens.active;
585 var list: std.ArrayList(u8) = .empty;
586 errdefer list.deinit(alloc);
587 var w = try proto.CellRowWriter.begin(&list, alloc);
588 errdefer w.deinit();
589 const pin = screen.pages.pin(pt) orelse {
590 w.finish();
591 return list.toOwnedSlice(alloc);
592 };
593 const page = &pin.node.data;
594 const rac = pin.rowAndCell();
595 const cells = page.getCells(rac.row);
596 // A page is built at the terminal's width, but the min keeps a
597 // mismatch a short row rather than a read past the row's cells.
598 const cols: usize = @min(@as(usize, self.term.cols), cells.len);
599
600 // Find the last cell worth sending.
601 var last: usize = 0;
602 var any = false;
603 for (cells[0..cols], 0..) |c, x| {
604 const blank = c.style_id == 0 and c.wide == .narrow and switch (c.content_tag) {
605 .codepoint => c.content.codepoint == 0 or c.content.codepoint == ' ',
606 .codepoint_grapheme => false,
607 .bg_color_palette, .bg_color_rgb => false,
608 };
609 if (!blank) {
610 last = x;
611 any = true;
612 }
613 }
614 if (!any) {
615 w.finish();
616 return list.toOwnedSlice(alloc);
617 }
618
619 var text: [proto.cell_text_max]u8 = undefined;
620 for (cells[0 .. last + 1]) |*c| {
621 var style: proto.CellStyle = if (c.style_id == 0) .{} else packStyle(page.styles.get(page.memory, c.style_id).*);
622 var len: usize = 0;
623 switch (c.content_tag) {
624 .codepoint, .codepoint_grapheme => {
625 if (c.content.codepoint != 0) {
626 len += std.unicode.utf8Encode(@intCast(c.content.codepoint), text[len..]) catch 0;
627 }
628 if (c.hasGrapheme()) {
629 if (page.lookupGrapheme(c)) |cps| {
630 for (cps) |cp| {
631 var buf: [4]u8 = undefined;
632 const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch continue;
633 if (len + n > proto.cell_text_max) break;
634 @memcpy(text[len .. len + n], buf[0..n]);
635 len += n;
636 }
637 }
638 }
639 },
640 .bg_color_palette => style.bg = proto.colorPalette(c.content.color_palette),
641 .bg_color_rgb => style.bg = proto.colorRgb(c.content.color_rgb.r, c.content.color_rgb.g, c.content.color_rgb.b),
642 }
643 const wide: proto.Wide = switch (c.wide) {
644 .narrow => .narrow,
645 .wide => .wide,
646 .spacer_tail => .spacer_tail,
647 .spacer_head => .spacer_head,
648 };
649 // A space is sent AS a space, not as the empty text a blank cell
650 // carries. Both paint the same, but only a one-byte narrow cell
651 // qualifies for the run's ascii form, and one empty cell drops the
652 // whole run out of it — every remaining cell then pays a head byte.
653 // Blanking interior spaces cost 36% on a prose screen for nothing
654 // (measured 2026-09-04, decisions.md). Trailing blanks are already
655 // gone: the row ends at the last cell that is not one.
656 const t: []const u8 = text[0..len];
657 try w.cell(style, wide, t);
658 }
659 w.finish();
660 return list.toOwnedSlice(alloc);
661 }
662
663 pub fn encodeViewportRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
664 std.debug.assert(y < self.term.rows);
665 return self.encodeRowAt(alloc, .{ .viewport = .{ .x = 0, .y = y } });
666 }
667
668 /// Screen-space rows [start, start+count) on the active screen, clamped
669 /// to what exists, dense. Row 0 is the oldest retained history row.
670 pub fn encodeScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) !EncodedRows {
671 const total: u32 = self.historyRows() + self.term.rows;
672 const first = @min(start, total -| 1);
673 const last = @min(first + count -| 1, total -| 1);
674 var list: std.ArrayList(u8) = .empty;
675 errdefer list.deinit(alloc);
676 var y = first;
677 var n: u16 = 0;
678 while (y <= last and total > 0) : (y += 1) {
679 const row = try self.encodeRowAt(alloc, .{ .screen = .{ .x = 0, .y = y } });
680 defer alloc.free(row);
681 try list.appendSlice(alloc, row);
682 n += 1;
683 }
684 return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) };
685 }
686
560 /// Screen-space rows, zero being the oldest retained. Formatting writes 687 /// Screen-space rows, zero being the oldest retained. Formatting writes
561 /// into a fixed-size allocation: hostile coordinates cannot blow it up. 688 /// into a fixed-size allocation: hostile coordinates cannot blow it up.
562 pub fn extractSelection( 689 pub fn extractSelection(
@@ -1664,3 +1791,136 @@ test "mode 2048: a resize reports the new size in-band, and only when asked" {
1664 try e.resize(90, 28); 1791 try e.resize(90, 28);
1665 try std.testing.expectEqualStrings("", e.ptyOutput()); 1792 try std.testing.expectEqualStrings("", e.ptyOutput());
1666 } 1793 }
1794
1795 fn decodeAll(alloc: std.mem.Allocator, bytes: []const u8) ![]proto.DecodedCell {
1796 var out: std.ArrayList(proto.DecodedCell) = .empty;
1797 var r = try proto.CellRowReader.init(bytes);
1798 while (try r.next()) |c| try out.append(alloc, c);
1799 return out.toOwnedSlice(alloc);
1800 }
1801
1802 test "encodeViewportRow: ascii, a wide glyph with its spacer, a grapheme, and trailing blanks dropped" {
1803 const alloc = std.testing.allocator;
1804 var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
1805 defer e.deinit();
1806 e.feed("ab漢e\u{301}");
1807 const row = try e.encodeViewportRow(alloc, 0);
1808 defer alloc.free(row);
1809 const cells = try decodeAll(alloc, row);
1810 defer alloc.free(cells);
1811 // a b 漢 (spacer) é — five cells; the seven blanks after are not sent.
1812 try std.testing.expectEqual(@as(usize, 5), cells.len);
1813 try std.testing.expectEqualStrings("a", cells[0].text);
1814 try std.testing.expectEqual(proto.Wide.wide, cells[2].wide);
1815 try std.testing.expectEqualStrings("漢", cells[2].text);
1816 try std.testing.expectEqual(proto.Wide.spacer_tail, cells[3].wide);
1817 try std.testing.expectEqualStrings("e\u{301}", cells[4].text);
1818 }
1819
1820 test "encodeViewportRow: styles pack as the wire says, and a bg-only cell is not blank" {
1821 const alloc = std.testing.allocator;
1822 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
1823 defer e.deinit();
1824 // bold red on palette-4 blue 'x', then EL with the blue background held:
1825 // the cells after x carry a background and no glyph.
1826 e.feed("\x1b[1;31;44mx\x1b[0m\x1b[44m\x1b[K");
1827 const row = try e.encodeViewportRow(alloc, 0);
1828 defer alloc.free(row);
1829 const cells = try decodeAll(alloc, row);
1830 defer alloc.free(cells);
1831 try std.testing.expectEqual(@as(usize, 8), cells.len);
1832 try std.testing.expectEqual(proto.colorPalette(1), cells[0].style.fg);
1833 try std.testing.expectEqual(proto.colorPalette(4), cells[0].style.bg);
1834 try std.testing.expectEqual(@as(u16, 1), cells[0].style.flags & 1);
1835 try std.testing.expectEqualStrings("", cells[7].text);
1836 try std.testing.expectEqual(proto.colorPalette(4), cells[7].style.bg);
1837 }
1838
1839 test "encodeViewportRow: an interior space keeps the row in one ascii run" {
1840 const alloc = std.testing.allocator;
1841 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
1842 defer e.deinit();
1843 e.feed("a b");
1844 const row = try e.encodeViewportRow(alloc, 0);
1845 defer alloc.free(row);
1846 // Sending the space as empty text would drop this run out of the ascii
1847 // form and cost a head byte on every cell of it — 36% of a prose screen.
1848 try std.testing.expectEqualSlices(u8, &[_]u8{
1849 3, 0, // ncells
1850 3, 0, 0, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header, ascii
1851 'a', ' ', 'b',
1852 }, row);
1853 }
1854
1855 test "encodeViewportRow: an empty row is ncells 0 and nothing else" {
1856 const alloc = std.testing.allocator;
1857 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
1858 defer e.deinit();
1859 const row = try e.encodeViewportRow(alloc, 0);
1860 defer alloc.free(row);
1861 try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row);
1862 }
1863
1864 test "encodeScrollback: clamps like dumpScrollback and returns dense rows" {
1865 const alloc = std.testing.allocator;
1866 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 });
1867 defer e.deinit();
1868 e.feed("one\r\ntwo\r\nthree\r\nfour");
1869 // history: one two; viewport: three four. Ask for 3 rows from 1: two three four.
1870 const got = try e.encodeScrollback(alloc, 1, 3);
1871 defer alloc.free(got.bytes);
1872 try std.testing.expectEqual(@as(u32, 1), got.first);
1873 try std.testing.expectEqual(@as(u16, 3), got.count);
1874 var r = try proto.CellRowReader.init(got.bytes);
1875 try std.testing.expectEqualStrings("t", (try r.next()).?.text);
1876 // Past the end clamps to what exists.
1877 const tail = try e.encodeScrollback(alloc, 100, 5);
1878 defer alloc.free(tail.bytes);
1879 try std.testing.expectEqual(@as(u32, 3), tail.first);
1880 try std.testing.expectEqual(@as(u16, 1), tail.count);
1881 }
1882
1883 fn vtBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
1884 var n: usize = 0;
1885 var y: u16 = 0;
1886 while (y < e.term.rows) : (y += 1) {
1887 const b = try e.dumpVtRow(alloc, y);
1888 defer alloc.free(b);
1889 n += b.len;
1890 }
1891 return n;
1892 }
1893
1894 fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
1895 var n: usize = 0;
1896 var y: u16 = 0;
1897 while (y < e.term.rows) : (y += 1) {
1898 const b = try e.encodeViewportRow(alloc, y);
1899 defer alloc.free(b);
1900 n += b.len;
1901 }
1902 return n;
1903 }
1904
1905 test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
1906 const alloc = std.testing.allocator;
1907 const Screen = struct { name: []const u8, feed: []const u8 };
1908 const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24;
1909 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;
1910 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;
1911 const shell = "$ ls\r\nbuild.zig docs src test\r\n$ \r\n";
1912 const screens = [_]Screen{
1913 .{ .name = "prose", .feed = prose },
1914 .{ .name = "vim", .feed = vim },
1915 .{ .name = "htop", .feed = htop },
1916 .{ .name = "shell", .feed = shell },
1917 };
1918 for (screens) |s| {
1919 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1920 defer e.deinit();
1921 e.feed(s.feed);
1922 const v = try vtBytes(alloc, e);
1923 const c = try cellBytes(alloc, e);
1924 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)) });
1925 }
1926 }