diff --git a/src/client.zig b/src/client.zig index e69f440..36787dc 100644 --- a/src/client.zig +++ b/src/client.zig @@ -83,9 +83,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 dc4ca0b..a4ca05c 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -681,9 +681,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;