diff --git a/src/daemon.zig b/src/daemon.zig index 61a5760..6664482 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 { @@ -243,6 +246,8 @@ pub const Daemon = struct { fn handleMsg(self: *Daemon, conn: *Conn, msg: protocol.Msg) !void { switch (msg.type) { + .ui => conn.ui = true, + .attach => { const size = try protocol.SizePayload.decode(msg.payload); // Steal from any previously attached client. @@ -256,6 +261,13 @@ pub const Daemon = struct { self.key_parser = .{}; 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. 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); }, @@ -599,6 +611,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..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, diff --git a/src/ui.zig b/src/ui.zig index 0cd587b..9a645c6 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -728,6 +728,12 @@ pub const View = struct { self.stream = .initAlloc(alloc, handler); errdefer self.stream.deinit(); + // 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), @@ -1193,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, @@ -2017,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; @@ -2220,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 { @@ -2792,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. @@ -3026,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); 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; 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(); +}