From f8a4cd345685d531a37d7e6d2f0321cdff9fcc2f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 11 Jun 2026 05:53:43 +0000 Subject: [PATCH] fix: focus the next session or show the splash when one closes When the focused session ended and nothing was free to attach, the ui destroyed the dead view and parked on a 'no session focused' placard, which was permanent when the only sessions left were held by other clients or hosted this ui. Now the refresh falls back to selecting (without attaching) the most recently active session, the same fallback startup uses, so a held session gets the 'attached elsewhere' hint instead. When nothing is selectable at all, the viewport shows the splash, and the placard branch is gone. --- src/ui.zig | 83 +++++++++++++++++++++++++++++++++----------- test/integration.zig | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/ui.zig b/src/ui.zig index 15dcf12..b2c19a9 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1599,14 +1599,23 @@ const Ui = struct { } else if (self.autoFocusable()) |i| { self.selected = i; self.attachSelected(); - } else if (self.view) |v| { - // No automatic candidate. A live view keeps running, but a - // dead one makes room for the empty state. - if (v.state != .live) { - v.destroy(); - self.view = null; - if (self.view_name) |n| self.alloc.free(n); - self.view_name = null; + } else { + // No automatic candidate: every other session is held by + // some client or hosts this UI. A dead view makes room for + // the empty state, and selecting (without attaching) the + // most recent session keeps a focus target around, the + // same fallback startup uses. + if (self.view) |v| { + if (v.state != .live) { + v.destroy(); + self.view = null; + if (self.view_name) |n| self.alloc.free(n); + self.view_name = null; + } + } + if (self.view == null and !self.browsing) { + self.selectInitial(); + self.scrollSelectedIntoView(); } } self.clampScroll(); @@ -1652,9 +1661,10 @@ const Ui = struct { return best; } - /// Startup fallback when every session is attached elsewhere: - /// select the most recently active one without attaching, so the - /// sidebar has a focus target but nothing is stolen. + /// Fallback when every session is attached elsewhere: select the + /// most recently active one without attaching, so the sidebar has + /// a focus target but nothing is stolen. Used at startup and when + /// the focused session goes away. fn selectInitial(self: *Ui) void { var best: ?usize = null; for (self.sessions.items, 0..) |entry, i| { @@ -2405,13 +2415,16 @@ const Ui = struct { try out.appendSlice(alloc, "\x1b[K"); const v = self.view orelse { - if (self.sessions.items.len == 0) { - try self.composeNoSessions(y, out); - } else if (self.selected != null and self.sessions.items[self.selected.?].attached) { - try self.composeEmptyRow(y, "attached elsewhere", "click the session to take it over", out); - } else { - try self.composeEmptyRow(y, "no session focused", "pick a session on the left", out); + if (self.selected) |i| { + if (self.sessions.items[i].attached) { + try self.composeEmptyRow(y, "attached elsewhere", "click the session to take it over", out); + return; + } } + // Nothing is focusable (no sessions, or only this UI's + // host): the splash, rather than a placard with no + // actionable advice. + try self.composeNoSessions(y, out); return; }; @@ -2469,7 +2482,7 @@ const Ui = struct { try out.appendSlice(self.alloc, sgr_reset); } - /// The boo wordmark and its ghost, shown when no sessions exist. + /// The boo wordmark and its ghost, shown when nothing is focused. const ghost_art = [_][]const u8{ " _ .-.", "| |__ ___ ___ (o o)", @@ -2478,8 +2491,9 @@ const Ui = struct { "|_.__/ \\___/ \\___/ `~~~'", }; - /// Empty state for a boo with no sessions at all: the wordmark - /// art centered as a block, then a hint underneath. + /// Empty state when nothing is focusable: no sessions at all, or + /// only ones this UI must not attach on its own. The wordmark art + /// centered as a block, then a hint underneath. fn composeNoSessions(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; const l = self.layout; @@ -2813,6 +2827,35 @@ test "ui: automatic focus skips attached sessions and prefers recent ones" { try std.testing.expectEqual(@as(?usize, null), ui.autoFocusable()); } +test "ui: an empty viewport shows the splash, not a placard" { + const alloc = std.testing.allocator; + var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; + defer ui.sessions.deinit(alloc); + ui.layout = .init(24, 100); + + var host = "host".*; + var no_title: [0]u8 = .{}; + try ui.sessions.append(alloc, .{ .name = &host, .attached = true, .idle_ms = 0, .title = &no_title }); + + // Nothing selected (say, only this UI's host remains): the ghost + // splash renders instead of a "no session focused" placard. + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + for (0..ui.layout.viewportRows()) |y| { + try ui.composeViewportCell(@intCast(y), &out); + } + try std.testing.expect(std.mem.indexOf(u8, out.items, "(o o)") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "no session focused") == null); + + // A selected session held by another client keeps its hint. + ui.selected = 0; + out.clearRetainingCapacity(); + for (0..ui.layout.viewportRows()) |y| { + try ui.composeViewportCell(@intCast(y), &out); + } + try std.testing.expect(std.mem.indexOf(u8, out.items, "attached elsewhere") != null); +} + test "ui: search matches prefer name prefixes over substrings" { const alloc = std.testing.allocator; var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; diff --git a/test/integration.zig b/test/integration.zig index 9c24a97..4539a92 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1361,6 +1361,84 @@ test "ui: clicking the kill target asks for confirmation" { try waitUiSessionCount(&h, 0); } +test "ui: killing the focused session moves focus to the next one" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("doomed", &.{"cat"}); + try h.startDetached("stay", &.{"cat"}); + try h.sendLine("stay", "STAY-MARK"); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("stay"); + + // Focus "doomed" (first alphabetically, sidebar row 3) so its + // death has somewhere to fall back to. + try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m"); + try h.sendLine("doomed", "DOOM-MARK"); + try ui.waitFor("DOOM-MARK"); + + // Killing the focused session attaches the remaining free one: + // its screen contents render without any further input. + try ui.send("\x01k"); + try ui.waitFor("kill doomed? y/n"); + try ui.send("y"); + try ui.waitFor("STAY-MARK"); + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "no session focused") == null); +} + +test "ui: killing the last free session points at a held one" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("held", &.{"cat"}); + var holder = try PtyClient.spawn(&h, &.{ "attach", "held" }, 24, 80); + defer holder.deinit(); + try h.sendLine("held", "HELD-MARK"); + try holder.waitFor("HELD-MARK"); + + try h.startDetached("doomed", &.{"cat"}); + + // The UI auto-focuses "doomed", the only free session. + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try h.sendLine("doomed", "DOOM-MARK"); + try ui.waitFor("DOOM-MARK"); + + // After the kill nothing is free to attach: the held session is + // selected without stealing it, with the viewport explaining how + // to take it over. + try ui.send("\x01k"); + try ui.waitFor("kill doomed? y/n"); + try ui.send("y"); + try ui.waitFor("attached elsewhere"); + try ui.waitFor("click the session to take it over"); + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "no session focused") == null); +} + +test "ui: killing the only session brings back the splash" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("solo", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try h.sendLine("solo", "SOLO-MARK"); + try ui.waitFor("SOLO-MARK"); + + try ui.send("\x01k"); + try ui.waitFor("kill solo? y/n"); + try ui.send("y"); + try ui.waitFor("(o o)"); + try ui.waitFor("no sessions"); + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "no session focused") == null); +} + test "ui: quit with C-a d leaves sessions running and restores the terminal" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);