From b61cf2bbb1dee433e5db889d79e0a9ccbecdb629 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 11 Jun 2026 05:21:05 +0000 Subject: [PATCH] feat: scroll primary-screen sessions with the mouse wheel in boo ui The ui dropped wheel events over the viewport unless the application had asked for mouse reporting. Now the view terminal keeps 512KB of scrollback and the wheel pages through it: scrolling pins the viewport, hides the session cursor, and shows a bottom-row hint; wheeling back down or a lone Esc returns to live output, and typed input snaps back first so it lands where the user can see it. The daemon's passthrough strips alternate-screen toggles, so clients cannot tell which screen an application is on. A new .screen protocol message, sent with every repaint (attach, redraw, screen switches), carries that state; full-screen applications get three arrow keys per wheel tick instead, matching terminals' alternate-scroll mode. --- README.md | 3 +- src/daemon.zig | 4 ++ src/help.zig | 4 ++ src/protocol.zig | 5 ++ src/ui.zig | 115 ++++++++++++++++++++++++++++++++++++++----- src/window.zig | 7 +++ test/integration.zig | 69 ++++++++++++++++++++++++++ 7 files changed, 195 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f1f0006..13f51f9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ exactly as a human would see it. - A full-screen session manager: `boo ui` lists sessions in a sidebar with their titles, and renders the focused one next to it. Click to switch, create, kill, or rename sessions; drag to select and copy - text (OSC 52); everything also works from the keyboard. + text (OSC 52); scroll the wheel to page through a session's history; + everything also works from the keyboard. - One command per session, named after your current directory by default. Sessions are cheap; run one per task. - Faithful redraws from libghostty terminal state, including SGR styles, diff --git a/src/daemon.zig b/src/daemon.zig index 3a0ba87..540f32c 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -571,6 +571,10 @@ pub const Daemon = struct { // from a clean slate. win.alt_filter.reset(); conn.send(.output, bytes); + // Repaints accompany every screen identity change (attach, + // redraw, alt-screen switches), so this keeps the client's + // picture of the application's screen current. + conn.send(.screen, if (win.onAltScreen()) "alt" else "primary"); } fn detachConn(self: *Daemon, conn: *Conn, reason: []const u8) void { diff --git a/src/help.zig b/src/help.zig index fca4ae0..6c2ba1f 100644 --- a/src/help.zig +++ b/src/help.zig @@ -117,6 +117,10 @@ pub const commands = [_]Entry{ \\ click its 'x' kill it (asks for confirmation) \\ click + new session start a session running $SHELL \\ scroll the sidebar scroll the session list + \\ wheel in viewport scroll the session's history; wheel + \\ back down or press esc to return to + \\ live output (full-screen applications + \\ receive arrow keys instead) \\ in the viewport forwarded to the application when it \\ asked for mouse reporting; otherwise \\ dragging selects text and copies it on diff --git a/src/protocol.zig b/src/protocol.zig index 8d3d0bd..acf6280 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -29,6 +29,11 @@ pub const MsgType = enum(u8) { exit = 66, ok = 67, err = 68, + /// Which screen the application is on ("alt" or "primary"), + /// sent alongside every repaint. The passthrough strips screen + /// toggles, so clients cannot tell on their own; the ui uses it + /// to decide what a wheel over the viewport should do. + screen = 69, _, }; diff --git a/src/ui.zig b/src/ui.zig index 1e5e33b..15dcf12 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -43,6 +43,9 @@ const render_interval_ms: i64 = 15; /// after this long without a follow-up byte. Escape sequences arrive /// as one chunk, so only a human pressing the ESC key waits this long. const esc_flush_ms: i64 = 50; +/// Rows per mouse wheel tick, both for paging local scrollback and +/// for the arrow keys sent to alternate-screen applications. +const wheel_lines = 3; // -- Layout ----------------------------------------------------------------- @@ -387,6 +390,10 @@ pub const View = struct { title_changed: bool = false, /// The application rang the bell; the UI forwards it. bell: bool = false, + /// The application is on the alternate screen, per the daemon's + /// `.screen` messages. Decides whether a wheel over the viewport + /// pages local scrollback or sends arrow keys. + app_alt: bool = false, pub const State = enum { live, ended, stolen, lost }; pub const Stream = vt.TerminalStream; @@ -415,7 +422,10 @@ pub const View = struct { self.term = try vt.Terminal.init(alloc, .{ .cols = @max(cols, 1), .rows = @max(rows, 1), - .max_scrollback = 0, + // Output that scrolls off while attached accumulates + // here; the wheel pages through it for primary-screen + // applications. + .max_scrollback = 512 * 1024, }); errdefer self.term.deinit(alloc); @@ -973,7 +983,9 @@ const Ui = struct { .forward => |bytes| { if (self.resizeConsumes(bytes)) return; if (self.browseConsumes(bytes)) return; + if (self.scrollConsumes(bytes)) return; const v = self.liveView() orelse return; + self.snapViewBottom(); v.sendInput(bytes) catch self.markViewLost(); }, .prefix => |byte| { @@ -999,6 +1011,7 @@ const Ui = struct { self.need_render = true; } const v = self.liveView() orelse return; + self.snapViewBottom(); v.sendInput(if (a.dir == .left) "\x1b[D" else "\x1b[C") catch self.markViewLost(); }, @@ -1014,6 +1027,7 @@ const Ui = struct { return; } const v = self.liveView() orelse return; + self.snapViewBottom(); v.sendInput(if (a.dir == .up) "\x1b[A" else "\x1b[B") catch self.markViewLost(); }, @@ -1027,6 +1041,7 @@ const Ui = struct { .paste => |begin| { const v = self.liveView() orelse return; if (!v.term.modes.get(.bracketed_paste)) return; + self.snapViewBottom(); const marker: []const u8 = if (begin) "\x1b[200~" else "\x1b[201~"; v.sendInput(marker) catch self.markViewLost(); }, @@ -1194,7 +1209,7 @@ const Ui = struct { if (m.isWheel() and !m.release) { switch (self.layout.hit(x, y)) { - .viewport => return self.forwardMouse(m), + .viewport => return self.wheelViewport(m), else => { // Wheel over the sidebar scrolls the session list. const down = m.code & 1 != 0; @@ -1242,6 +1257,72 @@ const Ui = struct { } } + /// Wheel over the viewport. Applications that asked for mouse + /// reporting get the event. Alternate-screen applications get + /// arrow keys per tick, like terminals' alternate-scroll mode, + /// so pagers scroll without mouse support. Otherwise the wheel + /// pages the view's local scrollback. + fn wheelViewport(self: *Ui, m: Mouse) !void { + const v = self.liveView() orelse return; + if (v.term.flags.mouse_event != .none) return self.forwardMouse(m); + const down = m.code & 1 != 0; + if (v.app_alt) { + const seq: []const u8 = if (v.term.modes.get(.cursor_keys)) + (if (down) "\x1bOB" else "\x1bOA") + else + (if (down) "\x1b[B" else "\x1b[A"); + for (0..wheel_lines) |_| { + v.sendInput(seq) catch return self.markViewLost(); + } + return; + } + self.scrollView(if (down) wheel_lines else -@as(isize, wheel_lines)); + } + + /// Page the focused view's scrollback by delta rows (up is + /// negative). A scrolled viewport pins to its content, so + /// streaming output does not move it; the bottom row hints how + /// to get back. + fn scrollView(self: *Ui, delta: isize) void { + const v = self.liveView() orelse return; + if (!self.viewScrolled()) { + // The scrollback hint renders on the bottom row; a stale + // transient message would cover it up. + self.message.clearRetainingCapacity(); + self.message_deadline = 0; + } + v.term.scrollViewport(.{ .delta = delta }); + self.full_render = true; + self.need_render = true; + } + + /// Whether the focused view's viewport is scrolled into history. + fn viewScrolled(self: *Ui) bool { + const v = self.view orelse return false; + if (v.state != .live) return false; + return !v.term.screens.active.viewportIsBottom(); + } + + /// Return the viewport to the live bottom, so input lands where + /// the user can see it. + fn snapViewBottom(self: *Ui) void { + if (!self.viewScrolled()) return; + if (self.view) |v| v.term.scrollViewport(.{ .bottom = {} }); + self.full_render = true; + self.need_render = true; + } + + /// A lone Esc while the view is scrolled returns it to the + /// bottom instead of reaching the application. + fn scrollConsumes(self: *Ui, bytes: []const u8) bool { + if (!self.viewScrolled()) return false; + if (bytes.len == 1 and bytes[0] == 0x1b) { + self.snapViewBottom(); + return true; + } + return false; + } + /// Track press state and forward the event to the application /// when it asked for mouse reporting, with coordinates translated /// into viewport space. @@ -1350,8 +1431,8 @@ const Ui = struct { if (e.y < s.y or (e.y == s.y and e.x < s.x)) std.mem.swap(CellPos, &s, &e); const screen = v.term.screens.active; - const start = screen.pages.pin(.{ .active = .{ .x = s.x, .y = s.y } }) orelse return; - const end = screen.pages.pin(.{ .active = .{ .x = e.x, .y = e.y } }) orelse return; + const start = screen.pages.pin(.{ .viewport = .{ .x = s.x, .y = s.y } }) orelse return; + const end = screen.pages.pin(.{ .viewport = .{ .x = e.x, .y = e.y } }) orelse return; var formatter: vt.formatter.ScreenFormatter = .init(screen, .plain); formatter.content = .{ .selection = vt.Selection.init(start, end, false) }; @@ -1420,6 +1501,9 @@ const Ui = struct { self.setMessage("session attached elsewhere", .{}); self.need_render = true; }, + .screen => { + v.app_alt = std.mem.eql(u8, msg.payload, "alt"); + }, .exit => { v.state = .ended; ended = true; @@ -2137,6 +2221,10 @@ const Ui = struct { if (self.renameCursor()) |s| return s; if (self.searchCursor()) |s| return s; const v = self.liveView() orelse 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. + if (self.viewScrolled()) return state; const cursor = &v.term.screens.active.cursor; const row: usize = @min(cursor.y, self.layout.viewportRows() -| 1); const col: usize = @min( @@ -2193,12 +2281,13 @@ const Ui = struct { } /// Whether the bottom-row status overlay has content to show: an - /// open prompt, the armed-prefix keybind list, an active browse - /// or resize, or a live message. + /// open prompt, the armed-prefix keybind list, an active browse, + /// resize, or scrollback, or a live message. fn statusActive(self: *Ui) bool { return self.rename_input != null or self.search_input != null or self.confirm_kill != null or self.parser.pending_prefix or - self.browsing or self.resizing or self.message.items.len > 0; + self.browsing or self.resizing or self.viewScrolled() or + self.message.items.len > 0; } /// One full screen row: sidebar columns, separator, then the @@ -2258,6 +2347,8 @@ const Ui = struct { try text.appendSlice(alloc, " left/right resize enter done esc cancel"); } else if (self.browsing) { try text.appendSlice(alloc, " up/down select enter attach esc cancel"); + } else if (self.viewScrolled()) { + try text.appendSlice(alloc, " scrollback wheel down or esc to return"); } try appendClipped(alloc, out, text.items, w); try out.appendSlice(alloc, sgr_reset); @@ -2436,8 +2527,10 @@ pub fn appendTermRow( ) !void { const screen = term.screens.active; if (term.cols == 0) return; - const start = screen.pages.pin(.{ .active = .{ .x = 0, .y = y } }) orelse return; - const end = screen.pages.pin(.{ .active = .{ .x = term.cols - 1, .y = y } }) orelse return; + // Viewport pins follow scrollback paging; at the bottom the + // viewport and the active screen are the same rows. + const start = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }) orelse return; + const end = screen.pages.pin(.{ .viewport = .{ .x = term.cols - 1, .y = y } }) orelse return; var formatter: vt.formatter.ScreenFormatter = .init(screen, .vt); formatter.content = .{ .selection = vt.Selection.init(start, end, true) }; @@ -2467,8 +2560,8 @@ fn appendPlainSpan( out: *std.ArrayList(u8), ) !void { const screen = term.screens.active; - const start = screen.pages.pin(.{ .active = .{ .x = x0, .y = y } }) orelse return; - const end = screen.pages.pin(.{ .active = .{ .x = x1, .y = y } }) orelse return; + const start = screen.pages.pin(.{ .viewport = .{ .x = x0, .y = y } }) orelse return; + const end = screen.pages.pin(.{ .viewport = .{ .x = x1, .y = y } }) orelse return; var formatter: vt.formatter.ScreenFormatter = .init(screen, .plain); formatter.content = .{ .selection = vt.Selection.init(start, end, false) }; diff --git a/src/window.zig b/src/window.zig index 16a0999..cfdd2da 100644 --- a/src/window.zig +++ b/src/window.zig @@ -205,6 +205,13 @@ pub const Window = struct { return self.term.screens.active.kitty_keyboard.current().int() != 0; } + /// Whether the application is on the alternate screen. The + /// passthrough strips screen toggles, so clients cannot tell + /// from the byte stream. + pub fn onAltScreen(self: *Window) bool { + return self.term.screens.active_key == .alternate; + } + /// Plain-text dump of the screen, for peek. pub fn plainScreen(self: *Window, alloc: std.mem.Allocator) ![]const u8 { return self.term.plainString(alloc); diff --git a/test/integration.zig b/test/integration.zig index 10b6545..9c24a97 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1844,6 +1844,75 @@ fn waitPeekSize(h: *Harness, name: []const u8, rows: u16, cols: u16) !void { } } +test "ui: wheel scrolls primary-screen scrollback" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("scrolly", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("+ new session"); + + // Stream enough lines through cat that the earliest ones scroll + // off the 24-row screen into the view's scrollback. + var n: usize = 1; + while (n <= 40) : (n += 1) { + var buf: [16]u8 = undefined; + const line = try std.fmt.bufPrint(&buf, "SCROLL-{d:0>3}", .{n}); + try h.sendLine("scrolly", line); + } + try ui.waitFor("SCROLL-040"); + + // cat never asked for mouse reporting and stays on the primary + // screen, so wheel-up over the viewport pages local scrollback. + // Over-scrolling clamps at the top, which puts the first line on + // screen regardless of exact row math. + ui.clearOutput(); + for (0..35) |_| try ui.send("\x1b[<64;50;10M"); + try ui.waitFor(" scrollback"); + try ui.waitFor("SCROLL-001"); + + // A lone Esc snaps the viewport back to the live bottom. + ui.clearOutput(); + try ui.send("\x1b"); + try ui.waitFor("SCROLL-040"); +} + +test "ui: wheel sends arrows to alternate-screen applications" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // The application switches to the alternate screen before the UI + // attaches; the painted marker proves the switch landed. cat -v + // makes the arrow bytes visible in peek. + try h.startDetached("alty", &.{ + "bash", "-c", "printf '\\033[?1049hALTREADY'; exec cat -v", + }); + const seeded = try h.waitPeekContains("alty", "ALTREADY"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("ALTREADY"); + + // Round-trip a typed marker before wheeling: its echo rendering + // in the viewport proves the attach repaint, and the `.screen` + // message sent with it, has been processed. + try ui.send("READY\r"); + try ui.waitFor("READY"); + + // One wheel-up tick over the viewport turns into arrow keys for + // the application instead of paging local scrollback. The tty is + // canonical, so Enter flushes the buffered arrows through cat -v. + try ui.send("\x1b[<64;50;10M"); + try ui.send("\r"); + const peeked = try h.waitPeekContains("alty", "^[[A^[[A^[[A"); + alloc.free(peeked); +} + test "ui: session titles render in the sidebar" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);