From 2f726933d62b10aae80aa748b729c1fe4e41faa8 Mon Sep 17 00:00:00 2001 From: benshi <807629978@qq.com> Date: Tue, 16 Jun 2026 05:49:26 +0000 Subject: [PATCH 1/3] feat: replay scrollback history to boo ui views on attach A boo ui view's scrollback lived only in its local terminal and was built from output streamed while attached. Switching sessions destroys the view (and its scrollback) and creates a fresh one, and the daemon's attach replay (repaint) deliberately sends only the visible screen, so a just-switched-to view had nothing above the current screen to scroll up into. Replay the window's history to ui views on attach: - window.historyReplay: VT bytes that reproduce the scrollback HISTORY (rows above the visible screen) as a styled, scrolling stream, plus a full-screen flush so every history row lands in the canvas's scrollback before the repaint's erase, with no blank gap. Fed before repaint(), a fresh terminal ends up holding the same scrollback, viewport at bottom. - protocol.AttachPayload: the attach handshake now carries a `ui` flag (decodes a bare 4-byte size payload as non-ui for slack). - The daemon sends the history (chunked across output frames, since it can exceed max_payload) before the repaint, but only to ui clients. A plain `boo attach` is raw passthrough, where a history dump would spam the user's real terminal, so it stays history-free. Verified end to end: a ui attach receives scrolled-off history; a plain attach receives only the visible screen. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client.zig | 5 +- src/daemon.zig | 30 +++++++++++- src/protocol.zig | 41 ++++++++++++++++ src/ui.zig | 5 +- src/window.zig | 119 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 4 deletions(-) diff --git a/src/client.zig b/src/client.zig index 40c2f21..4d53c0f 100644 --- a/src/client.zig +++ b/src/client.zig @@ -84,9 +84,12 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { // Handshake with our current size. const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80); - try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{ + try protocol.writeMsg(sock, .attach, &(protocol.AttachPayload{ .rows = ws.row, .cols = ws.col, + // A plain attach is raw passthrough; replaying scrollback here + // would dump the buffer onto the user's real terminal. + .ui = false, }).encode()); var decoder: protocol.Decoder = .init(alloc); diff --git a/src/daemon.zig b/src/daemon.zig index 61a5760..82a7cb0 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -32,6 +32,9 @@ const Conn = struct { fd: posix.fd_t, decoder: protocol.Decoder, attached: bool = false, + /// A ui view (vs a plain attach): gets its scrollback history + /// replayed on attach so a wheel-up can page it. + ui: bool = false, closed: bool = false, fn send(self: *Conn, msg_type: protocol.MsgType, payload: []const u8) void { @@ -244,7 +247,7 @@ pub const Daemon = struct { fn handleMsg(self: *Daemon, conn: *Conn, msg: protocol.Msg) !void { switch (msg.type) { .attach => { - const size = try protocol.SizePayload.decode(msg.payload); + const a = try protocol.AttachPayload.decode(msg.payload); // Steal from any previously attached client. for (self.conns.items) |other| { if (other != conn and other.attached) { @@ -253,9 +256,15 @@ pub const Daemon = struct { } } conn.attached = true; + conn.ui = a.ui; self.key_parser = .{}; - self.resizeWindow(size.rows, size.cols); + self.resizeWindow(a.rows, a.cols); self.updatePassthrough(); + // A ui view starts with an empty terminal; seed its + // scrollback with the window's history (sized to the + // client) before the repaint puts the live screen at + // the bottom. Best effort: failure just means no history. + if (conn.ui) self.historyTo(conn) catch {}; try self.repaintTo(conn); }, @@ -599,6 +608,23 @@ pub const Daemon = struct { } } + /// Send the window's scrollback history to a ui view as a stream of + /// `output` frames, to be fed before the repaint. The replay can be + /// larger than one frame, so it is split across messages; the client + /// feeds them in order into its terminal, so an escape sequence split + /// across the boundary still parses. + fn historyTo(self: *Daemon, conn: *Conn) !void { + const win = self.liveWindow() orelse return; + const bytes = (try win.historyReplay(self.alloc)) orelse return; + defer self.alloc.free(bytes); + var i: usize = 0; + while (i < bytes.len) { + const end = @min(i + protocol.max_payload, bytes.len); + conn.send(.output, bytes[i..end]); + i = end; + } + } + fn repaintTo(self: *Daemon, conn: *Conn) !void { const win = self.liveWindow() orelse return; const bytes = try win.repaint(self.alloc); diff --git a/src/protocol.zig b/src/protocol.zig index acf6280..0d1a39e 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -63,6 +63,33 @@ pub const SizePayload = struct { } }; +/// attach payload: rows, cols (u16 LE each) plus a flag byte. The flag's +/// low bit marks a ui client (`boo ui`), which wants its scrollback +/// history replayed on attach; a plain `boo attach` leaves it zero. A +/// bare 4-byte size payload is also accepted and decodes as non-ui. +pub const AttachPayload = struct { + rows: u16, + cols: u16, + ui: bool = false, + + pub fn encode(self: AttachPayload) [5]u8 { + var buf: [5]u8 = undefined; + std.mem.writeInt(u16, buf[0..2], self.rows, .little); + std.mem.writeInt(u16, buf[2..4], self.cols, .little); + buf[4] = @intFromBool(self.ui); + return buf; + } + + pub fn decode(payload: []const u8) error{InvalidPayload}!AttachPayload { + if (payload.len != 4 and payload.len != 5) return error.InvalidPayload; + return .{ + .rows = std.mem.readInt(u16, payload[0..2], .little), + .cols = std.mem.readInt(u16, payload[2..4], .little), + .ui = payload.len == 5 and payload[4] != 0, + }; + } +}; + /// Write a full frame to a fd. Handles short writes. pub fn writeMsg(fd: std.posix.fd_t, msg_type: MsgType, payload: []const u8) !void { std.debug.assert(payload.len <= max_payload); @@ -153,6 +180,20 @@ test "size payload roundtrip" { try std.testing.expectError(error.InvalidPayload, SizePayload.decode("abc")); } +test "attach payload roundtrip carries the ui flag" { + const ui_client: AttachPayload = .{ .rows = 24, .cols = 80, .ui = true }; + try std.testing.expectEqual(ui_client, try AttachPayload.decode(&ui_client.encode())); + const plain: AttachPayload = .{ .rows = 5, .cols = 10, .ui = false }; + try std.testing.expectEqual(plain, try AttachPayload.decode(&plain.encode())); + // A bare 4-byte size payload decodes as a non-ui attach. + const sized = (SizePayload{ .rows = 7, .cols = 9 }).encode(); + try std.testing.expectEqual( + AttachPayload{ .rows = 7, .cols = 9, .ui = false }, + try AttachPayload.decode(&sized), + ); + try std.testing.expectError(error.InvalidPayload, AttachPayload.decode("ab")); +} + test "argv roundtrip" { const alloc = std.testing.allocator; const argv = [_][]const u8{ "stuff", "hello world\n" }; diff --git a/src/ui.zig b/src/ui.zig index 0cd587b..d88899c 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -728,9 +728,12 @@ pub const View = struct { self.stream = .initAlloc(alloc, handler); errdefer self.stream.deinit(); - try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{ + try protocol.writeMsg(sock, .attach, &(protocol.AttachPayload{ .rows = @max(rows, 1), .cols = @max(cols, 1), + // A ui view renders from terminal state, so it can take a + // scrollback-history replay and page it on a wheel-up. + .ui = true, }).encode()); return self; diff --git a/src/window.zig b/src/window.zig index 59ed0e4..e5496c5 100644 --- a/src/window.zig +++ b/src/window.zig @@ -280,6 +280,46 @@ pub const Window = struct { return alloc.dupe(u8, out.writer.buffered()); } + /// VT bytes that reproduce this window's scrollback HISTORY (the rows + /// above the visible screen) as a styled, scrolling stream. A fresh + /// ui-view terminal that feeds historyReplay() and then repaint() ends + /// up holding the same scrollback, so a wheel-up pages real history + /// instead of an empty buffer. Returns null when there is no history. + /// + /// Only ui views request this. A plain `boo attach` is raw passthrough + /// to the user's real terminal, where replaying history would dump the + /// whole buffer onto their screen. + pub fn historyReplay(self: *Window, alloc: std.mem.Allocator) !?[]u8 { + const pages = &self.term.screens.active.pages; + const br = pages.getBottomRight(.history) orelse return null; // no history + const tl = pages.getTopLeft(.history); + const sel = vt.Selection.init(tl, br, false); + + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + + // Content only (per-cell SGR is part of content emission); no + // cursor, modes, or other terminal state — the repaint that + // follows re-establishes all of that. + var formatter: vt.formatter.TerminalFormatter = .init(&self.term, .{ .emit = .vt }); + formatter.content = .{ .selection = sel }; + formatter.extra = .none; + try out.writer.print("{f}", .{formatter}); + + // The history selection reproduces as a stream that fills the + // canvas's visible screen with its last rows; the repaint that + // follows opens with ED (erase display), which would drop those + // still-visible rows. Reset SGR, then scroll a full screen so + // every history row lands in scrollback before the erase. The + // blank rows this leaves are in the visible area, so ED discards + // them rather than committing them to scrollback. + try out.writer.writeAll("\x1b[0m"); + for (0..self.term.rows) |_| try out.writer.writeAll("\r\n"); + + const bytes = try alloc.dupe(u8, out.writer.buffered()); + return bytes; + } + /// Selection spanning the visible screen of the active terminal /// screen, so a repaint excludes scrollback history. Null (the /// formatter's dump-everything default) only if the pins cannot @@ -434,6 +474,85 @@ test "repaint reproduces the screen once output has scrolled" { try std.testing.expectEqual(want_cursor.x, got_cursor.x); } +test "historyReplay reconstructs scrollback for a ui view canvas" { + // A switched-to ui view starts with an empty terminal; feeding + // historyReplay() then repaint() must leave it holding the same + // scrollback the daemon window has, so a wheel-up pages real history. + const alloc = std.testing.allocator; + + var win: Window = .{ + .alloc = alloc, + .pty_fd = -1, + .child_pid = -1, + .command_title = "test", + .last_output_ms = 0, + .term = try vt.Terminal.init(alloc, .{ + .cols = 20, + .rows = 5, + .max_scrollback = 512 * 1024, + }), + .stream = undefined, + }; + defer win.term.deinit(alloc); + var stream = win.term.vtStream(); + defer stream.deinit(); + + // Ten lines on a five-row screen: five scroll into history, five + // stay visible. The first row carries color, to prove styling + // survives the round trip. + stream.nextSlice("\x1b[31mL1\x1b[0m\r\nL2\r\nL3\r\nL4\r\nL5\r\nL6\r\nL7\r\nL8\r\nL9\r\nL10"); + + const history = (try win.historyReplay(alloc)) orelse return error.TestUnexpectedResult; + defer alloc.free(history); + // The colored history row keeps its SGR. + try std.testing.expect(std.mem.indexOf(u8, history, "\x1b[") != null); + + const repaint = try win.repaint(alloc); + defer alloc.free(repaint); + + // A fresh canvas, standing in for a ui view's terminal. + var canvas = try vt.Terminal.init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 512 * 1024 }); + defer canvas.deinit(alloc); + var canvas_stream = canvas.vtStream(); + defer canvas_stream.deinit(); + canvas_stream.nextSlice(history); + canvas_stream.nextSlice(repaint); + + // Full dump (history + visible) matches: the canvas holds the same + // scrollback as the window, with no blank gap. + const want_full = try win.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(want_full); + const got_full = try canvas.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(got_full); + try std.testing.expectEqualStrings(want_full, got_full); + + // The viewport still sits at the live bottom. + const want_screen = try win.term.plainString(alloc); + defer alloc.free(want_screen); + const got_screen = try canvas.plainString(alloc); + defer alloc.free(got_screen); + try std.testing.expectEqualStrings(want_screen, got_screen); +} + +test "historyReplay returns null without scrollback" { + const alloc = std.testing.allocator; + var win: Window = .{ + .alloc = alloc, + .pty_fd = -1, + .child_pid = -1, + .command_title = "test", + .last_output_ms = 0, + .term = try vt.Terminal.init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 512 * 1024 }), + .stream = undefined, + }; + defer win.term.deinit(alloc); + var stream = win.term.vtStream(); + defer stream.deinit(); + // Two lines, nothing scrolled off: no history. + stream.nextSlice("hello\r\nworld"); + try std.testing.expect((try win.historyReplay(alloc)) == null); +} + test "title set via OSC is tracked and emitted sanitized" { const alloc = std.testing.allocator; From 4e249e3c12f55975fdf87584cec3cffb7f85a611 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 18:42:22 +0000 Subject: [PATCH 2/3] fix: keep boo ui scrollback replay compatible with older daemons The original change widened the attach handshake to a 5-byte AttachPayload (rows, cols, ui flag). An already-running daemon from before that change decodes attach as a strict 4-byte SizePayload and rejects the extra byte, so after upgrading the binary a new `boo ui`/`boo attach` could not attach to a pre-upgrade session (the client reported "lost connection"). Carry the ui-ness out of band instead: - protocol: drop AttachPayload; attach stays the 4-byte SizePayload it has always been. Add a client-to-daemon `ui` message (type 6) that marks the connection as a ui view. - ui: send the `ui` marker right before attaching. An older daemon ignores the unknown message type (its handler has a catch-all) and simply attaches the view with no history, so a new ui degrades gracefully against an old daemon instead of failing. - daemon: set conn.ui from the `ui` message; attach decodes SizePayload again and still replays history to ui views. - client: revert plain attach to the unchanged 4-byte handshake. Add PTY integration tests for both directions of the feature: a ui view attaching to a session that already had scrolled-off history pages it in on a wheel-up, and a plain attach receives only the visible screen. Verified all four upgrade combinations: new/new replays history; new ui against an old daemon attaches with no history (was "lost connection"); old client against a new daemon still attaches. Generated by Coder Agents on behalf of @kylecarbs. --- src/client.zig | 5 +--- src/daemon.zig | 11 ++++++--- src/protocol.zig | 47 +++++------------------------------ src/ui.zig | 11 ++++++--- test/integration.zig | 58 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 53 deletions(-) diff --git a/src/client.zig b/src/client.zig index 4d53c0f..40c2f21 100644 --- a/src/client.zig +++ b/src/client.zig @@ -84,12 +84,9 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { // Handshake with our current size. const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80); - try protocol.writeMsg(sock, .attach, &(protocol.AttachPayload{ + try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{ .rows = ws.row, .cols = ws.col, - // A plain attach is raw passthrough; replaying scrollback here - // would dump the buffer onto the user's real terminal. - .ui = false, }).encode()); var decoder: protocol.Decoder = .init(alloc); diff --git a/src/daemon.zig b/src/daemon.zig index 82a7cb0..6664482 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -246,8 +246,10 @@ pub const Daemon = struct { fn handleMsg(self: *Daemon, conn: *Conn, msg: protocol.Msg) !void { switch (msg.type) { + .ui => conn.ui = true, + .attach => { - const a = try protocol.AttachPayload.decode(msg.payload); + const size = try protocol.SizePayload.decode(msg.payload); // Steal from any previously attached client. for (self.conns.items) |other| { if (other != conn and other.attached) { @@ -256,14 +258,15 @@ pub const Daemon = struct { } } conn.attached = true; - conn.ui = a.ui; self.key_parser = .{}; - self.resizeWindow(a.rows, a.cols); + self.resizeWindow(size.rows, size.cols); self.updatePassthrough(); // A ui view starts with an empty terminal; seed its // scrollback with the window's history (sized to the // client) before the repaint puts the live screen at - // the bottom. Best effort: failure just means no history. + // the bottom. conn.ui is set by the `.ui` marker the + // client sends just before attaching. Best effort: + // failure just means no history. if (conn.ui) self.historyTo(conn) catch {}; try self.repaintTo(conn); }, diff --git a/src/protocol.zig b/src/protocol.zig index 0d1a39e..f618c35 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -22,6 +22,12 @@ pub const MsgType = enum(u8) { resize = 3, detach_req = 4, command = 5, + /// Marks this connection as a `boo ui` view. Sent right before the + /// attach so the daemon replays scrollback history on attach. A + /// daemon from before this message existed ignores the unknown type + /// and simply attaches the view with no history, so a new ui client + /// stays compatible with an already-running older daemon. + ui = 6, // Daemon to client. output = 64, @@ -63,33 +69,6 @@ pub const SizePayload = struct { } }; -/// attach payload: rows, cols (u16 LE each) plus a flag byte. The flag's -/// low bit marks a ui client (`boo ui`), which wants its scrollback -/// history replayed on attach; a plain `boo attach` leaves it zero. A -/// bare 4-byte size payload is also accepted and decodes as non-ui. -pub const AttachPayload = struct { - rows: u16, - cols: u16, - ui: bool = false, - - pub fn encode(self: AttachPayload) [5]u8 { - var buf: [5]u8 = undefined; - std.mem.writeInt(u16, buf[0..2], self.rows, .little); - std.mem.writeInt(u16, buf[2..4], self.cols, .little); - buf[4] = @intFromBool(self.ui); - return buf; - } - - pub fn decode(payload: []const u8) error{InvalidPayload}!AttachPayload { - if (payload.len != 4 and payload.len != 5) return error.InvalidPayload; - return .{ - .rows = std.mem.readInt(u16, payload[0..2], .little), - .cols = std.mem.readInt(u16, payload[2..4], .little), - .ui = payload.len == 5 and payload[4] != 0, - }; - } -}; - /// Write a full frame to a fd. Handles short writes. pub fn writeMsg(fd: std.posix.fd_t, msg_type: MsgType, payload: []const u8) !void { std.debug.assert(payload.len <= max_payload); @@ -180,20 +159,6 @@ test "size payload roundtrip" { try std.testing.expectError(error.InvalidPayload, SizePayload.decode("abc")); } -test "attach payload roundtrip carries the ui flag" { - const ui_client: AttachPayload = .{ .rows = 24, .cols = 80, .ui = true }; - try std.testing.expectEqual(ui_client, try AttachPayload.decode(&ui_client.encode())); - const plain: AttachPayload = .{ .rows = 5, .cols = 10, .ui = false }; - try std.testing.expectEqual(plain, try AttachPayload.decode(&plain.encode())); - // A bare 4-byte size payload decodes as a non-ui attach. - const sized = (SizePayload{ .rows = 7, .cols = 9 }).encode(); - try std.testing.expectEqual( - AttachPayload{ .rows = 7, .cols = 9, .ui = false }, - try AttachPayload.decode(&sized), - ); - try std.testing.expectError(error.InvalidPayload, AttachPayload.decode("ab")); -} - test "argv roundtrip" { const alloc = std.testing.allocator; const argv = [_][]const u8{ "stuff", "hello world\n" }; diff --git a/src/ui.zig b/src/ui.zig index d88899c..522d07a 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -728,12 +728,15 @@ pub const View = struct { self.stream = .initAlloc(alloc, handler); errdefer self.stream.deinit(); - try protocol.writeMsg(sock, .attach, &(protocol.AttachPayload{ + // Mark this connection as a ui view before attaching, so the + // daemon replays scrollback history on attach. A ui view renders + // from terminal state, so it can take the replay and page it on + // a wheel-up. An older daemon ignores the unknown `.ui` message + // and just attaches with no history. + try protocol.writeMsg(sock, .ui, ""); + try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{ .rows = @max(rows, 1), .cols = @max(cols, 1), - // A ui view renders from terminal state, so it can take a - // scrollback-history replay and page it on a wheel-up. - .ui = true, }).encode()); return self; diff --git a/test/integration.zig b/test/integration.zig index 31a2bf4..a952ded 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -2628,3 +2628,61 @@ test "ui without a tty fails cleanly" { try std.testing.expect(result.term == .Exited and result.term.Exited == 1); try std.testing.expect(std.mem.indexOf(u8, result.stderr, "requires a terminal") != null); } + +test "ui: attaching replays a session's pre-existing scrollback history" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // Seed far more output than the screen is tall, so the earliest + // lines scroll into the daemon window's history BEFORE any ui + // attaches. A fresh ui view's terminal starts empty and cannot + // rebuild this from the live screen, so the daemon must replay the + // history on attach for a wheel-up to page it. + try h.startDetached("hist", &.{"sh"}); + try h.sendLine("hist", "i=1; while [ $i -le 60 ]; do printf 'HIST-%03d\\n' $i; i=$((i+1)); done"); + const seeded = try h.waitPeekContains("hist", "HIST-060"); + alloc.free(seeded); + + // The ui attaches after the history already exists and focuses the + // only session; its repaint reproduces the live screen (HIST-060). + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("HIST-060"); + + // HIST-001 scrolled off the live screen, so it renders only if the + // daemon replayed history into the view. Wheel up over the viewport + // pages it in; over-scrolling clamps at the top. + ui.clearOutput(); + for (0..40) |_| try ui.send("\x1b[<64;50;10M"); + try ui.waitFor(" scrollback"); + try ui.waitFor("HIST-001"); +} + +test "plain attach replays the visible screen only, never scrollback history" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // Same seeding: HIST-001 scrolls off the live screen. A plain attach + // is raw passthrough to the user's real terminal, where a history + // dump would spam their screen, so it must receive only the visible + // screen, never the scrolled-off history a ui view gets. + try h.startDetached("plain", &.{"sh"}); + try h.sendLine("plain", "i=1; while [ $i -le 60 ]; do printf 'HIST-%03d\\n' $i; i=$((i+1)); done"); + const seeded = try h.waitPeekContains("plain", "HIST-060"); + alloc.free(seeded); + + var client = try PtyClient.spawn(&h, &.{ "attach", "plain" }, 24, 80); + defer client.deinit(); + // The repaint reproduces the visible screen, ending at HIST-060. + try client.waitFor("HIST-060"); + // The daemon sends any history before the repaint, so if it had been + // (wrongly) sent it would already be here alongside HIST-060. The + // off-screen line must be absent. + try std.testing.expect(std.mem.indexOf(u8, client.output.items, "HIST-001") == null); + + try client.send("\x01d"); + try client.waitFor("detached from plain"); + _ = try client.waitExit(); +} From af75c7be88b097f6776ce00e6f2cf5384c76bff8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 19:10:56 +0000 Subject: [PATCH 3/3] fix: hold the boo ui viewport blank until the attach repaint completes A freshly focused ui view is seeded by the daemon with a scrollback history replay followed by a repaint. The ui renders on a ~15ms timer, so for a history large enough to span multiple output frames it could paint a half-applied frame, briefly flashing partial scrollback before the repaint snapped the viewport to the live screen. Hold the viewport blank from attach until the repaint's terminating `.screen` message arrives, then reveal it cleanly. The sidebar and status keep rendering during the load so the switch stays responsive, and the cursor stays hidden while the viewport is blank. Non-live views render their placard as before. Reveal incrementally rather than with a full repaint. The held frames clear libghostty's dirty bits while the viewport rows are painted blank, so the reveal invalidates the viewport cache to re-serialize the live rows instead of reusing stale bytes, but leaves the row-level diff in place so only rows that actually changed are written, exactly as a plain attach's first repaint does. A full-screen rewrite here would emit every row at once, and a ui whose terminal is momentarily undrained blocks on that larger write (the same Darwin PTY buffering restoreTty works around) and wedges the loop before it reads the next key or SIGWINCH. Generated by Coder Agents on behalf of @kylecarbs. --- src/ui.zig | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/ui.zig b/src/ui.zig index 522d07a..9a645c6 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1199,6 +1199,12 @@ const Ui = struct { need_render: bool = true, /// Force every row out on the next render (resize, C-a l). full_render: bool = true, + /// A freshly focused view is seeded by the daemon with a scrollback + /// history replay followed by a repaint that ends in a `.screen` + /// message. Until that marker arrives the viewport is painted blank + /// rather than from the half-applied stream, so a large history + /// replay never flashes partial scrollback before the live screen. + awaiting_attach_repaint: bool = false, last_render_ms: i64 = 0, next_refresh_ms: i64 = 0, @@ -2023,6 +2029,30 @@ const Ui = struct { }, .screen => { v.app_alt = std.mem.eql(u8, msg.payload, "alt"); + // The attach replay (history, then repaint) ends + // with this marker; release the hold and reveal the + // live screen. The seeded viewport rows streamed in + // while they were painted blank, and composeFrame + // clears libghostty's dirty bits every one of those + // frames, so the rows are no longer flagged dirty: + // invalidate the viewport cache so the reveal + // re-serializes them from the now-complete screen + // rather than reusing stale bytes. This stops short + // of a full repaint on purpose. The row-level diff + // still skips rows whose bytes are unchanged (the + // sidebar, blank gaps), so the reveal writes only the + // live rows, exactly as a plain attach's first + // repaint does. A full-screen rewrite here would + // instead emit every row at once, and a ui whose + // terminal is momentarily undrained blocks on that + // larger write (macOS PTY buffering, see restoreTty) + // and wedges the loop before it reads the next key + // or SIGWINCH. + if (self.awaiting_attach_repaint) { + self.awaiting_attach_repaint = false; + for (self.viewport_cache.items) |*row| row.valid = false; + self.need_render = true; + } }, .exit => { v.state = .ended; @@ -2226,6 +2256,9 @@ const Ui = struct { self.view_gen += 1; self.full_render = true; self.need_render = true; + // The daemon streams this view's history then a repaint; hold + // the viewport blank until that repaint's `.screen` arrives. + self.awaiting_attach_repaint = true; } fn rememberLast(self: *Ui, idx: usize) void { @@ -2798,6 +2831,9 @@ const Ui = struct { if (self.renameCursor()) |s| return s; if (self.gotoCursor()) |s| return s; const v = self.liveView() orelse return state; + // The viewport is blank while the view is being seeded; keep the + // cursor hidden until the attach repaint releases it. + if (self.awaiting_attach_repaint) return state; // While scrolled back the cursor coordinates belong to the // bottom of the screen, not the history rows on display, so // keep the cursor hidden until the viewport snaps back. @@ -3032,7 +3068,11 @@ const Ui = struct { }, } - if (y < v.term.rows) { + // While the view is still being seeded (history replay and + // repaint in flight) the viewport stays blank, so a large + // replay never flashes partial scrollback before snapping to + // the live screen; the repaint's `.screen` releases it. + if (!self.awaiting_attach_repaint and y < v.term.rows) { try self.appendViewportRow(v, y, out); } try out.appendSlice(alloc, sgr_reset);