a73x

a54a8105

fix: a blank row paints nothing, and a trailing default space is not a divergence

a73x   2026-09-04 18:04

Commit message
fix: a blank row paints nothing, and a trailing default space is not a divergence

Two consequences of the cell wire that the e2e convergence check found.

The serializer stopped a row at column 0 when every cell was blank, which
printed a space into every empty row and left a screen that could never look
empty again. It now writes nothing at all for a row with nothing on it.

The styled convergence leg normalizes trailing default spaces on both sides,
as the plain leg already did. The row encoder stops a row at its last cell
that is not a default blank, so a space the shell wrote at the end of a row
never leaves the daemon: the client's grid holds an erased cell where the
daemon's holds a space, and the two paint identically. A trailing space
carrying a colour is sent, arrives wrapped in SGR bytes, and is untouched.

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

src/tui/paint.zig
Old New
@@ -49,15 +49,22 @@ pub fn rowToVtFrom(
49 if (s.from > last) break :blk null; 49 if (s.from > last) break :blk null;
50 break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last)); 50 break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last));
51 } else null; 51 } else null;
52 var end: u16 = last; 52 // The last cell worth writing: trailing default blanks are the caller's
53 // Trailing default blanks are the caller's clear, not ours — but a 53 // clear, not ours — but a selected blank is a cell the user can see they
54 // selected blank is a cell the user can see they selected, so the span 54 // selected, so the span holds the stop open past it. A row with nothing
55 // holds the stop open past it. 55 // on it writes nothing at all, or an empty row would print a space and a
56 while (end > 0 and r.cells[end].isBlank() and (snapped == null or end > snapped.?.to)) end -= 1; 56 // screen would never look empty again.
57 var end: ?u16 = null;
58 var i: u16 = 0;
59 while (i <= last) : (i += 1) {
60 const selected = if (snapped) |s| i >= s.from and i <= s.to else false;
61 if (!r.cells[i].isBlank() or selected) end = i;
62 }
63 const stop = end orelse return out.toOwnedSlice(alloc);
57 var cur: proto.CellStyle = .{}; 64 var cur: proto.CellStyle = .{};
58 var x: u16 = 0; 65 var x: u16 = 0;
59 var inverted = false; 66 var inverted = false;
60 while (x <= end) : (x += 1) { 67 while (x <= stop) : (x += 1) {
61 const c = r.cells[x]; 68 const c = r.cells[x];
62 if (c.wide == .spacer_tail or c.wide == .spacer_head) continue; 69 if (c.wide == .spacer_tail or c.wide == .spacer_head) continue;
63 const in_span = if (snapped) |s| x >= s.from and x <= s.to else false; 70 const in_span = if (snapped) |s| x >= s.from and x <= s.to else false;
test/e2e_01_boot.sh
Old New
@@ -98,7 +98,8 @@ if converged_quiet "$OUT.doctored" "$SOCK"; then
98 fi 98 fi
99 rm_swept "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \ 99 rm_swept "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \
100 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \ 100 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \
101 "$OUT.doctored.rvt" "$OUT.doctored.dvt" 101 "$OUT.doctored.rvt" "$OUT.doctored.dvt" \
102 "$OUT.doctored.rvt.n" "$OUT.doctored.dvt.n"
102 ok "convergence control fires on a doctored stream" 103 ok "convergence control fires on a doctored stream"
103 104
104 # --- M11: a styled specimen, so the byte-exact leg has something to compare. 105 # --- M11: a styled specimen, so the byte-exact leg has something to compare.
test/e2e_lib.sh
Old New
@@ -1041,9 +1041,20 @@ converged_quiet() {
1041 # shellcheck disable=SC2086 1041 # shellcheck disable=SC2086
1042 "$RENDER" --vt $_sz $_drop < "$_co" > "$_co.rvt" || return 1 1042 "$RENDER" --vt $_sz $_drop < "$_co" > "$_co.rvt" || return 1
1043 "$MUX" d dump --vt --sock "$_cs" > "$_co.dvt" || return 1 1043 "$MUX" d dump --vt --sock "$_cs" > "$_co.dvt" || return 1
1044 cmp -s "$_co.dvt" "$_co.rvt" || return 1 1044 # Trailing DEFAULT spaces are normalized away on both sides for the same
1045 # reason the plain leg strips them: the row encoder stops a row at its
1046 # last cell that is not a default blank, so a space the shell wrote at
1047 # the end of a row never leaves the daemon and the client's grid holds an
1048 # erased cell where the daemon's holds a space. The two paint identically.
1049 # A trailing space carrying a colour is NOT a default blank — it is sent,
1050 # and it arrives wrapped in SGR bytes, so the line does not end in
1051 # whitespace and this leaves it alone.
1052 _cr=$(printf '\r')
1053 sed "s/ *\\(${_cr}\\{0,1\\}\\)\$/\\1/" "$_co.dvt" > "$_co.dvt.n"
1054 sed "s/ *\\(${_cr}\\{0,1\\}\\)\$/\\1/" "$_co.rvt" > "$_co.rvt.n"
1055 cmp -s "$_co.dvt.n" "$_co.rvt.n" || return 1
1045 rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \ 1056 rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \
1046 "$_co.diff" "$_co.rvt" "$_co.dvt" 1057 "$_co.diff" "$_co.rvt" "$_co.dvt" "$_co.rvt.n" "$_co.dvt.n"
1047 return 0 1058 return 0
1048 } 1059 }
1049 1060