Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 28 additions & 2 deletions src/daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
},

Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions src/protocol.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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" };
Expand Down
5 changes: 4 additions & 1 deletion src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
119 changes: 119 additions & 0 deletions src/window.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down