From 3eb85d8b5cb77046aea398cd0d22977f0fc52aa0 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 15 Jun 2026 18:39:00 +0000 Subject: [PATCH] fix: repaint boo ui after a session resets the terminal (RIS) In `boo ui` the wheel over the viewport pages local scrollback only when the focused session is on the primary screen and has not asked for mouse reporting. The daemon strips alternate-screen toggles from passthrough and tells the client which screen the application is on out of band, via a `.screen` message sent only when its filter detects a 47/1047/1049 toggle (plus on attach and redraw). A full reset (RIS, `ESC c`) returns the terminal to the primary screen without such a toggle, so the filter never reported a switch and the client's `app_alt` stayed stuck true. The wheel then sent arrow keys forever instead of scrolling, and only re-attaching (exit and re-enter `boo ui`, or a C-a l redraw) recovered it. Repaint whenever the active screen changes, even when the alt-screen filter saw no toggle, so a fresh repaint and `.screen` reach the client. Generated by Coder Agents on behalf of @kylecarbs. --- src/daemon.zig | 12 ++++++++++-- test/integration.zig | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/daemon.zig b/src/daemon.zig index 191d35b..61a5760 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -505,6 +505,11 @@ pub const Daemon = struct { // repainted from terminal state. var out_buf: [32 * 1024 + 32]u8 = undefined; var writer = std.Io.Writer.fixed(&out_buf); + // The active screen before this chunk. A switch the filter does + // not see (a full reset, RIS, returns to the primary screen + // without a 47/1047/1049 toggle) still has to repaint so the + // client's `.screen` state stays authoritative. + const was_alt = win.onAltScreen(); const result = win.alt_filter.feed(chunk, &writer) catch altscreen.Filter.Result{ .switched = true, .discard_start = 0 }; @@ -518,9 +523,12 @@ pub const Daemon = struct { const filtered = writer.buffered(); if (filtered.len > 0) conn.send(.output, filtered); - if (result.switched) { + // Repaint when the filter stripped a toggle, or when the active + // screen changed by a path the filter cannot see (e.g. RIS), so + // a fresh repaint and `.screen` reach the client either way. + if (result.switched or win.onAltScreen() != was_alt) { self.repaintTo(conn) catch |err| { - log.warn("repaint after screen switch failed: {}", .{err}); + log.warn("repaint after screen change failed: {}", .{err}); }; } } diff --git a/test/integration.zig b/test/integration.zig index 42dda1c..5eee342 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -2514,6 +2514,43 @@ test "ui: wheel sends arrows to alternate-screen applications" { alloc.free(peeked); } +test "ui: wheel scrolls again after a session resets the terminal" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("rst", &.{"bash"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("rst"); + + // Enter the alternate screen while attached: the daemon strips the + // toggle, repaints, and sends `.screen` = alt, so the wheel would + // turn into arrow keys for the application. + try h.sendLine("rst", "printf '\\033[?1049hINALT'"); + try ui.waitFor("INALT"); + + // A full reset (RIS, ESC c) returns the terminal to the primary + // screen without a 47/1047/1049 toggle, so the alt-screen filter + // never sees a switch. The daemon must still repaint and send + // `.screen` = primary, or the client keeps treating the session as + // alternate-screen and the wheel never pages local scrollback. + try h.sendLine("rst", "printf '\\033c'"); + try h.sendLine("rst", "echo POSTRIS"); + try ui.waitFor("POSTRIS"); + + // Fill the view's scrollback on the primary screen. + try h.sendLine("rst", "i=1; while [ $i -le 60 ]; do echo SCROLL-$i; i=$((i+1)); done"); + try ui.waitFor("SCROLL-60"); + + // Wheel up pages local scrollback instead of sending arrows; the + // hint only renders while the viewport is scrolled off the bottom. + ui.clearOutput(); + for (0..35) |_| try ui.send("\x1b[<64;50;10M"); + try ui.waitFor(" scrollback"); +} + test "ui: session titles render in the sidebar" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);