632d1166
feat: the daemon encodes a grid row as cells, and the size is measured
a73x 2026-09-04 17:00
Commit message
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 | } | ||