From cc4ecd5f14353746f9f1f6f990dca3045a8ce5cf Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 18:01:47 +0000 Subject: [PATCH 1/8] feat: add boo ui, a full-screen session manager Sessions are listed in a left sidebar; the focused session renders in a viewport on the right. Sessions can be focused (click or C-a n/p), created (C-a c or the + button), and killed (C-a k or the per-row x) without leaving the UI. Session output is never passed through raw: the UI feeds the focused session into a client-side libghostty terminal sized to the viewport and repaints changed rows at a column offset, so absolute cursor addressing, scrolling, and clears cannot trample the sidebar. The local terminal also answers terminal queries and drives mouse, focus, and bracketed-paste forwarding based on the modes the application actually enabled, with mouse coordinates translated into viewport space. Running boo ui inside a boo session never auto-attaches its host session (the BOO env var), which would feed the UI's own output back into itself. --- README.md | 17 +- src/client.zig | 4 +- src/help.zig | 37 + src/main.zig | 22 +- src/ui.zig | 1751 ++++++++++++++++++++++++++++++++++++++++++ test/integration.zig | 221 ++++++ 6 files changed, 2045 insertions(+), 7 deletions(-) create mode 100644 src/ui.zig diff --git a/README.md b/README.md index 92e83fc..da9a424 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ exactly as a human would see it. - Sessions that survive disconnects: detach with `C-a d`, reattach with `boo attach`. +- A full-screen session manager: `boo ui` lists sessions in a sidebar + and renders the focused one live next to it. Click to switch, create, + or kill sessions; 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, @@ -63,6 +66,7 @@ automatically (pinned in `build.zig.zon`). boo new # new session running $SHELL, attached boo new work # named session boo new work -d -- make # create detached, running a command +boo ui # manage sessions in a full-screen UI boo ls # list sessions boo attach work # reattach (steals if attached elsewhere) boo at w # same: alias + unique-prefix matching @@ -87,6 +91,10 @@ Bindings follow GNU screen's defaults, including the `C-x` variants | `C-a l`, `C-a C-l` | redraw | | `C-a a` | send a literal `C-a` | +`boo ui` adds bindings for switching (`C-a n`/`C-a p`/`C-a C-a`), +creating (`C-a c`), and killing (`C-a k`) sessions; see +`boo help ui`. + ## Automation Everything except `attach` works without a terminal, which makes boo a @@ -148,11 +156,12 @@ your terminal <-(raw tty)-> boo client <-(unix socket)-> session daemon This is a young project, not a drop-in GNU screen replacement: - One attached client per session (attaching steals); no `-x` sharing. -- One window per session: no splits, tabs, or window juggling. Run one - session per task instead. +- One window per session: no splits or tabs inside a session. Run one + session per task and juggle them with `boo ui`. - The `C-a` prefix is not yet configurable, and pasted bytes containing - `0x01` are interpreted as the prefix (GNU screen has the same quirk). -- No status line, monitoring, copy mode, or split regions yet. + `0x01` are interpreted as the prefix (GNU screen has the same quirk; + `boo ui` is immune thanks to bracketed paste). +- No status line, monitoring, or copy mode yet. - Sessions run with `TERM=xterm-256color`. ## License diff --git a/src/client.zig b/src/client.zig index f9f1b87..dfe2b1d 100644 --- a/src/client.zig +++ b/src/client.zig @@ -153,7 +153,9 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { } } -fn rawMode(t: *posix.termios) void { +/// Configure a termios for raw byte-at-a-time input. Shared with the +/// boo ui client, which manages its own terminal lifecycle. +pub fn rawMode(t: *posix.termios) void { t.iflag.IGNBRK = false; t.iflag.BRKINT = false; t.iflag.PARMRK = false; diff --git a/src/help.zig b/src/help.zig index 98c371c..3095eb9 100644 --- a/src/help.zig +++ b/src/help.zig @@ -27,6 +27,7 @@ pub const overview = \\commands: \\ new [name] [-d] [-- cmd...] start a session (attach unless -d) \\ attach, at [name] attach a session (steals politely) + \\ ui manage sessions in a full-screen UI \\ ls [--json] list sessions \\ send [-s name] [text] type into a session \\ peek [name] print the session's screen @@ -110,6 +111,39 @@ pub const commands = [_]Entry{ \\ , }, + .{ + .name = "ui", + .body = + \\usage: boo ui + \\ + \\Manage sessions in a full-screen interface: a sidebar lists + \\every session and the focused session runs in a viewport on + \\the right, rendered live from terminal state. + \\ + \\mouse: + \\ click a session focus it (steals politely, like attach) + \\ click its 'x' kill it (asks for confirmation) + \\ click + new session start a session running $SHELL + \\ scroll the sidebar scroll the session list + \\ in the viewport forwarded to the application when it + \\ asked for mouse reporting + \\ + \\keys (prefix C-a, control variants match GNU screen): + \\ C-a c create a session and focus it + \\ C-a k kill the focused session (asks y/n) + \\ C-a n focus the next session + \\ C-a p focus the previous session + \\ C-a C-a focus the previously focused session + \\ C-a d quit the UI (sessions keep running) + \\ C-a l redraw + \\ C-a a send a literal C-a to the application + \\ + \\Everything else is typed into the focused session. Unlike a + \\plain attach, pasted text may contain C-a bytes safely + \\(bracketed paste). + \\ + , + }, .{ .name = "ls", .alias = "list", @@ -260,6 +294,9 @@ pub const topics = [_]Entry{ \\C-a C-l redraws. Detaching leaves the session running; \\'boo attach' brings it back. \\ + \\'boo ui' adds bindings for managing sessions; see + \\'boo help ui'. + \\ , }, .{ diff --git a/src/main.zig b/src/main.zig index ac72418..3b1229c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,6 +9,7 @@ const daemonpkg = @import("daemon.zig"); const help = @import("help.zig"); const paths = @import("paths.zig"); const protocol = @import("protocol.zig"); +const ui = @import("ui.zig"); pub const version = "0.3.0"; @@ -64,6 +65,7 @@ pub fn main() !void { if (eql(cmd, "new")) return cmdNew(alloc, rest); if (eql(cmd, "attach") or eql(cmd, "at")) return cmdAttach(alloc, rest); + if (eql(cmd, "ui")) return cmdUi(alloc, rest); if (eql(cmd, "ls") or eql(cmd, "list")) return cmdLs(alloc, rest); if (eql(cmd, "send")) return cmdSend(alloc, rest); if (eql(cmd, "peek")) return cmdPeek(alloc, rest); @@ -176,7 +178,7 @@ fn pickMostRecent(alloc: std.mem.Allocator, dir: []const u8) !?[]u8 { return best; } -const SessionInfo = struct { +pub const SessionInfo = struct { /// Full info payload: /// name \t Attached|Detached \t idle_ms \t out_idle_ms \t title. text: []u8, @@ -189,7 +191,7 @@ const SessionInfo = struct { }; /// Query a session daemon, deleting the socket when the daemon is gone. -fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !?SessionInfo { +pub fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !?SessionInfo { const sock = try paths.socketPath(alloc, dir, name); defer alloc.free(sock); const result = client.control(alloc, sock, &.{"info"}) catch { @@ -358,6 +360,21 @@ fn attachLoop(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !void } } +fn cmdUi(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { + for (args) |arg| { + if (isHelpFlag(arg)) return printHelpPage("ui"); + usageFail("ui", "unexpected argument '{s}'", .{arg}); + } + + const dir = try paths.socketDir(alloc); + defer alloc.free(dir); + ui.run(alloc, dir) catch |err| switch (err) { + error.NotATty => fail(exit_runtime, "ui requires a terminal", .{}), + else => return err, + }; + std.debug.print("[boo ui closed]\n", .{}); +} + fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { var json = false; for (args) |arg| { @@ -989,4 +1006,5 @@ test { _ = @import("daemon.zig"); _ = @import("client.zig"); _ = @import("help.zig"); + _ = @import("ui.zig"); } diff --git a/src/ui.zig b/src/ui.zig new file mode 100644 index 0000000..67ae0a7 --- /dev/null +++ b/src/ui.zig @@ -0,0 +1,1751 @@ +//! boo ui: a full-screen session manager. Sessions are listed in a +//! left sidebar; the focused session renders in a viewport on the +//! right. Sessions can be created, focused, and killed with the mouse +//! or with C-a key bindings. +//! +//! Unlike `boo attach`, session output is never passed through to the +//! terminal raw: absolute cursor addressing, scrolling, and clears +//! from the session would trample the sidebar. Instead the UI is a +//! client-side compositor. Output of the focused session feeds a +//! local libghostty terminal sized to the viewport, and the UI +//! repaints changed viewport rows (offset by the sidebar width) from +//! that terminal state, the same way the daemon rehydrates a plain +//! attach from its own terminal state. +//! +//! The local terminal also stands in for a real terminal in both +//! directions: it answers terminal queries (DSR, DA, ...) by sending +//! the reply back to the session as input, and its mode state decides +//! whether mouse, focus, and bracketed-paste events are forwarded to +//! the application (with mouse coordinates translated into viewport +//! space). + +const std = @import("std"); +const posix = std.posix; +const vt = @import("ghostty-vt"); + +const client = @import("client.zig"); +const keys = @import("keys.zig"); +const paths = @import("paths.zig"); +const protocol = @import("protocol.zig"); +const ptypkg = @import("pty.zig"); +const windowpkg = @import("window.zig"); + +const log = std.log.scoped(.ui); + +/// Refresh cadence for the sidebar's session list. +const refresh_interval_ms: i64 = 1000; +/// Transient status messages stay visible this long. +const message_ttl_ms: i64 = 4000; +/// Render coalescing: at most one repaint per interval while output +/// is streaming. +const render_interval_ms: i64 = 15; + +// -- Layout ----------------------------------------------------------------- + +/// Screen geometry: a sidebar on the left, a one-column separator, +/// and the session viewport filling the rest. The viewport always +/// reaches the right edge, so erase-to-end-of-line stays inside it. +pub const Layout = struct { + rows: u16, + cols: u16, + /// Sidebar text columns, excluding the separator column. + sidebar_w: u16, + + pub fn init(rows: u16, cols: u16) Layout { + // Narrow terminals get a proportionally smaller sidebar; the + // viewport keeps at least a sliver so the focused session + // stays usable. + const sw: u16 = if (cols >= 72) 24 else @max(8, cols / 3); + return .{ .rows = rows, .cols = cols, .sidebar_w = sw }; + } + + pub fn viewportCols(self: Layout) u16 { + return self.cols -| (self.sidebar_w + 1); + } + + /// First viewport column, 0-based. + pub fn viewportX(self: Layout) u16 { + return self.sidebar_w + 1; + } + + /// Rows available for session entries between the header and the + /// new-session/status rows. + pub fn listRows(self: Layout) u16 { + return self.rows -| 3; + } + + pub const Hit = union(enum) { + header, + /// Index into the visible session list (scroll already applied + /// by the caller). + session: struct { row: u16, kill: bool }, + new_button, + status, + viewport: struct { x: u16, y: u16 }, + none, + }; + + /// Map a 0-based screen coordinate to a UI region. Session rows + /// report whether the kill target ('x' in the last column) was hit. + pub fn hit(self: Layout, x: u16, y: u16) Hit { + if (y >= self.rows or x >= self.cols) return .none; + if (x >= self.viewportX()) { + return .{ .viewport = .{ .x = x - self.viewportX(), .y = y } }; + } + if (x >= self.sidebar_w) return .none; // separator column + if (y == 0) return .header; + if (y == self.rows -| 1) return .status; + if (y == self.rows -| 2) return .new_button; + return .{ .session = .{ + .row = y - 1, + .kill = self.sidebar_w >= 12 and x == self.sidebar_w - 2, + } }; + } +}; + +// -- Input parsing ---------------------------------------------------------- + +/// A mouse report from the terminal (SGR 1006 encoding). +pub const Mouse = struct { + /// Raw SGR button code: low bits select the button, bit 2..4 are + /// modifiers, bit 5 marks motion, bit 6 marks wheel buttons. + code: u16, + /// 1-based terminal column. + x: u16, + /// 1-based terminal row. + y: u16, + release: bool, + + pub fn isWheel(self: Mouse) bool { + return self.code & 64 != 0; + } + + pub fn isMotion(self: Mouse) bool { + return self.code & 32 != 0; + } +}; + +pub const InputEvent = union(enum) { + /// Bytes destined for the focused session. + forward: []const u8, + /// Command key following the C-a prefix. + prefix: u8, + mouse: Mouse, + /// Bracketed paste begin (true) / end (false). + paste: bool, + /// Focus in (true) / out (false). + focus: bool, +}; + +/// Splits raw terminal input into session bytes and UI events: the +/// C-a prefix, SGR mouse reports, focus reports, and bracketed paste +/// markers. Everything else passes through untouched. While a paste +/// is open the prefix byte is NOT special, so pasted 0x01 bytes reach +/// the application (unlike a plain attach). +pub const InputParser = struct { + /// A C-a was seen; the next byte is a command key. + pending_prefix: bool = false, + /// Held bytes of a possible CSI sequence that may need to be + /// intercepted (mouse/focus/paste reports). Replayed verbatim the + /// moment the sequence diverges. + held: [hold_max]u8 = undefined, + held_len: u8 = 0, + in_paste: bool = false, + + const hold_max = 40; + + pub fn feed(self: *InputParser, input: []const u8, handler: anytype) !void { + var start: usize = 0; + var i: usize = 0; + while (i < input.len) { + const byte = input[i]; + + if (self.held_len > 0) { + if (self.heldAccepts(byte)) { + self.held[self.held_len] = byte; + self.held_len += 1; + i += 1; + start = i; + if (isCsiFinal(byte)) try self.finishCsi(handler); + if (self.held_len == hold_max) try self.flushHeld(handler); + } else { + try self.flushHeld(handler); + } + continue; + } + + if (self.pending_prefix) { + self.pending_prefix = false; + i += 1; + start = i; + try handler.event(.{ .prefix = byte }); + continue; + } + + if (byte == 0x1b) { + if (i > start) try handler.event(.{ .forward = input[start..i] }); + self.held[0] = byte; + self.held_len = 1; + i += 1; + start = i; + continue; + } + + if (byte == keys.escape_byte and !self.in_paste) { + if (i > start) try handler.event(.{ .forward = input[start..i] }); + self.pending_prefix = true; + i += 1; + start = i; + continue; + } + + i += 1; + } + + if (i > start) try handler.event(.{ .forward = input[start..i] }); + } + + /// Whether `byte` keeps the held bytes a candidate for a sequence + /// this parser intercepts: CSI mouse (ESC [ < ... M/m), focus + /// (ESC [ I, ESC [ O), or paste markers (ESC [ 200~, ESC [ 201~). + fn heldAccepts(self: *const InputParser, byte: u8) bool { + const len = self.held_len; + if (len == 1) return byte == '['; + if (len == 2) return switch (byte) { + '<', 'I', 'O', '2' => true, + else => false, + }; + return switch (self.held[2]) { + '<' => switch (byte) { + '0'...'9', ';', 'M', 'm' => true, + else => false, + }, + '2' => switch (byte) { + '0'...'9', '~' => true, + else => false, + }, + else => false, + }; + } + + fn isCsiFinal(byte: u8) bool { + return switch (byte) { + 'M', 'm', '~', 'I', 'O' => true, + else => false, + }; + } + + fn finishCsi(self: *InputParser, handler: anytype) !void { + const seq = self.held[0..self.held_len]; + const body = seq[2 .. seq.len - 1]; + const final = seq[seq.len - 1]; + + // Focus reports arrive as a bare final byte. + if (final == 'I' or final == 'O') { + if (body.len != 0) return self.flushHeld(handler); + self.held_len = 0; + return handler.event(.{ .focus = final == 'I' }); + } + + if (final == '~') { + if (std.mem.eql(u8, body, "200")) { + self.held_len = 0; + self.in_paste = true; + return handler.event(.{ .paste = true }); + } + if (std.mem.eql(u8, body, "201")) { + self.held_len = 0; + self.in_paste = false; + return handler.event(.{ .paste = false }); + } + return self.flushHeld(handler); + } + + // SGR mouse: ESC [ < code ; x ; y (M|m). + if (body.len == 0 or body[0] != '<') return self.flushHeld(handler); + var it = std.mem.splitScalar(u8, body[1..], ';'); + const code = parseField(it.next()) orelse return self.flushHeld(handler); + const x = parseField(it.next()) orelse return self.flushHeld(handler); + const y = parseField(it.next()) orelse return self.flushHeld(handler); + if (it.next() != null) return self.flushHeld(handler); + self.held_len = 0; + return handler.event(.{ .mouse = .{ + .code = code, + .x = x, + .y = y, + .release = final == 'm', + } }); + } + + fn parseField(field: ?[]const u8) ?u16 { + const text = field orelse return null; + return std.fmt.parseInt(u16, text, 10) catch null; + } + + /// Replay held bytes as session input: the sequence is some other + /// key encoding (arrows, function keys, ...) that belongs to the + /// application. + fn flushHeld(self: *InputParser, handler: anytype) !void { + const held = self.held[0..self.held_len]; + self.held_len = 0; + if (held.len > 0) try handler.event(.{ .forward = held }); + } +}; + +// -- Focused session view ---------------------------------------------------- + +/// The attach connection and local terminal state of the focused +/// session. Heap-allocated and pinned: the stream handler keeps a +/// pointer to `term`, and effects callbacks recover the View with +/// @fieldParentPtr (the same shape as window.Window). +pub const View = struct { + alloc: std.mem.Allocator, + sock: posix.fd_t, + decoder: protocol.Decoder, + term: vt.Terminal, + stream: Stream, + state: State = .live, + /// The application set the window title; the sidebar refresh + /// picks it up. + title_changed: bool = false, + /// The application rang the bell; the UI forwards it. + bell: bool = false, + + pub const State = enum { live, ended, stolen, lost }; + pub const Stream = vt.TerminalStream; + + pub fn create( + alloc: std.mem.Allocator, + socket_path: []const u8, + rows: u16, + cols: u16, + ) !*View { + const self = try alloc.create(View); + errdefer alloc.destroy(self); + + const sock = try client.connect(alloc, socket_path); + errdefer posix.close(sock); + + self.* = .{ + .alloc = alloc, + .sock = sock, + .decoder = .init(alloc), + .term = undefined, + .stream = undefined, + }; + errdefer self.decoder.deinit(); + + self.term = try vt.Terminal.init(alloc, .{ + .cols = @max(cols, 1), + .rows = @max(rows, 1), + .max_scrollback = 0, + }); + errdefer self.term.deinit(alloc); + + var handler: Stream.Handler = .init(&self.term); + handler.effects = .{ + .write_pty = effectWritePty, + .bell = effectBell, + .color_scheme = null, + .device_attributes = effectDeviceAttributes, + .enquiry = null, + .size = effectSize, + .title_changed = effectTitleChanged, + .pwd_changed = null, + .xtversion = effectXtversion, + }; + self.stream = .initAlloc(alloc, handler); + errdefer self.stream.deinit(); + + try protocol.writeMsg(sock, .attach, &(protocol.SizePayload{ + .rows = @max(rows, 1), + .cols = @max(cols, 1), + }).encode()); + + return self; + } + + pub fn destroy(self: *View) void { + // Ask for an orderly detach; the daemon also detaches on EOF + // if the request is lost. + if (self.state == .live) { + protocol.writeMsg(self.sock, .detach_req, "") catch {}; + } + posix.close(self.sock); + self.stream.deinit(); + self.term.deinit(self.alloc); + self.decoder.deinit(); + self.alloc.destroy(self); + } + + fn fromHandler(handler: *Stream.Handler) *View { + const stream: *Stream = @alignCast(@fieldParentPtr("handler", handler)); + return @alignCast(@fieldParentPtr("stream", stream)); + } + + /// Query replies (DSR, DA, OSC color queries, ...) generated by + /// the local terminal go back to the session as input, exactly as + /// a real terminal would answer them. + fn effectWritePty(handler: *Stream.Handler, data: [:0]const u8) void { + const self = fromHandler(handler); + self.sendInput(data) catch |err| { + log.warn("query reply failed: {}", .{err}); + }; + } + + fn effectBell(handler: *Stream.Handler) void { + fromHandler(handler).bell = true; + } + + const DeviceAttributes = EffectReturn("device_attributes"); + + fn EffectReturn(comptime field_name: []const u8) type { + const Effects = Stream.Handler.Effects; + const field = std.meta.fieldInfo( + Effects, + @field(std.meta.FieldEnum(Effects), field_name), + ); + const Fn = @typeInfo(field.type).optional.child; + return @typeInfo(@typeInfo(Fn).pointer.child).@"fn".return_type.?; + } + + fn effectDeviceAttributes(handler: *Stream.Handler) DeviceAttributes { + _ = handler; + return .{}; + } + + fn effectSize(handler: *Stream.Handler) ?vt.size_report.Size { + const self = fromHandler(handler); + return .{ + .rows = self.term.rows, + .columns = self.term.cols, + .cell_width = cell_px_w, + .cell_height = cell_px_h, + }; + } + + fn effectTitleChanged(handler: *Stream.Handler) void { + fromHandler(handler).title_changed = true; + } + + fn effectXtversion(handler: *Stream.Handler) []const u8 { + _ = handler; + return "boo " ++ @import("main.zig").version; + } + + pub fn feedOutput(self: *View, bytes: []const u8) void { + self.stream.nextSlice(bytes); + } + + pub fn sendInput(self: *View, bytes: []const u8) !void { + if (self.state != .live) return; + try protocol.writeMsg(self.sock, .input, bytes); + } + + pub fn resize(self: *View, rows: u16, cols: u16) !void { + try self.term.resize(self.alloc, @max(cols, 1), @max(rows, 1)); + if (self.state != .live) return; + try protocol.writeMsg(self.sock, .resize, &(protocol.SizePayload{ + .rows = @max(rows, 1), + .cols = @max(cols, 1), + }).encode()); + } +}; + +// Nominal cell metrics reported to applications that ask for pixel +// sizes (XTWINOPS, kitty); the same values the daemon reports. +const cell_px_w = 8; +const cell_px_h = 16; + +// -- Session list ------------------------------------------------------------- + +pub const Entry = struct { + /// Owned by the list. + name: []u8, + attached: bool, + idle_ms: i64, + /// Owned by the list; sanitized to printable ASCII. + title: []u8, +}; + +fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { + for (entries.items) |entry| { + alloc.free(entry.name); + alloc.free(entry.title); + } + entries.deinit(alloc); +} + +/// Short fixed-width ages for the sidebar: 3s, 12m, 99h. +pub fn fmtAge(buf: []u8, ms: i64) []const u8 { + const s = @divTrunc(@max(0, ms), std.time.ms_per_s); + if (s < 60) return std.fmt.bufPrint(buf, "{d}s", .{s}) catch "?"; + if (s < 3600) return std.fmt.bufPrint(buf, "{d}m", .{@divTrunc(s, 60)}) catch "?"; + const h = @min(99, @divTrunc(s, 3600)); + return std.fmt.bufPrint(buf, "{d}h", .{h}) catch "?"; +} + +// -- Sidebar rendering -------------------------------------------------------- + +const sgr_reset = "\x1b[0m"; +const style_header = "\x1b[1m"; +const style_selected = "\x1b[7m"; +const style_dim = "\x1b[2m"; + +/// Append `text` clipped to `width` columns, then pad with spaces to +/// exactly `width`. Only printable ASCII reaches the writer, so byte +/// count equals column count. +fn appendClipped( + alloc: std.mem.Allocator, + out: *std.ArrayList(u8), + text: []const u8, + width: usize, +) !void { + var used: usize = 0; + for (text) |byte| { + if (used >= width) break; + try out.append(alloc, if (byte >= 0x20 and byte < 0x7f) byte else '?'); + used += 1; + } + while (used < width) : (used += 1) try out.append(alloc, ' '); +} + +/// One sidebar session row: marker, name, age, and a kill target in +/// the last column. Exactly `width` display columns plus SGR codes. +pub fn appendSessionRow( + alloc: std.mem.Allocator, + out: *std.ArrayList(u8), + entry: Entry, + width: u16, + selected: bool, +) !void { + if (width == 0) return; + if (selected) try out.appendSlice(alloc, style_selected); + + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(alloc); + + const marker: u8 = if (selected) '>' else if (entry.attached) '*' else ' '; + try scratch.append(alloc, marker); + + if (width >= 12) { + // " x": age right-aligned, kill target last. + var age_buf: [8]u8 = undefined; + const age = fmtAge(&age_buf, entry.idle_ms); + const name_w = width - 2 - age.len - 2 - 1; + try appendClipped(alloc, &scratch, entry.name, name_w); + try scratch.append(alloc, ' '); + try scratch.appendSlice(alloc, age); + try scratch.appendSlice(alloc, " x "); + } else { + try appendClipped(alloc, &scratch, entry.name, width - 1); + } + try out.appendSlice(alloc, scratch.items[0..@min(scratch.items.len, width)]); + try out.appendSlice(alloc, sgr_reset); +} + +// -- The UI ------------------------------------------------------------------- + +var signal_pipe: posix.fd_t = -1; + +fn handleSignal(sig: c_int) callconv(.c) void { + if (signal_pipe >= 0) { + const byte: [1]u8 = .{@intCast(sig & 0xff)}; + _ = posix.write(signal_pipe, &byte) catch {}; + } +} + +const enter_sequence = + "\x1b[?1049h" ++ // alternate screen, saving the cursor + "\x1b[?1002h\x1b[?1006h" ++ // mouse: button events, SGR encoding + "\x1b[?1004h" ++ // focus reporting + "\x1b[?2004h" ++ // bracketed paste + "\x1b]2;boo ui\x07"; // window title + +/// reset_state_sequence turns every mode above back off. +const restore_sequence = windowpkg.reset_state_sequence ++ "\x1b[?1049l"; + +pub fn run(alloc: std.mem.Allocator, dir: []const u8) !void { + const tty: posix.fd_t = 0; + if (!posix.isatty(tty)) return error.NotATty; + + var ui: Ui = .{ .alloc = alloc, .dir = dir, .tty = tty }; + defer ui.deinit(); + + // Signal plumbing mirrors client.attach: WINCH relayouts, + // TERM/HUP quit cleanly. + const pipe_fds = try posix.pipe2(.{ .CLOEXEC = true, .NONBLOCK = true }); + defer posix.close(pipe_fds[0]); + defer posix.close(pipe_fds[1]); + signal_pipe = pipe_fds[1]; + defer signal_pipe = -1; + const sigact: posix.Sigaction = .{ + .handler = .{ .handler = handleSignal }, + .mask = posix.sigemptyset(), + .flags = posix.SA.RESTART, + }; + posix.sigaction(posix.SIG.WINCH, &sigact, null); + posix.sigaction(posix.SIG.TERM, &sigact, null); + posix.sigaction(posix.SIG.HUP, &sigact, null); + posix.sigaction(posix.SIG.PIPE, &.{ + .handler = .{ .handler = posix.SIG.IGN }, + .mask = posix.sigemptyset(), + .flags = 0, + }, null); + + const saved = try posix.tcgetattr(tty); + var raw = saved; + client.rawMode(&raw); + try posix.tcsetattr(tty, .FLUSH, raw); + defer posix.tcsetattr(tty, .FLUSH, saved) catch {}; + try protocol.writeAll(1, enter_sequence); + defer protocol.writeAll(1, restore_sequence) catch {}; + + const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80); + ui.layout = .init(ws.row, ws.col); + // Running inside a boo session: never attach the session hosting + // this UI, or its output would feed back into itself forever. + ui.host_name = posix.getenv("BOO"); + + try ui.refreshSessions(); + ui.selectInitial(); + ui.attachSelected(); + + try ui.loop(pipe_fds[0]); +} + +const Ui = struct { + alloc: std.mem.Allocator, + dir: []const u8, + tty: posix.fd_t, + + layout: Layout = .{ .rows = 24, .cols = 80, .sidebar_w = 24 }, + sessions: std.ArrayList(Entry) = .empty, + /// Selected (and focused) session index, when any session exists. + selected: ?usize = null, + /// The session this UI itself runs inside, when nested in boo. + host_name: ?[]const u8 = null, + /// Name of the previously focused session for C-a C-a toggling. + last_name: ?[]u8 = null, + /// First visible session row when the list overflows. + scroll: usize = 0, + view: ?*View = null, + + parser: InputParser = .{}, + /// Pending kill confirmation: index into sessions. + confirm_kill: ?usize = null, + /// Transient status message and its expiry time. + message: std.ArrayList(u8) = .empty, + message_deadline: i64 = 0, + + /// Per-screen-row cache of the last emitted bytes; rows that did + /// not change are not re-sent. + row_cache: std.ArrayList(std.ArrayList(u8)) = .empty, + need_render: bool = true, + /// Force every row out on the next render (resize, C-a l). + full_render: bool = true, + last_render_ms: i64 = 0, + next_refresh_ms: i64 = 0, + + /// Mouse forwarding state for the focused application. + mouse_pressed: bool = false, + mouse_last_cell: ?vt.Coordinate = null, + + /// Incremented on every attach; detects view switches that happen + /// between poll() and the socket read. + view_gen: u64 = 0, + + quitting: bool = false, + + fn deinit(self: *Ui) void { + if (self.view) |v| v.destroy(); + freeEntries(self.alloc, &self.sessions); + if (self.last_name) |n| self.alloc.free(n); + self.message.deinit(self.alloc); + for (self.row_cache.items) |*row| row.deinit(self.alloc); + self.row_cache.deinit(self.alloc); + } + + // -- Main loop --------------------------------------------------------- + + fn loop(self: *Ui, sig_read: posix.fd_t) !void { + var buf: [32 * 1024]u8 = undefined; + + while (!self.quitting) { + try self.renderIfNeeded(); + + var fds = [_]posix.pollfd{ + .{ .fd = self.tty, .events = posix.POLL.IN, .revents = 0 }, + .{ .fd = sig_read, .events = posix.POLL.IN, .revents = 0 }, + .{ .fd = -1, .events = posix.POLL.IN, .revents = 0 }, + }; + // Only a live view's socket is polled: a dead one stays + // readable (EOF) forever and would spin the loop. + if (self.liveView()) |v| fds[2].fd = v.sock; + const polled_gen = self.view_gen; + + _ = try posix.poll(&fds, self.pollTimeout()); + + if (fds[1].revents != 0) self.drainSignals(sig_read, &buf); + if (self.quitting) break; + + if (fds[0].revents != 0) try self.readTty(&buf); + if (self.quitting) break; + + // Input handling may have switched the focused session; + // the poll result then describes the old socket, and + // reading the new (still quiet) one would block the UI. + if (fds[2].revents != 0 and self.view_gen == polled_gen) { + try self.readView(&buf); + } + + const now = std.time.milliTimestamp(); + if (now >= self.next_refresh_ms) { + self.refreshSessions() catch |err| { + log.warn("session refresh failed: {}", .{err}); + }; + } + if (self.message_deadline != 0 and now >= self.message_deadline) { + self.message.clearRetainingCapacity(); + self.message_deadline = 0; + self.need_render = true; + } + if (self.view) |v| { + if (v.title_changed) { + v.title_changed = false; + self.refreshSessions() catch {}; + } + if (v.bell) { + v.bell = false; + protocol.writeAll(1, "\x07") catch {}; + } + } + } + } + + fn pollTimeout(self: *Ui) i32 { + const now = std.time.milliTimestamp(); + var deadline = self.next_refresh_ms; + if (self.need_render) { + deadline = @min(deadline, self.last_render_ms + render_interval_ms); + } + if (self.message_deadline != 0) { + deadline = @min(deadline, self.message_deadline); + } + return @intCast(std.math.clamp(deadline - now, 0, 1000)); + } + + fn drainSignals(self: *Ui, sig_read: posix.fd_t, buf: []u8) void { + while (true) { + const n = posix.read(sig_read, buf) catch 0; + if (n == 0) break; + for (buf[0..n]) |sig| switch (sig) { + posix.SIG.WINCH => self.relayout(), + else => self.quitting = true, + }; + if (n < buf.len) break; + } + } + + fn relayout(self: *Ui) void { + const ws = ptypkg.getSize(self.tty) catch return; + self.layout = .init(ws.row, ws.col); + if (self.view) |v| { + v.resize(self.layout.rows, self.layout.viewportCols()) catch |err| { + log.warn("viewport resize failed: {}", .{err}); + }; + } + self.full_render = true; + self.need_render = true; + } + + // -- Terminal input ------------------------------------------------------ + + fn readTty(self: *Ui, buf: []u8) !void { + const n = posix.read(self.tty, buf) catch 0; + if (n == 0) { + self.quitting = true; + return; + } + const Handler = struct { + ui: *Ui, + pub fn event(h: @This(), ev: InputEvent) !void { + try h.ui.handleEvent(ev); + } + }; + try self.parser.feed(buf[0..n], Handler{ .ui = self }); + } + + fn handleEvent(self: *Ui, ev: InputEvent) !void { + // A pending kill confirmation swallows the next key. + if (self.confirm_kill) |idx| { + switch (ev) { + .forward => |bytes| { + self.confirm_kill = null; + if (bytes.len > 0 and (bytes[0] == 'y' or bytes[0] == 'Y')) { + self.killSession(idx); + } else { + self.setMessage("kill cancelled", .{}); + } + return; + }, + .prefix => { + self.confirm_kill = null; + self.setMessage("kill cancelled", .{}); + return; + }, + else => {}, + } + } + + switch (ev) { + .forward => |bytes| { + const v = self.liveView() orelse return; + v.sendInput(bytes) catch self.markViewLost(); + }, + .prefix => |byte| try self.handlePrefix(byte), + .mouse => |m| try self.handleMouse(m), + .paste => |begin| { + const v = self.liveView() orelse return; + if (!v.term.modes.get(.bracketed_paste)) return; + const marker: []const u8 = if (begin) "\x1b[200~" else "\x1b[201~"; + v.sendInput(marker) catch self.markViewLost(); + }, + .focus => |in| { + const v = self.liveView() orelse return; + if (!v.term.modes.get(.focus_event)) return; + const marker: []const u8 = if (in) "\x1b[I" else "\x1b[O"; + v.sendInput(marker) catch self.markViewLost(); + }, + } + } + + fn handlePrefix(self: *Ui, byte: u8) !void { + switch (byte) { + 'c', 0x03 => self.createSession(), + 'k', 0x0b => self.confirmKill(), + 'd', 0x04, 'q' => self.quitting = true, + 'n', 0x0e => self.focusOffset(1), + 'p', 0x10 => self.focusOffset(-1), + keys.escape_byte => self.focusLast(), + 'l', 0x0c => { + // Re-seed the local terminal from daemon state and + // repaint everything. + if (self.liveView()) |v| { + v.sendInput(&.{ keys.escape_byte, 'l' }) catch self.markViewLost(); + } + self.full_render = true; + self.need_render = true; + }, + 'a' => { + // Literal C-a: the daemon's own prefix parser turns + // C-a a into a raw 0x01 for the application. + if (self.liveView()) |v| { + v.sendInput(&.{ keys.escape_byte, 'a' }) catch self.markViewLost(); + } + }, + else => { + if (std.ascii.isPrint(byte)) { + self.setMessage("^A {c}? c new k kill n/p d quit", .{byte}); + } else { + self.setMessage("^A ^{c}? c new k kill n/p d quit", .{byte ^ 0x40}); + } + }, + } + } + + fn handleMouse(self: *Ui, m: Mouse) !void { + if (m.x == 0 or m.y == 0) return; + const x: u16 = m.x - 1; + const y: u16 = m.y - 1; + + // A click anywhere answers a pending kill confirmation with + // "no"; a click on a kill target re-arms it below. + if (self.confirm_kill != null and !m.release and !m.isMotion() and !m.isWheel()) { + self.confirm_kill = null; + self.need_render = true; + } + + if (m.isWheel() and !m.release) { + switch (self.layout.hit(x, y)) { + .viewport => return self.forwardMouse(m), + else => { + // Wheel over the sidebar scrolls the session list. + const down = m.code & 1 != 0; + if (down) { + self.scroll += 1; + } else { + self.scroll -|= 1; + } + self.clampScroll(); + self.need_render = true; + return; + }, + } + } + + switch (self.layout.hit(x, y)) { + .viewport => return self.forwardMouse(m), + .session => |s| { + if (m.release or m.isMotion()) return; + const idx = self.scroll + s.row; + if (idx >= self.sessions.items.len) return; + if (s.kill) { + self.armKillConfirm(idx); + return; + } + self.focusIndex(idx); + }, + .new_button => { + if (m.release or m.isMotion()) return; + self.createSession(); + }, + else => {}, + } + } + + /// Track press state and forward the event to the application + /// when it asked for mouse reporting, with coordinates translated + /// into viewport space. + fn forwardMouse(self: *Ui, m: Mouse) !void { + const v = self.liveView() orelse return; + + if (!m.isWheel() and !m.isMotion()) { + if (m.release) { + self.mouse_pressed = false; + } else { + self.mouse_pressed = true; + } + } + + if (v.term.flags.mouse_event == .none) return; + + const cell_x: u16 = (m.x - 1) -| self.layout.viewportX(); + const cell_y: u16 = m.y - 1; + + const SizeType = @FieldType(vt.input.MouseEncodeOptions, "size"); + const size: SizeType = .{ + .screen = .{ + .width = @as(u32, v.term.cols) * cell_px_w, + .height = @as(u32, v.term.rows) * cell_px_h, + }, + .cell = .{ .width = cell_px_w, .height = cell_px_h }, + .padding = .{}, + }; + var opts: vt.input.MouseEncodeOptions = .fromTerminal(&v.term, size); + opts.any_button_pressed = self.mouse_pressed; + opts.last_cell = &self.mouse_last_cell; + + const event: vt.input.MouseEncodeEvent = .{ + .action = if (m.release) + .release + else if (m.isMotion()) + .motion + else + .press, + .button = sgrButton(m), + .mods = .{ + .shift = m.code & 4 != 0, + .alt = m.code & 8 != 0, + .ctrl = m.code & 16 != 0, + }, + .pos = .{ + .x = (@as(f32, @floatFromInt(cell_x)) + 0.5) * cell_px_w, + .y = (@as(f32, @floatFromInt(cell_y)) + 0.5) * cell_px_h, + }, + }; + + var enc_buf: [64]u8 = undefined; + var writer = std.Io.Writer.fixed(&enc_buf); + vt.input.encodeMouse(&writer, event, opts) catch return; + const encoded = writer.buffered(); + if (encoded.len > 0) v.sendInput(encoded) catch self.markViewLost(); + } + + fn sgrButton(m: Mouse) ?vt.input.MouseButton { + if (m.isWheel()) { + return if (m.code & 1 != 0) .five else .four; + } + return switch (m.code & 3) { + 0 => .left, + 1 => .middle, + 2 => .right, + else => null, + }; + } + + // -- Daemon output ------------------------------------------------------- + + fn readView(self: *Ui, buf: []u8) !void { + const v = self.view orelse return; + if (v.state != .live) return; + const n = posix.read(v.sock, buf) catch 0; + if (n == 0) { + self.markViewLost(); + return; + } + v.decoder.feed(buf[0..n]) catch { + self.markViewLost(); + return; + }; + while (true) { + const msg = v.decoder.next() catch { + self.markViewLost(); + return; + } orelse break; + switch (msg.type) { + .output => { + v.feedOutput(msg.payload); + self.need_render = true; + }, + .detached => { + v.state = .stolen; + self.setMessage("session attached elsewhere", .{}); + self.need_render = true; + }, + .exit => { + v.state = .ended; + self.setMessage("session ended", .{}); + self.refreshSessions() catch {}; + self.need_render = true; + }, + else => {}, + } + if (v.state != .live) break; + } + } + + fn liveView(self: *Ui) ?*View { + const v = self.view orelse return null; + if (v.state != .live) return null; + return v; + } + + fn markViewLost(self: *Ui) void { + if (self.view) |v| { + if (v.state == .live) v.state = .lost; + } + self.refreshSessions() catch {}; + self.need_render = true; + } + + // -- Session management ---------------------------------------------------- + + /// Re-query every session socket. Selection is kept by name, the + /// focused view is dropped when its session disappeared, and a + /// session is auto-focused when the focused one went away. + fn refreshSessions(self: *Ui) !void { + self.next_refresh_ms = std.time.milliTimestamp() + refresh_interval_ms; + + const selected_name: ?[]u8 = if (self.selected) |i| + try self.alloc.dupe(u8, self.sessions.items[i].name) + else + null; + defer if (selected_name) |n| self.alloc.free(n); + + var fresh: std.ArrayList(Entry) = .empty; + errdefer freeEntries(self.alloc, &fresh); + + const names = try paths.listSessions(self.alloc, self.dir); + defer { + for (names) |n| self.alloc.free(n); + self.alloc.free(names); + } + std.mem.sort([]u8, names, {}, struct { + fn lt(_: void, a: []u8, b: []u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + + const main = @import("main.zig"); + for (names) |name| { + const info = main.sessionInfo(self.alloc, self.dir, name) catch continue orelse continue; + defer self.alloc.free(info.text); + try fresh.append(self.alloc, .{ + .name = try self.alloc.dupe(u8, name), + .attached = info.attached, + .idle_ms = info.idle_ms, + .title = try self.alloc.dupe(u8, info.title), + }); + } + + freeEntries(self.alloc, &self.sessions); + self.sessions = fresh; + + // Restore selection by name. + self.selected = null; + if (selected_name) |want| { + for (self.sessions.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.name, want)) { + self.selected = i; + break; + } + } + } + if (self.selected == null) { + // The focused session is gone; fall back to a neighbor. + if (self.view) |v| { + if (v.state == .live) v.state = .lost; + } + if (self.firstFocusable()) |i| { + self.selected = i; + self.attachSelected(); + } else if (self.view != null) { + self.view.?.destroy(); + self.view = null; + } + } + self.clampScroll(); + self.need_render = true; + } + + fn isHost(self: *Ui, idx: usize) bool { + const host = self.host_name orelse return false; + return std.mem.eql(u8, self.sessions.items[idx].name, host); + } + + fn firstFocusable(self: *Ui) ?usize { + for (self.sessions.items, 0..) |_, i| { + if (!self.isHost(i)) return i; + } + return null; + } + + /// Pick the most recently active session on startup. + fn selectInitial(self: *Ui) void { + var best: ?usize = null; + for (self.sessions.items, 0..) |entry, i| { + if (self.isHost(i)) continue; + if (best == null or entry.idle_ms < self.sessions.items[best.?].idle_ms) { + best = i; + } + } + self.selected = best; + } + + fn attachSelected(self: *Ui) void { + const idx = self.selected orelse return; + const name = self.sessions.items[idx].name; + + if (self.view) |v| { + v.destroy(); + self.view = null; + } + + const sock = paths.socketPath(self.alloc, self.dir, name) catch return; + defer self.alloc.free(sock); + self.view = View.create( + self.alloc, + sock, + self.layout.rows, + self.layout.viewportCols(), + ) catch |err| { + self.setMessage("attach {s} failed: {s}", .{ name, @errorName(err) }); + return; + }; + self.view_gen += 1; + self.full_render = true; + self.need_render = true; + } + + fn rememberLast(self: *Ui, idx: usize) void { + const name = self.sessions.items[idx].name; + if (self.last_name) |old| self.alloc.free(old); + self.last_name = self.alloc.dupe(u8, name) catch null; + } + + fn focusIndex(self: *Ui, idx: usize) void { + if (idx >= self.sessions.items.len) return; + if (self.isHost(idx)) { + self.setMessage("{s} hosts this ui", .{self.sessions.items[idx].name}); + return; + } + if (self.selected) |cur| { + if (cur != idx) self.rememberLast(cur); + } + self.selected = idx; + self.scrollSelectedIntoView(); + self.attachSelected(); + } + + fn focusOffset(self: *Ui, dir: i2) void { + const len = self.sessions.items.len; + if (len == 0) return; + const cur = self.selected orelse len - 1; + // Step past the session hosting this UI, when nested. + var idx = cur; + for (0..len) |_| { + idx = if (dir > 0) + (idx + 1) % len + else + (idx + len - 1) % len; + if (!self.isHost(idx)) break; + } + if (self.isHost(idx)) return; + self.focusIndex(idx); + } + + fn focusLast(self: *Ui) void { + const want = self.last_name orelse return; + for (self.sessions.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.name, want)) { + self.focusIndex(i); + return; + } + } + self.setMessage("no previous session", .{}); + } + + /// Create a session by re-running our own binary with `new -d`. + /// The exec drops every inherited descriptor (they are all + /// CLOEXEC), so the daemon cannot pin the UI's sockets open, and + /// naming falls back exactly like the CLI. + fn createSession(self: *Ui) void { + const exe = std.fs.selfExePathAlloc(self.alloc) catch { + self.setMessage("create failed", .{}); + return; + }; + defer self.alloc.free(exe); + + const result = std.process.Child.run(.{ + .allocator = self.alloc, + .argv = &.{ exe, "new", "-d" }, + }) catch { + self.setMessage("create failed", .{}); + return; + }; + defer self.alloc.free(result.stdout); + defer self.alloc.free(result.stderr); + + if (result.term != .Exited or result.term.Exited != 0) { + const reason = std.mem.trim(u8, result.stderr, " \n"); + self.setMessage("create failed: {s}", .{reason}); + return; + } + const name = std.mem.trimRight(u8, result.stdout, "\n"); + self.setMessage("created {s}", .{name}); + + self.refreshSessions() catch return; + for (self.sessions.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.name, name)) { + self.focusIndex(i); + break; + } + } + } + + fn confirmKill(self: *Ui) void { + const idx = self.selected orelse { + self.setMessage("no session to kill", .{}); + return; + }; + self.armKillConfirm(idx); + } + + fn armKillConfirm(self: *Ui, idx: usize) void { + self.confirm_kill = idx; + // The prompt renders from confirm_kill; a stale transient + // message would cover it up. + self.message.clearRetainingCapacity(); + self.message_deadline = 0; + self.need_render = true; + } + + fn killSession(self: *Ui, idx: usize) void { + if (idx >= self.sessions.items.len) return; + const name = self.sessions.items[idx].name; + + const sock = paths.socketPath(self.alloc, self.dir, name) catch return; + defer self.alloc.free(sock); + const result = client.control(self.alloc, sock, &.{"quit"}) catch { + // The daemon is already gone; remove the stale socket. + std.fs.cwd().deleteFile(sock) catch {}; + self.refreshSessions() catch {}; + return; + }; + self.alloc.free(result.text); + self.setMessage("killed {s}", .{name}); + self.refreshSessions() catch {}; + } + + fn setMessage(self: *Ui, comptime fmt: []const u8, args: anytype) void { + self.message.clearRetainingCapacity(); + self.message.print(self.alloc, fmt, args) catch {}; + self.message_deadline = std.time.milliTimestamp() + message_ttl_ms; + self.need_render = true; + } + + fn clampScroll(self: *Ui) void { + const max_scroll = self.sessions.items.len -| self.layout.listRows(); + if (self.scroll > max_scroll) self.scroll = max_scroll; + } + + /// Scroll just enough that the selected session is on screen. + /// Only focus changes call this, so wheel scrolling can move the + /// list freely without snapping back to the selection. + fn scrollSelectedIntoView(self: *Ui) void { + self.clampScroll(); + const list_rows = self.layout.listRows(); + const idx = self.selected orelse return; + if (idx < self.scroll) self.scroll = idx; + if (list_rows > 0 and idx >= self.scroll + list_rows) { + self.scroll = idx + 1 - list_rows; + } + } + + // -- Rendering ----------------------------------------------------------- + + fn renderIfNeeded(self: *Ui) !void { + if (!self.need_render) return; + const now = std.time.milliTimestamp(); + if (now - self.last_render_ms < render_interval_ms) return; + self.last_render_ms = now; + self.need_render = false; + + var frame: std.ArrayList(u8) = .empty; + defer frame.deinit(self.alloc); + try self.composeFrame(&frame); + self.full_render = false; + if (frame.items.len > 0) try protocol.writeAll(1, frame.items); + } + + /// Build the bytes for one repaint: changed rows only, wrapped in + /// a synchronized update so terminals that support it repaint + /// atomically. + fn composeFrame(self: *Ui, frame: *std.ArrayList(u8)) !void { + const alloc = self.alloc; + const l = self.layout; + + // Grow/shrink the row cache to the current height. + while (self.row_cache.items.len < l.rows) { + try self.row_cache.append(alloc, .empty); + } + while (self.row_cache.items.len > l.rows) { + var row = self.row_cache.pop() orelse break; + row.deinit(alloc); + } + + var body: std.ArrayList(u8) = .empty; + defer body.deinit(alloc); + + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(alloc); + + for (0..l.rows) |y| { + scratch.clearRetainingCapacity(); + try self.composeRow(@intCast(y), &scratch); + const cache = &self.row_cache.items[y]; + if (!self.full_render and std.mem.eql(u8, cache.items, scratch.items)) { + continue; + } + cache.clearRetainingCapacity(); + try cache.appendSlice(alloc, scratch.items); + try body.print(alloc, "\x1b[{d};1H", .{y + 1}); + try body.appendSlice(alloc, scratch.items); + } + + const cursor = self.cursorSequence(); + + if (body.items.len == 0 and !self.full_render) { + // Row content unchanged; the cursor may still have moved. + try frame.appendSlice(alloc, "\x1b[?25l"); + try frame.appendSlice(alloc, cursor.pos[0..cursor.pos_len]); + try frame.appendSlice(alloc, if (cursor.visible) "\x1b[?25h" else "\x1b[?25l"); + return; + } + + try frame.appendSlice(alloc, "\x1b[?2026h\x1b[?25l"); + try frame.appendSlice(alloc, body.items); + try frame.appendSlice(alloc, cursor.pos[0..cursor.pos_len]); + try frame.appendSlice(alloc, if (cursor.visible) "\x1b[?25h" else "\x1b[?25l"); + try frame.appendSlice(alloc, "\x1b[?2026l"); + } + + const CursorState = struct { + pos: [32]u8 = undefined, + pos_len: usize = 0, + visible: bool = false, + }; + + fn cursorSequence(self: *Ui) CursorState { + var state: CursorState = .{}; + const v = self.liveView() orelse return state; + const cursor = &v.term.screens.active.cursor; + const row: usize = @min(cursor.y, self.layout.rows -| 1); + const col: usize = @min( + @as(usize, cursor.x) + self.layout.viewportX(), + self.layout.cols -| 1, + ); + const text = std.fmt.bufPrint(&state.pos, "\x1b[{d};{d}H", .{ + row + 1, + col + 1, + }) catch return state; + state.pos_len = text.len; + state.visible = v.term.modes.get(.cursor_visible); + return state; + } + + /// One full screen row: sidebar columns, separator, then the + /// viewport slice. The sidebar segment is always exactly + /// sidebar_w columns so the row never bleeds into the viewport. + fn composeRow(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { + const alloc = self.alloc; + + try out.appendSlice(alloc, sgr_reset); + try self.composeSidebarCell(y, out); + try out.appendSlice(alloc, style_dim); + try out.appendSlice(alloc, "\u{2502}"); + try out.appendSlice(alloc, sgr_reset); + try self.composeViewportCell(y, out); + } + + fn composeSidebarCell(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { + const alloc = self.alloc; + const l = self.layout; + const w = l.sidebar_w; + + if (y == 0) { + try out.appendSlice(alloc, style_header); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(alloc); + try text.print(alloc, " boo {d} session{s}", .{ + self.sessions.items.len, + if (self.sessions.items.len == 1) "" else "s", + }); + try appendClipped(alloc, out, text.items, w); + try out.appendSlice(alloc, sgr_reset); + return; + } + + if (y == l.rows -| 1) { + try out.appendSlice(alloc, style_dim); + // A pending confirmation outlives transient messages, so + // the prompt is regenerated rather than stored. + if (self.confirm_kill) |idx| { + var prompt: std.ArrayList(u8) = .empty; + defer prompt.deinit(alloc); + if (idx < self.sessions.items.len) { + try prompt.print(alloc, "kill {s}? y/n", .{self.sessions.items[idx].name}); + } + try appendClipped(alloc, out, prompt.items, w); + } else if (self.message.items.len > 0) { + try appendClipped(alloc, out, self.message.items, w); + } else { + try appendClipped(alloc, out, "^A c new k kill d quit", w); + } + try out.appendSlice(alloc, sgr_reset); + return; + } + + if (y == l.rows -| 2) { + try out.appendSlice(alloc, style_dim); + try appendClipped(alloc, out, " + new session", w); + try out.appendSlice(alloc, sgr_reset); + return; + } + + const idx = self.scroll + (y - 1); + if (idx < self.sessions.items.len) { + try appendSessionRow( + alloc, + out, + self.sessions.items[idx], + w, + self.selected != null and self.selected.? == idx, + ); + return; + } + + try appendClipped(alloc, out, "", w); + } + + fn composeViewportCell(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { + const alloc = self.alloc; + + const v = self.view orelse { + try self.composeEmptyRow(y, "no sessions", "press C-a c or click + new session", out); + try out.appendSlice(alloc, "\x1b[K"); + return; + }; + + switch (v.state) { + .live => {}, + .stolen => { + try self.composeEmptyRow(y, "attached elsewhere", "click the session to steal it back", out); + try out.appendSlice(alloc, "\x1b[K"); + return; + }, + .ended, .lost => { + try self.composeEmptyRow(y, "session ended", "pick another session on the left", out); + try out.appendSlice(alloc, "\x1b[K"); + return; + }, + } + + if (y < v.term.rows) { + try appendTermRow(alloc, &v.term, y, out); + } + try out.appendSlice(alloc, sgr_reset); + try out.appendSlice(alloc, "\x1b[K"); + } + + fn composeEmptyRow( + self: *Ui, + y: u16, + comptime line1: []const u8, + comptime line2: []const u8, + out: *std.ArrayList(u8), + ) !void { + const l = self.layout; + const mid = l.rows / 2; + const text: []const u8 = if (y == mid) + line1 + else if (y == mid + 1) + line2 + else + return; + const vw = l.viewportCols(); + if (text.len >= vw) return; + const pad = (vw - text.len) / 2; + try out.appendSlice(self.alloc, style_dim); + for (0..pad) |_| try out.append(self.alloc, ' '); + try out.appendSlice(self.alloc, text); + try out.appendSlice(self.alloc, sgr_reset); + } +}; + +/// Append one row of the terminal's active screen as styled VT bytes. +/// Rendered through libghostty's own formatter, so styles, wide +/// characters, and blank runs come out exactly as the daemon would +/// replay them, just one row at a time. +pub fn appendTermRow( + alloc: std.mem.Allocator, + term: *vt.Terminal, + y: u16, + out: *std.ArrayList(u8), +) !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; + + var formatter: vt.formatter.ScreenFormatter = .init(screen, .vt); + formatter.content = .{ .selection = vt.Selection.init(start, end, true) }; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + aw.writer.print("{f}", .{formatter}) catch return error.OutOfMemory; + + const bytes = aw.writer.buffered(); + try out.appendSlice(alloc, bytes); + // A row that opened a hyperlink must not leak it into the next + // row or the sidebar. + if (std.mem.indexOf(u8, bytes, "\x1b]8;") != null) { + try out.appendSlice(alloc, "\x1b]8;;\x1b\\"); + } +} + +// -- Tests -------------------------------------------------------------------- + +const TestHandler = struct { + alloc: std.mem.Allocator, + events: std.ArrayList(InputEvent) = .empty, + forwarded: std.ArrayList(u8) = .empty, + + fn deinit(self: *TestHandler) void { + self.events.deinit(self.alloc); + self.forwarded.deinit(self.alloc); + } + + fn event(self: *TestHandler, ev: InputEvent) !void { + switch (ev) { + .forward => |bytes| try self.forwarded.appendSlice(self.alloc, bytes), + else => try self.events.append(self.alloc, ev), + } + } +}; + +test "parser: plain bytes pass through" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("hello", &h); + try std.testing.expectEqualStrings("hello", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + +test "parser: prefix commands" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("ab\x01cde", &h); + try std.testing.expectEqualStrings("abde", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 'c' }, h.events.items[0]); +} + +test "parser: prefix split across feeds" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x01", &h); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + try p.feed("k", &h); + try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); +} + +test "parser: sgr mouse press and release" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[<0;5;7M\x1b[<0;5;7m", &h); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); + const press = h.events.items[0].mouse; + try std.testing.expectEqual(@as(u16, 0), press.code); + try std.testing.expectEqual(@as(u16, 5), press.x); + try std.testing.expectEqual(@as(u16, 7), press.y); + try std.testing.expect(!press.release); + try std.testing.expect(h.events.items[1].mouse.release); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: mouse sequence split across feeds" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[<6", &h); + try p.feed("5;10;2M", &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + const m = h.events.items[0].mouse; + try std.testing.expectEqual(@as(u16, 65), m.code); + try std.testing.expect(m.isWheel()); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: non-intercepted CSI passes through" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[A\x1b[1;5C", &h); + try std.testing.expectEqualStrings("\x1b[A\x1b[1;5C", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + +test "parser: bracketed paste protects the prefix byte" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[200~a\x01b\x1b[201~", &h); + try std.testing.expectEqualStrings("a\x01b", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[0]); + try std.testing.expectEqual(InputEvent{ .paste = false }, h.events.items[1]); +} + +test "parser: focus reports" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[I\x1b[O", &h); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .focus = true }, h.events.items[0]); + try std.testing.expectEqual(InputEvent{ .focus = false }, h.events.items[1]); +} + +test "layout: geometry and hit testing" { + const l = Layout.init(24, 100); + try std.testing.expectEqual(@as(u16, 24), l.sidebar_w); + try std.testing.expectEqual(@as(u16, 75), l.viewportCols()); + try std.testing.expectEqual(@as(u16, 25), l.viewportX()); + + try std.testing.expectEqual(Layout.Hit.header, l.hit(3, 0)); + try std.testing.expectEqual(Layout.Hit.status, l.hit(3, 23)); + try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 22)); + try std.testing.expectEqual(Layout.Hit.none, l.hit(24, 5)); // separator + + const s = l.hit(3, 5); + try std.testing.expectEqual(@as(u16, 4), s.session.row); + try std.testing.expect(!s.session.kill); + const k = l.hit(22, 5); + try std.testing.expect(k.session.kill); + + const v = l.hit(30, 7); + try std.testing.expectEqual(@as(u16, 5), v.viewport.x); + try std.testing.expectEqual(@as(u16, 7), v.viewport.y); + + try std.testing.expectEqual(Layout.Hit.none, l.hit(100, 5)); +} + +test "layout: narrow terminals shrink the sidebar" { + const l = Layout.init(24, 48); + try std.testing.expectEqual(@as(u16, 16), l.sidebar_w); + try std.testing.expect(l.viewportCols() > 0); +} + +test "fmtAge" { + var buf: [8]u8 = undefined; + try std.testing.expectEqualStrings("0s", fmtAge(&buf, 1)); + try std.testing.expectEqualStrings("59s", fmtAge(&buf, 59_999)); + try std.testing.expectEqualStrings("3m", fmtAge(&buf, 3 * 60_000)); + try std.testing.expectEqualStrings("99h", fmtAge(&buf, 1000 * 3_600_000)); +} + +test "sidebar session row is exactly the requested width" { + const alloc = std.testing.allocator; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + + var name_buf: [8]u8 = "work1234".*; + var title_buf: [0]u8 = .{}; + const entry: Entry = .{ + .name = &name_buf, + .attached = false, + .idle_ms = 12_000, + .title = &title_buf, + }; + + try appendSessionRow(alloc, &out, entry, 24, false); + // Strip SGR wrapping: not selected, so only the trailing reset. + const text = out.items[0 .. out.items.len - sgr_reset.len]; + try std.testing.expectEqual(@as(usize, 24), text.len); + try std.testing.expect(std.mem.indexOf(u8, text, "work1234") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "12s") != null); + try std.testing.expect(std.mem.endsWith(u8, text, "x ")); + + // Selected rows are wrapped in inverse video. + out.clearRetainingCapacity(); + try appendSessionRow(alloc, &out, entry, 24, true); + try std.testing.expect(std.mem.startsWith(u8, out.items, style_selected)); + try std.testing.expect(std.mem.indexOf(u8, out.items, ">") != null); +} + +test "appendTermRow renders styled content for one row only" { + const alloc = std.testing.allocator; + + var term = try vt.Terminal.init(alloc, .{ .cols = 20, .rows = 5 }); + defer term.deinit(alloc); + var stream = term.vtStream(); + defer stream.deinit(); + + stream.nextSlice("first\r\n \x1b[1;31mred\x1b[0m end"); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + try appendTermRow(alloc, &term, 0, &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "first") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "red") == null); + + out.clearRetainingCapacity(); + try appendTermRow(alloc, &term, 1, &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "red") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "first") == null); + // Leading blanks are preserved so columns line up. + try std.testing.expect(std.mem.indexOf(u8, out.items, " ") != null); + // The row carries SGR styling for the red word. + try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[") != null); + + // Blank rows render as nothing (the caller clears with EL). + out.clearRetainingCapacity(); + try appendTermRow(alloc, &term, 3, &out); + try std.testing.expectEqual(@as(usize, 0), out.items.len); +} diff --git a/test/integration.zig b/test/integration.zig index 5e761d8..49dbbb5 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -997,3 +997,224 @@ test "agent loop: new, send, wait, peek, kill" { try h.runOk(&.{ "kill", "agent" }); try h.runExit(&.{ "peek", "agent" }, 3); } + +// -- boo ui ------------------------------------------------------------------- + +fn uiSessionCount(h: *Harness) !usize { + const result = try h.run(&.{ "ls", "--json" }); + defer h.alloc.free(result.stdout); + defer h.alloc.free(result.stderr); + if (result.term != .Exited or result.term.Exited != 0) return error.LsFailed; + var parsed = try std.json.parseFromSlice(std.json.Value, h.alloc, result.stdout, .{}); + defer parsed.deinit(); + return parsed.value.array.items.len; +} + +fn waitUiSessionCount(h: *Harness, want: usize) !void { + var deadline = Deadline.init(default_timeout_ms); + while (true) { + if (try uiSessionCount(h) == want) return; + try deadline.tick("session count never settled"); + } +} + +test "ui: sidebar lists sessions and the focused session renders in the viewport" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("aa", &.{"cat"}); + try h.startDetached("bb", &.{"cat"}); + try h.sendLine("bb", "BB-VIEW-MARK"); + const seeded = try h.waitPeekContains("bb", "BB-VIEW-MARK"); + alloc.free(seeded); + + // bb saw input last, so it is the most recent session and the UI + // focuses it on startup. + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("aa"); + try ui.waitFor("bb"); + try ui.waitFor("+ new session"); + try ui.waitFor("BB-VIEW-MARK"); + + // The UI renders on the alternate screen, like attach. + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "\x1b[?1049h") != null); + + // C-a p focuses the previous (other) session; typing lands there. + ui.clearOutput(); + try ui.send("\x01p"); + try ui.send("AA-TYPED-MARK\r"); + try ui.waitFor("AA-TYPED-MARK"); + const peeked = try h.waitPeekContains("aa", "AA-TYPED-MARK"); + defer alloc.free(peeked); +} + +test "ui: clicking a session in the sidebar focuses it" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("one", &.{"cat"}); + try h.startDetached("two", &.{"cat"}); + try h.sendLine("one", "ONE-MARK"); + try h.sendLine("two", "TWO-MARK"); + const seeded = try h.waitPeekContains("two", "TWO-MARK"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("TWO-MARK"); // most recent session focused + + // Sessions are sorted by name: "one" on sidebar row 2 (1-based). + // An SGR press + release on that row switches the viewport. + ui.clearOutput(); + try ui.send("\x1b[<0;5;2M\x1b[<0;5;2m"); + try ui.waitFor("ONE-MARK"); +} + +test "ui: create and kill sessions from the ui" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("keep1", &.{"cat"}); + try h.startDetached("keep2", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("keep2"); + + // C-a c creates a session (named after the cwd or the creating + // pid) and focuses it. + try ui.send("\x01c"); + try ui.waitFor("3 sessions"); + try waitUiSessionCount(&h, 3); + + // C-a k asks for confirmation, then kills the focused (new) + // session. + try ui.send("\x01k"); + try ui.waitFor("? y/n"); + try ui.send("y"); + try ui.waitFor("2 sessions"); + try waitUiSessionCount(&h, 2); + + // The pre-existing sessions survived. + const ls = try h.run(&.{"ls"}); + defer alloc.free(ls.stdout); + defer alloc.free(ls.stderr); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "keep1") != null); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "keep2") != null); +} + +test "ui: clicking the kill target asks for confirmation" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("victim", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("victim"); + + // The kill target is the 'x' in the second-to-last sidebar + // column (sidebar width 24 -> 1-based column 23), row 2. + try ui.send("\x1b[<0;23;2M\x1b[<0;23;2m"); + try ui.waitFor("kill victim? y/n"); + try ui.send("y"); + try waitUiSessionCount(&h, 0); +} + +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); + defer h.deinit(); + + try h.startDetached("survivor", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("survivor"); + + try ui.send("\x01d"); + try ui.waitFor("[boo ui closed]"); + try std.testing.expectEqual(@as(u32, 0), try ui.waitExit()); + + // The alternate screen is left before the exit notice prints. + const leave = std.mem.lastIndexOf(u8, ui.output.items, "\x1b[?1049l").?; + const notice = std.mem.indexOf(u8, ui.output.items, "[boo ui closed]").?; + try std.testing.expect(leave < notice); + + const ls = try h.run(&.{"ls"}); + defer alloc.free(ls.stdout); + defer alloc.free(ls.stderr); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "survivor") != null); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "detached") != null); +} + +test "ui: viewport size tracks the terminal minus the sidebar" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("rz", &.{"/bin/sh"}); + + // 100 columns - 24 sidebar - 1 separator = 75 viewport columns. + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("rz"); + + const size_file = try std.fmt.allocPrint(alloc, "{s}/ui-size.txt", .{h.dir}); + defer alloc.free(size_file); + const cmd = try std.fmt.allocPrint(alloc, "stty size > {s}", .{size_file}); + defer alloc.free(cmd); + + try h.sendLine("rz", cmd); + try waitFileEquals(alloc, size_file, "24 75\n"); + + // Resizing the outer terminal resizes the viewport with it. + try ui.setSize(30, 120); + var deadline = Deadline.init(default_timeout_ms); + while (true) { + try h.sendLine("rz", cmd); + std.Thread.sleep(50 * std.time.ns_per_ms); + const content = std.fs.cwd().readFileAlloc(alloc, size_file, 4096) catch ""; + defer if (content.len > 0) alloc.free(content); + if (std.mem.eql(u8, content, "30 95\n")) break; + try deadline.tick("viewport resize never reached the session"); + } +} + +test "ui: a plain attach steals the focused session" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("st", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("st"); + + var thief = try PtyClient.spawn(&h, &.{ "attach", "st" }, 24, 80); + defer thief.deinit(); + try ui.waitFor("attached elsewhere"); + + // Clicking the session in the sidebar steals it back. + try ui.send("\x1b[<0;5;2M\x1b[<0;5;2m"); + try thief.waitFor("attached elsewhere"); + _ = try thief.waitExit(); +} + +test "ui without a tty fails cleanly" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + const result = try h.run(&.{"ui"}); + defer alloc.free(result.stdout); + defer alloc.free(result.stderr); + try std.testing.expect(result.term == .Exited and result.term.Exited == 1); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "requires a terminal") != null); +} From 78b4c98f6cb3c6778beb8799481d9b9162ca65e6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 18:42:01 +0000 Subject: [PATCH 2/8] feat: rename sessions, show titles in the sidebar, and add a keybind bar - 'boo rename ' and a daemon 'rename' control verb: the listening socket is renamed in place, so the running program and any attached client are unaffected. - boo ui: C-a r opens a rename prompt in the status bar, pre-filled with the current name. - boo ui: each sidebar entry now shows the window title dim under the session name. - boo ui: the last row is a full-width status bar. It hints 'Press Ctrl+A for keybinds' and lists every binding while the prefix is armed; prompts and messages render there too. - boo ui: the selection highlight is the only selection marker; the '>' glyph is gone and '*' only marks sessions attached elsewhere. --- README.md | 9 +- src/daemon.zig | 65 ++++++++ src/help.zig | 18 +++ src/main.zig | 31 ++++ src/ui.zig | 353 ++++++++++++++++++++++++++++++++++++------- test/integration.zig | 83 +++++++++- 6 files changed, 500 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 26fd18d..b4afa1c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ exactly as a human would see it. - Sessions that survive disconnects: detach with `C-a d`, reattach with `boo attach`. - A full-screen session manager: `boo ui` lists sessions in a sidebar - and renders the focused one live next to it. Click to switch, create, - or kill sessions; everything also works from the keyboard. + with their titles and renders the focused one live next to it. Click + to switch, create, kill, or rename sessions; 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, @@ -70,6 +71,7 @@ boo ui # manage sessions in a full-screen UI boo ls # list sessions boo attach work # reattach (steals if attached elsewhere) boo at w # same: alias + unique-prefix matching +boo rename work api # rename a session boo kill work # end a session boo kill --all # end every session ``` @@ -92,7 +94,8 @@ Bindings follow GNU screen's defaults, including the `C-x` variants | `C-a a` | send a literal `C-a` | `boo ui` adds bindings for switching (`C-a n`/`C-a p`/`C-a C-a`), -creating (`C-a c`), and killing (`C-a k`) sessions; see +creating (`C-a c`), killing (`C-a k`), and renaming (`C-a r`) +sessions; pressing `C-a` alone lists them in the bottom bar. See `boo help ui`. ## Automation diff --git a/src/daemon.zig b/src/daemon.zig index 1a10825..3a0ba87 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -12,6 +12,7 @@ const posix = std.posix; const protocol = @import("protocol.zig"); const keys = @import("keys.zig"); const altscreen = @import("altscreen.zig"); +const paths = @import("paths.zig"); const windowpkg = @import("window.zig"); const Window = windowpkg.Window; const main = @import("main.zig"); @@ -58,6 +59,11 @@ pub const Daemon = struct { conns: std.ArrayList(*Conn) = .empty, key_parser: keys.Parser = .{}, + /// Owned replacements for opts.name and opts.socket_path after a + /// rename; the startup values are borrowed from the caller. + owned_name: ?[]u8 = null, + owned_socket_path: ?[]u8 = null, + rows: u16, cols: u16, @@ -115,6 +121,8 @@ pub const Daemon = struct { self.conns.deinit(self.alloc); posix.close(self.opts.listen_fd); std.fs.cwd().deleteFile(self.opts.socket_path) catch {}; + if (self.owned_name) |n| self.alloc.free(n); + if (self.owned_socket_path) |p| self.alloc.free(p); if (self.sig_read >= 0) posix.close(self.sig_read); if (sigchld_pipe >= 0) posix.close(sigchld_pipe); } @@ -371,6 +379,12 @@ pub const Daemon = struct { } } conn.send(.ok, out.items); + } else if (std.mem.eql(u8, cmd, "rename")) { + if (argv.len != 2) { + conn.send(.err, "usage: rename "); + return; + } + self.rename(conn, argv[1]); } else if (std.mem.eql(u8, cmd, "quit")) { conn.send(.ok, ""); if (self.win) |w| { @@ -383,6 +397,57 @@ pub const Daemon = struct { } } + /// Move the session to a new name by renaming the listening + /// socket; established connections survive, and new clients find + /// the session under the new name. + fn rename(self: *Daemon, conn: *Conn, new_name: []const u8) void { + paths.validateName(new_name) catch { + conn.send(.err, "invalid session name"); + return; + }; + if (std.mem.eql(u8, new_name, self.opts.name)) { + conn.send(.ok, ""); + return; + } + + const dir = std.fs.path.dirname(self.opts.socket_path) orelse "."; + const new_path = paths.socketPath(self.alloc, dir, new_name) catch { + conn.send(.err, "rename failed"); + return; + }; + const new_owned_name = self.alloc.dupe(u8, new_name) catch { + self.alloc.free(new_path); + conn.send(.err, "rename failed"); + return; + }; + + // Refuse to clobber another session's socket. Checking first + // is racy, but the window is tiny and losing the race only + // replaces a socket the same way 'kill' would free it. + if (std.fs.cwd().access(new_path, .{})) |_| { + self.alloc.free(new_path); + self.alloc.free(new_owned_name); + conn.send(.err, "a session with that name already exists"); + return; + } else |_| {} + + std.fs.cwd().rename(self.opts.socket_path, new_path) catch { + self.alloc.free(new_path); + self.alloc.free(new_owned_name); + conn.send(.err, "rename failed"); + return; + }; + + if (self.owned_name) |n| self.alloc.free(n); + if (self.owned_socket_path) |p| self.alloc.free(p); + self.owned_name = new_owned_name; + self.owned_socket_path = new_path; + self.opts.name = new_owned_name; + self.opts.socket_path = new_path; + log.info("renamed to {s}", .{new_name}); + conn.send(.ok, ""); + } + fn serviceWindow(self: *Daemon, win: *Window, buf: []u8) void { const n = posix.read(win.pty_fd, buf) catch |err| n: { // EIO means the slave side is fully closed: window is done. diff --git a/src/help.zig b/src/help.zig index f2eff5e..bfd98bc 100644 --- a/src/help.zig +++ b/src/help.zig @@ -33,6 +33,7 @@ pub const overview = \\ peek print the session's screen \\ wait block until output matches or settles \\ kill end a session, or all of them + \\ rename rename a session \\ version print the version \\ help [command | topic] this overview, or detailed help \\ @@ -127,6 +128,7 @@ pub const commands = [_]Entry{ \\keys (prefix C-a, control variants match GNU screen): \\ C-a c create a session and focus it \\ C-a k kill the focused session (asks y/n) + \\ C-a r rename the focused session \\ C-a n focus the next session \\ C-a p focus the previous session \\ C-a C-a focus the previously focused session @@ -134,6 +136,8 @@ pub const commands = [_]Entry{ \\ C-a l redraw \\ C-a a send a literal C-a to the application \\ + \\Pressing C-a alone lists these bindings in the bottom bar. + \\ \\Everything else is typed into the focused session. Unlike a \\plain attach, pasted text may contain C-a bytes safely \\(bracketed paste). @@ -241,6 +245,20 @@ pub const commands = [_]Entry{ \\ , }, + .{ + .name = "rename", + .body = + \\usage: boo rename + \\ + \\Rename a session. The running program is unaffected and an + \\attached client stays attached. The old name accepts a + \\unique prefix, like attach. + \\ + \\example: + \\ boo rename work api-server + \\ + , + }, .{ .name = "version", .body = diff --git a/src/main.zig b/src/main.zig index c8e8ee1..5873653 100644 --- a/src/main.zig +++ b/src/main.zig @@ -71,6 +71,7 @@ pub fn main() !void { if (eql(cmd, "peek")) return cmdPeek(alloc, rest); if (eql(cmd, "wait")) return cmdWait(alloc, rest); if (eql(cmd, "kill")) return cmdKill(alloc, rest); + if (eql(cmd, "rename")) return cmdRename(alloc, rest); if (eql(cmd, "version") or eql(cmd, "-V") or eql(cmd, "--version")) return cmdVersion(alloc); if (eql(cmd, "help") or eql(cmd, "-h") or eql(cmd, "--help")) return cmdHelp(alloc, rest); fail(exit_usage, "unknown command '{s}' (run 'boo help')", .{cmd}); @@ -750,6 +751,36 @@ fn cmdKill(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { if (!result.ok) fail(exit_runtime, "{s}", .{result.text}); } +fn cmdRename(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { + var old_arg: ?[]const u8 = null; + var new_arg: ?[]const u8 = null; + for (args) |arg| { + if (isHelpFlag(arg)) return printHelpPage("rename"); + if (arg.len > 0 and arg[0] == '-') { + usageFail("rename", "unknown flag '{s}'", .{arg}); + } else if (old_arg == null) { + old_arg = arg; + } else if (new_arg == null) { + new_arg = arg; + } else { + usageFail("rename", "unexpected argument '{s}'", .{arg}); + } + } + const want = old_arg orelse usageFail("rename", "a session name is required", .{}); + const new_name = new_arg orelse usageFail("rename", "a new session name is required", .{}); + paths.validateName(new_name) catch + usageFail("rename", "invalid session name '{s}'", .{new_name}); + + const dir = try paths.socketDir(alloc); + defer alloc.free(dir); + const name = try resolveSession(alloc, dir, want); + defer alloc.free(name); + + const result = try mustControl(alloc, dir, name, &.{ "rename", new_name }); + defer alloc.free(result.text); + if (!result.ok) fail(exit_runtime, "{s}", .{result.text}); +} + fn cmdVersion(alloc: std.mem.Allocator) !void { try stdoutPrint(alloc, "boo {s}\n", .{version}); } diff --git a/src/ui.zig b/src/ui.zig index 67ae0a7..f8eddb6 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -43,14 +43,18 @@ const render_interval_ms: i64 = 15; // -- Layout ----------------------------------------------------------------- /// Screen geometry: a sidebar on the left, a one-column separator, -/// and the session viewport filling the rest. The viewport always -/// reaches the right edge, so erase-to-end-of-line stays inside it. +/// the session viewport filling the rest, and a full-width status +/// bar on the last row. The viewport always reaches the right edge, +/// so erase-to-end-of-line stays inside it. pub const Layout = struct { rows: u16, cols: u16, /// Sidebar text columns, excluding the separator column. sidebar_w: u16, + /// Each session occupies two sidebar rows: name and title. + pub const entry_rows: u16 = 2; + pub fn init(rows: u16, cols: u16) Layout { // Narrow terminals get a proportionally smaller sidebar; the // viewport keeps at least a sliver so the focused session @@ -63,21 +67,31 @@ pub const Layout = struct { return self.cols -| (self.sidebar_w + 1); } + /// Viewport rows: everything above the status bar. + pub fn viewportRows(self: Layout) u16 { + return self.rows -| 1; + } + /// First viewport column, 0-based. pub fn viewportX(self: Layout) u16 { return self.sidebar_w + 1; } - /// Rows available for session entries between the header and the - /// new-session/status rows. + /// Sidebar rows available for session entries between the header + /// and the new-session row. pub fn listRows(self: Layout) u16 { return self.rows -| 3; } + /// Whole session entries that fit in the list area. + pub fn visibleEntries(self: Layout) usize { + return @max(1, self.listRows() / entry_rows); + } + pub const Hit = union(enum) { header, - /// Index into the visible session list (scroll already applied - /// by the caller). + /// Display row within the visible session list (entry_rows + /// rows per session; scroll applied by the caller). session: struct { row: u16, kill: bool }, new_button, status, @@ -89,12 +103,12 @@ pub const Layout = struct { /// report whether the kill target ('x' in the last column) was hit. pub fn hit(self: Layout, x: u16, y: u16) Hit { if (y >= self.rows or x >= self.cols) return .none; + if (y == self.rows -| 1) return .status; // full-width bar if (x >= self.viewportX()) { return .{ .viewport = .{ .x = x - self.viewportX(), .y = y } }; } if (x >= self.sidebar_w) return .none; // separator column if (y == 0) return .header; - if (y == self.rows -| 1) return .status; if (y == self.rows -| 2) return .new_button; return .{ .session = .{ .row = y - 1, @@ -510,8 +524,10 @@ fn appendClipped( while (used < width) : (used += 1) try out.append(alloc, ' '); } -/// One sidebar session row: marker, name, age, and a kill target in -/// the last column. Exactly `width` display columns plus SGR codes. +/// One sidebar session name row: attached marker, name, age, and a +/// kill target in the last column. Exactly `width` display columns +/// plus SGR codes; the inverse-video highlight alone marks the +/// selected session. pub fn appendSessionRow( alloc: std.mem.Allocator, out: *std.ArrayList(u8), @@ -525,7 +541,9 @@ pub fn appendSessionRow( var scratch: std.ArrayList(u8) = .empty; defer scratch.deinit(alloc); - const marker: u8 = if (selected) '>' else if (entry.attached) '*' else ' '; + // '*': attached by another client. The selected session is + // attached by this UI itself, which is not worth a marker. + const marker: u8 = if (!selected and entry.attached) '*' else ' '; try scratch.append(alloc, marker); if (width >= 12) { @@ -544,6 +562,28 @@ pub fn appendSessionRow( try out.appendSlice(alloc, sgr_reset); } +/// The second sidebar row of a session entry: the window title, dim, +/// indented under the name. Blank when the session has no title. +pub fn appendSessionTitleRow( + alloc: std.mem.Allocator, + out: *std.ArrayList(u8), + entry: Entry, + width: u16, + selected: bool, +) !void { + if (width == 0) return; + if (selected) try out.appendSlice(alloc, style_selected); + try out.appendSlice(alloc, style_dim); + + if (entry.title.len > 0 and width > 2) { + try out.appendSlice(alloc, " "); + try appendClipped(alloc, out, entry.title, width - 2); + } else { + try appendClipped(alloc, out, "", width); + } + try out.appendSlice(alloc, sgr_reset); +} + // -- The UI ------------------------------------------------------------------- var signal_pipe: posix.fd_t = -1; @@ -634,6 +674,10 @@ const Ui = struct { parser: InputParser = .{}, /// Pending kill confirmation: index into sessions. confirm_kill: ?usize = null, + /// Rename input buffer; non-null while the rename prompt is open. + rename_input: ?std.ArrayList(u8) = null, + /// Session index being renamed while the prompt is open. + rename_target: usize = 0, /// Transient status message and its expiry time. message: std.ArrayList(u8) = .empty, message_deadline: i64 = 0, @@ -661,6 +705,7 @@ const Ui = struct { if (self.view) |v| v.destroy(); freeEntries(self.alloc, &self.sessions); if (self.last_name) |n| self.alloc.free(n); + if (self.rename_input) |*input| input.deinit(self.alloc); self.message.deinit(self.alloc); for (self.row_cache.items) |*row| row.deinit(self.alloc); self.row_cache.deinit(self.alloc); @@ -751,7 +796,7 @@ const Ui = struct { const ws = ptypkg.getSize(self.tty) catch return; self.layout = .init(ws.row, ws.col); if (self.view) |v| { - v.resize(self.layout.rows, self.layout.viewportCols()) catch |err| { + v.resize(self.layout.viewportRows(), self.layout.viewportCols()) catch |err| { log.warn("viewport resize failed: {}", .{err}); }; } @@ -773,10 +818,19 @@ const Ui = struct { try h.ui.handleEvent(ev); } }; + // The status bar shows the keybind list while the prefix is + // armed, so arming and disarming both need a repaint. + const was_pending = self.parser.pending_prefix; try self.parser.feed(buf[0..n], Handler{ .ui = self }); + if (self.parser.pending_prefix != was_pending) self.need_render = true; } fn handleEvent(self: *Ui, ev: InputEvent) !void { + // An open rename prompt captures keyboard input. + if (self.rename_input != null) { + if (self.handleRenameEvent(ev)) return; + } + // A pending kill confirmation swallows the next key. if (self.confirm_kill) |idx| { switch (ev) { @@ -820,10 +874,58 @@ const Ui = struct { } } + /// Input while the rename prompt is open edits the new name. + /// Returns true when the event was consumed. + fn handleRenameEvent(self: *Ui, ev: InputEvent) bool { + const input = &(self.rename_input.?); + switch (ev) { + .forward => |bytes| { + // A bare escape cancels; longer escape sequences + // (arrow keys and friends) are ignored. + if (bytes.len > 0 and bytes[0] == 0x1b) { + if (bytes.len == 1) self.cancelRename(); + return true; + } + for (bytes) |byte| switch (byte) { + '\r', '\n' => { + self.commitRename(); + return true; + }, + 0x7f, 0x08 => _ = input.pop(), + 0x03 => { + self.cancelRename(); + return true; + }, + else => { + if (byte >= 0x20 and byte < 0x7f and + input.items.len < paths.max_name_len) + { + input.append(self.alloc, byte) catch {}; + } + }, + }; + self.need_render = true; + return true; + }, + .prefix => { + self.cancelRename(); + return true; + }, + .mouse => |m| { + if (!m.release and !m.isMotion() and !m.isWheel()) { + self.cancelRename(); + } + return true; + }, + .paste, .focus => return true, + } + } + fn handlePrefix(self: *Ui, byte: u8) !void { switch (byte) { 'c', 0x03 => self.createSession(), 'k', 0x0b => self.confirmKill(), + 'r', 0x12 => self.startRename(), 'd', 0x04, 'q' => self.quitting = true, 'n', 0x0e => self.focusOffset(1), 'p', 0x10 => self.focusOffset(-1), @@ -846,9 +948,9 @@ const Ui = struct { }, else => { if (std.ascii.isPrint(byte)) { - self.setMessage("^A {c}? c new k kill n/p d quit", .{byte}); + self.setMessage("^A {c} is not bound (press Ctrl+A alone for keybinds)", .{byte}); } else { - self.setMessage("^A ^{c}? c new k kill n/p d quit", .{byte ^ 0x40}); + self.setMessage("^A ^{c} is not bound (press Ctrl+A alone for keybinds)", .{byte ^ 0x40}); } }, } @@ -888,9 +990,9 @@ const Ui = struct { .viewport => return self.forwardMouse(m), .session => |s| { if (m.release or m.isMotion()) return; - const idx = self.scroll + s.row; + const idx = self.scroll + s.row / Layout.entry_rows; if (idx >= self.sessions.items.len) return; - if (s.kill) { + if (s.kill and s.row % Layout.entry_rows == 0) { self.armKillConfirm(idx); return; } @@ -1137,7 +1239,7 @@ const Ui = struct { self.view = View.create( self.alloc, sock, - self.layout.rows, + self.layout.viewportRows(), self.layout.viewportCols(), ) catch |err| { self.setMessage("attach {s} failed: {s}", .{ name, @errorName(err) }); @@ -1251,6 +1353,72 @@ const Ui = struct { self.need_render = true; } + fn startRename(self: *Ui) void { + const idx = self.selected orelse { + self.setMessage("no session to rename", .{}); + return; + }; + self.confirm_kill = null; + self.rename_target = idx; + var input: std.ArrayList(u8) = .empty; + // Pre-fill with the current name for quick edits. + input.appendSlice(self.alloc, self.sessions.items[idx].name) catch {}; + if (self.rename_input) |*old| old.deinit(self.alloc); + self.rename_input = input; + // The prompt renders from rename_input; a stale transient + // message would cover it up. + self.message.clearRetainingCapacity(); + self.message_deadline = 0; + self.need_render = true; + } + + fn cancelRename(self: *Ui) void { + if (self.rename_input) |*input| input.deinit(self.alloc); + self.rename_input = null; + self.setMessage("rename cancelled", .{}); + } + + /// Ask the daemon to rename the prompt's target session. On + /// success the local entry is patched in place: selection is + /// restored by name on refresh, and the attached view's socket + /// stays connected across the rename. + fn commitRename(self: *Ui) void { + var input = self.rename_input.?; + self.rename_input = null; + defer input.deinit(self.alloc); + const new_name = input.items; + + const idx = self.rename_target; + if (idx >= self.sessions.items.len) return; + const entry = &self.sessions.items[idx]; + if (std.mem.eql(u8, entry.name, new_name)) { + self.need_render = true; + return; + } + paths.validateName(new_name) catch { + self.setMessage("invalid session name '{s}'", .{new_name}); + return; + }; + + const sock = paths.socketPath(self.alloc, self.dir, entry.name) catch return; + defer self.alloc.free(sock); + const result = client.control(self.alloc, sock, &.{ "rename", new_name }) catch { + self.setMessage("rename failed", .{}); + return; + }; + defer self.alloc.free(result.text); + if (!result.ok) { + self.setMessage("{s}", .{result.text}); + return; + } + + self.setMessage("renamed {s} to {s}", .{ entry.name, new_name }); + const owned = self.alloc.dupe(u8, new_name) catch return; + self.alloc.free(entry.name); + entry.name = owned; + self.refreshSessions() catch {}; + } + fn killSession(self: *Ui, idx: usize) void { if (idx >= self.sessions.items.len) return; const name = self.sessions.items[idx].name; @@ -1276,7 +1444,7 @@ const Ui = struct { } fn clampScroll(self: *Ui) void { - const max_scroll = self.sessions.items.len -| self.layout.listRows(); + const max_scroll = self.sessions.items.len -| self.layout.visibleEntries(); if (self.scroll > max_scroll) self.scroll = max_scroll; } @@ -1285,11 +1453,11 @@ const Ui = struct { /// list freely without snapping back to the selection. fn scrollSelectedIntoView(self: *Ui) void { self.clampScroll(); - const list_rows = self.layout.listRows(); + const visible = self.layout.visibleEntries(); const idx = self.selected orelse return; if (idx < self.scroll) self.scroll = idx; - if (list_rows > 0 and idx >= self.scroll + list_rows) { - self.scroll = idx + 1 - list_rows; + if (idx >= self.scroll + visible) { + self.scroll = idx + 1 - visible; } } @@ -1369,9 +1537,10 @@ const Ui = struct { fn cursorSequence(self: *Ui) CursorState { var state: CursorState = .{}; + if (self.renameCursor()) |s| return s; const v = self.liveView() orelse return state; const cursor = &v.term.screens.active.cursor; - const row: usize = @min(cursor.y, self.layout.rows -| 1); + const row: usize = @min(cursor.y, self.layout.viewportRows() -| 1); const col: usize = @min( @as(usize, cursor.x) + self.layout.viewportX(), self.layout.cols -| 1, @@ -1385,13 +1554,36 @@ const Ui = struct { return state; } - /// One full screen row: sidebar columns, separator, then the + /// While the rename prompt is open, the cursor sits at the end + /// of the typed name in the status bar. + fn renameCursor(self: *Ui) ?CursorState { + const input = self.rename_input orelse return null; + if (self.rename_target >= self.sessions.items.len) return null; + var state: CursorState = .{}; + const prompt_len = " rename ".len + + self.sessions.items[self.rename_target].name.len + ": ".len; + const col = @min(prompt_len + input.items.len + 1, self.layout.cols); + const text = std.fmt.bufPrint(&state.pos, "\x1b[{d};{d}H", .{ + self.layout.rows, + col, + }) catch return state; + state.pos_len = text.len; + state.visible = true; + return state; + } + + /// One full screen row. The last row is the full-width status + /// bar; every other row is sidebar columns, separator, then the /// viewport slice. The sidebar segment is always exactly /// sidebar_w columns so the row never bleeds into the viewport. fn composeRow(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; try out.appendSlice(alloc, sgr_reset); + if (y == self.layout.rows -| 1) { + try self.composeStatusRow(out); + return; + } try self.composeSidebarCell(y, out); try out.appendSlice(alloc, style_dim); try out.appendSlice(alloc, "\u{2502}"); @@ -1399,6 +1591,44 @@ const Ui = struct { try self.composeViewportCell(y, out); } + const keybind_bar = + " C-a + c new k kill r rename n/p switch d quit C-a last a literal l redraw"; + + /// The full-width bar on the last screen row: rename prompt, kill + /// confirmation, the keybind list while the prefix is armed, a + /// transient message, or the default hint. + fn composeStatusRow(self: *Ui, out: *std.ArrayList(u8)) !void { + const alloc = self.alloc; + const w = self.layout.cols; + + try out.appendSlice(alloc, style_dim); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(alloc); + + // Prompts outlive transient messages, so they are regenerated + // from their state rather than stored. + if (self.rename_input) |input| { + if (self.rename_target < self.sessions.items.len) { + try text.print(alloc, " rename {s}: {s}", .{ + self.sessions.items[self.rename_target].name, + input.items, + }); + } + } else if (self.confirm_kill) |idx| { + if (idx < self.sessions.items.len) { + try text.print(alloc, " kill {s}? y/n", .{self.sessions.items[idx].name}); + } + } else if (self.parser.pending_prefix) { + try text.appendSlice(alloc, keybind_bar); + } else if (self.message.items.len > 0) { + try text.print(alloc, " {s}", .{self.message.items}); + } else { + try text.appendSlice(alloc, " Press Ctrl+A for keybinds"); + } + try appendClipped(alloc, out, text.items, w); + try out.appendSlice(alloc, sgr_reset); + } + fn composeSidebarCell(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; const l = self.layout; @@ -1417,26 +1647,6 @@ const Ui = struct { return; } - if (y == l.rows -| 1) { - try out.appendSlice(alloc, style_dim); - // A pending confirmation outlives transient messages, so - // the prompt is regenerated rather than stored. - if (self.confirm_kill) |idx| { - var prompt: std.ArrayList(u8) = .empty; - defer prompt.deinit(alloc); - if (idx < self.sessions.items.len) { - try prompt.print(alloc, "kill {s}? y/n", .{self.sessions.items[idx].name}); - } - try appendClipped(alloc, out, prompt.items, w); - } else if (self.message.items.len > 0) { - try appendClipped(alloc, out, self.message.items, w); - } else { - try appendClipped(alloc, out, "^A c new k kill d quit", w); - } - try out.appendSlice(alloc, sgr_reset); - return; - } - if (y == l.rows -| 2) { try out.appendSlice(alloc, style_dim); try appendClipped(alloc, out, " + new session", w); @@ -1444,15 +1654,16 @@ const Ui = struct { return; } - const idx = self.scroll + (y - 1); + const row = y - 1; + const idx = self.scroll + row / Layout.entry_rows; if (idx < self.sessions.items.len) { - try appendSessionRow( - alloc, - out, - self.sessions.items[idx], - w, - self.selected != null and self.selected.? == idx, - ); + const entry = self.sessions.items[idx]; + const selected = self.selected != null and self.selected.? == idx; + if (row % Layout.entry_rows == 0) { + try appendSessionRow(alloc, out, entry, w, selected); + } else { + try appendSessionTitleRow(alloc, out, entry, w, selected); + } return; } @@ -1497,7 +1708,7 @@ const Ui = struct { out: *std.ArrayList(u8), ) !void { const l = self.layout; - const mid = l.rows / 2; + const mid = l.viewportRows() / 2; const text: []const u8 = if (y == mid) line1 else if (y == mid + 1) @@ -1657,12 +1868,16 @@ test "layout: geometry and hit testing" { try std.testing.expectEqual(@as(u16, 24), l.sidebar_w); try std.testing.expectEqual(@as(u16, 75), l.viewportCols()); try std.testing.expectEqual(@as(u16, 25), l.viewportX()); + try std.testing.expectEqual(@as(u16, 23), l.viewportRows()); try std.testing.expectEqual(Layout.Hit.header, l.hit(3, 0)); + // The status bar spans the full width of the last row. try std.testing.expectEqual(Layout.Hit.status, l.hit(3, 23)); + try std.testing.expectEqual(Layout.Hit.status, l.hit(80, 23)); try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 22)); try std.testing.expectEqual(Layout.Hit.none, l.hit(24, 5)); // separator + // Sessions take two display rows: name, then title. const s = l.hit(3, 5); try std.testing.expectEqual(@as(u16, 4), s.session.row); try std.testing.expect(!s.session.kill); @@ -1712,11 +1927,43 @@ test "sidebar session row is exactly the requested width" { try std.testing.expect(std.mem.indexOf(u8, text, "12s") != null); try std.testing.expect(std.mem.endsWith(u8, text, "x ")); - // Selected rows are wrapped in inverse video. + // Selected rows are wrapped in inverse video; the highlight is + // the only selection marker. out.clearRetainingCapacity(); try appendSessionRow(alloc, &out, entry, 24, true); try std.testing.expect(std.mem.startsWith(u8, out.items, style_selected)); - try std.testing.expect(std.mem.indexOf(u8, out.items, ">") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, ">") == null); +} + +test "sidebar title row renders the title dim under the name" { + const alloc = std.testing.allocator; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + + var name_buf: [4]u8 = "work".*; + var title_buf: [9]u8 = "vim notes".*; + const entry: Entry = .{ + .name = &name_buf, + .attached = false, + .idle_ms = 0, + .title = &title_buf, + }; + + try appendSessionTitleRow(alloc, &out, entry, 24, false); + try std.testing.expect(std.mem.startsWith(u8, out.items, style_dim)); + const text = out.items[style_dim.len .. out.items.len - sgr_reset.len]; + try std.testing.expectEqual(@as(usize, 24), text.len); + try std.testing.expectEqualStrings(" vim notes", std.mem.trimRight(u8, text, " ")); + + // Without a title the row is blank but still full width. + var no_title: [0]u8 = .{}; + var bare = entry; + bare.title = &no_title; + out.clearRetainingCapacity(); + try appendSessionTitleRow(alloc, &out, bare, 24, false); + const blank = out.items[style_dim.len .. out.items.len - sgr_reset.len]; + try std.testing.expectEqual(@as(usize, 24), blank.len); + try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, blank, " ").len); } test "appendTermRow renders styled content for one row only" { diff --git a/test/integration.zig b/test/integration.zig index f8ddc28..16de91b 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1002,6 +1002,32 @@ test "agent loop: new, send, wait, peek, kill" { try h.runExit(&.{ "peek", "agent" }, 3); } +test "rename: moves a session to a new name" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("before", &.{"cat"}); + try h.sendLine("before", "RN-MARK"); + const seeded = try h.waitPeekContains("before", "RN-MARK"); + alloc.free(seeded); + + // The screen and the running process survive the rename. + try h.runOk(&.{ "rename", "before", "after" }); + const content = try h.waitPeekContains("after", "RN-MARK"); + alloc.free(content); + try h.runExit(&.{ "peek", "before" }, 3); + + // Name collisions, invalid names, and missing sessions are + // rejected with the documented exit codes. + try h.startDetached("other", &.{"cat"}); + try h.runExit(&.{ "rename", "after", "other" }, 1); + try h.runExit(&.{ "rename", "after", "sp ace" }, 2); + try h.runExit(&.{ "rename", "nosuchzz", "x" }, 3); + try h.runExit(&.{"rename"}, 2); + try h.runExit(&.{ "rename", "after" }, 2); +} + // -- boo ui ------------------------------------------------------------------- fn uiSessionCount(h: *Harness) !usize { @@ -1164,7 +1190,8 @@ test "ui: viewport size tracks the terminal minus the sidebar" { try h.startDetached("rz", &.{"/bin/sh"}); - // 100 columns - 24 sidebar - 1 separator = 75 viewport columns. + // 100 columns - 24 sidebar - 1 separator = 75 viewport columns; + // 24 rows - 1 status bar = 23 viewport rows. var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); defer ui.deinit(); try ui.waitFor("rz"); @@ -1175,7 +1202,7 @@ test "ui: viewport size tracks the terminal minus the sidebar" { defer alloc.free(cmd); try h.sendLine("rz", cmd); - try waitFileEquals(alloc, size_file, "24 75\n"); + try waitFileEquals(alloc, size_file, "23 75\n"); // Resizing the outer terminal resizes the viewport with it. try ui.setSize(30, 120); @@ -1185,7 +1212,7 @@ test "ui: viewport size tracks the terminal minus the sidebar" { std.Thread.sleep(50 * std.time.ns_per_ms); const content = std.fs.cwd().readFileAlloc(alloc, size_file, 4096) catch ""; defer if (content.len > 0) alloc.free(content); - if (std.mem.eql(u8, content, "30 95\n")) break; + if (std.mem.eql(u8, content, "29 95\n")) break; try deadline.tick("viewport resize never reached the session"); } } @@ -1211,6 +1238,56 @@ test "ui: a plain attach steals the focused session" { _ = try thief.waitExit(); } +test "ui: the status bar reveals keybinds and C-a r renames" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("oldname", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("oldname"); + try ui.waitFor("Press Ctrl+A for keybinds"); + + // Arming the prefix swaps the hint for the keybind list. + try ui.send("\x01"); + try ui.waitFor("r rename"); + + // C-a r opens the prompt pre-filled with the old name; erase it + // and type a new one. + try ui.send("r"); + try ui.waitFor("rename oldname:"); + try ui.send("\x7f\x7f\x7f\x7f\x7f\x7f\x7f"); + try ui.send("fresh\r"); + try ui.waitFor("renamed oldname to fresh"); + + // The daemon moved with the name: the old one is gone and the + // sidebar lists the new one. + const ls = try h.run(&.{"ls"}); + defer alloc.free(ls.stdout); + defer alloc.free(ls.stderr); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "fresh") != null); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "oldname") == null); +} + +test "ui: session titles render in the sidebar" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("titled", &.{"/bin/sh"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("titled"); + + // Set the window title via OSC 2. The marker is assembled from a + // variable so the echoed command line cannot match the wait. + try h.sendLine("titled", "T=TITLE; printf \"\\033]2;${T}-MARK\\007\""); + try ui.waitFor("TITLE-MARK"); +} + test "ui without a tty fails cleanly" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); From 00e0268957d5b0fde3283a546ab436c64f43b873 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 19:12:07 +0000 Subject: [PATCH 3/8] feat(ui): activity dots, top new-session button, and esc to cancel the prefix - Replace the per-row idle timer with a green activity dot that shows while a session produced output within the last 2 seconds (the same settle window 'wait --idle' uses). Rows stop re-rendering every second as a side effect. - Drop the 'boo N sessions' header; '+ new session' moves to the top row, freeing a sidebar row for the list. - Esc backs out of an armed C-a prefix, and the keybind bar says so. --- README.md | 6 +-- src/help.zig | 6 ++- src/ui.zig | 126 ++++++++++++++++++++++--------------------- test/integration.zig | 22 +++++--- 4 files changed, 86 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index b4afa1c..b99ae65 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ exactly as a human would see it. - Sessions that survive disconnects: detach with `C-a d`, reattach with `boo attach`. - A full-screen session manager: `boo ui` lists sessions in a sidebar - with their titles and renders the focused one live next to it. Click - to switch, create, kill, or rename sessions; everything also works - from the keyboard. + with their titles and a live activity dot, and renders the focused + one next to it. Click to switch, create, kill, or rename sessions; + 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/help.zig b/src/help.zig index bfd98bc..5273d84 100644 --- a/src/help.zig +++ b/src/help.zig @@ -114,8 +114,9 @@ pub const commands = [_]Entry{ \\usage: boo ui \\ \\Manage sessions in a full-screen interface: a sidebar lists - \\every session and the focused session runs in a viewport on - \\the right, rendered live from terminal state. + \\every session (window title underneath, a green dot while + \\output is arriving) and the focused session runs in a + \\viewport on the right, rendered live from terminal state. \\ \\mouse: \\ click a session focus it (steals politely, like attach) @@ -135,6 +136,7 @@ pub const commands = [_]Entry{ \\ C-a d quit the UI (sessions keep running) \\ C-a l redraw \\ C-a a send a literal C-a to the application + \\ C-a Esc cancel the armed prefix \\ \\Pressing C-a alone lists these bindings in the bottom bar. \\ diff --git a/src/ui.zig b/src/ui.zig index f8eddb6..c63cf40 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -34,6 +34,9 @@ const log = std.log.scoped(.ui); /// Refresh cadence for the sidebar's session list. const refresh_interval_ms: i64 = 1000; +/// A session shows its activity dot while output landed within this +/// window (matches the settle threshold of 'boo wait --idle'). +const active_threshold_ms: i64 = 2000; /// Transient status messages stay visible this long. const message_ttl_ms: i64 = 4000; /// Render coalescing: at most one repaint per interval while output @@ -77,10 +80,10 @@ pub const Layout = struct { return self.sidebar_w + 1; } - /// Sidebar rows available for session entries between the header - /// and the new-session row. + /// Sidebar rows available for session entries between the + /// new-session row and the status bar. pub fn listRows(self: Layout) u16 { - return self.rows -| 3; + return self.rows -| 2; } /// Whole session entries that fit in the list area. @@ -89,7 +92,6 @@ pub const Layout = struct { } pub const Hit = union(enum) { - header, /// Display row within the visible session list (entry_rows /// rows per session; scroll applied by the caller). session: struct { row: u16, kill: bool }, @@ -108,8 +110,7 @@ pub const Layout = struct { return .{ .viewport = .{ .x = x - self.viewportX(), .y = y } }; } if (x >= self.sidebar_w) return .none; // separator column - if (y == 0) return .header; - if (y == self.rows -| 2) return .new_button; + if (y == 0) return .new_button; return .{ .session = .{ .row = y - 1, .kill = self.sidebar_w >= 12 and x == self.sidebar_w - 2, @@ -192,7 +193,11 @@ pub const InputParser = struct { self.pending_prefix = false; i += 1; start = i; - try handler.event(.{ .prefix = byte }); + // Esc backs out of the armed prefix; the byte is + // consumed without becoming a command. + if (byte != 0x1b) { + try handler.event(.{ .prefix = byte }); + } continue; } @@ -478,6 +483,9 @@ pub const Entry = struct { name: []u8, attached: bool, idle_ms: i64, + /// Output landed within the activity window: the session is + /// doing something right now. + active: bool, /// Owned by the list; sanitized to printable ASCII. title: []u8, }; @@ -490,21 +498,14 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { entries.deinit(alloc); } -/// Short fixed-width ages for the sidebar: 3s, 12m, 99h. -pub fn fmtAge(buf: []u8, ms: i64) []const u8 { - const s = @divTrunc(@max(0, ms), std.time.ms_per_s); - if (s < 60) return std.fmt.bufPrint(buf, "{d}s", .{s}) catch "?"; - if (s < 3600) return std.fmt.bufPrint(buf, "{d}m", .{@divTrunc(s, 60)}) catch "?"; - const h = @min(99, @divTrunc(s, 3600)); - return std.fmt.bufPrint(buf, "{d}h", .{h}) catch "?"; -} - // -- Sidebar rendering -------------------------------------------------------- const sgr_reset = "\x1b[0m"; -const style_header = "\x1b[1m"; const style_selected = "\x1b[7m"; const style_dim = "\x1b[2m"; +/// The activity dot: green, then back to the default foreground so +/// the row's dim/inverse state is preserved. +const active_dot = "\x1b[32m\u{25cf}\x1b[39m"; /// Append `text` clipped to `width` columns, then pad with spaces to /// exactly `width`. Only printable ASCII reaches the writer, so byte @@ -524,10 +525,10 @@ fn appendClipped( while (used < width) : (used += 1) try out.append(alloc, ' '); } -/// One sidebar session name row: attached marker, name, age, and a -/// kill target in the last column. Exactly `width` display columns -/// plus SGR codes; the inverse-video highlight alone marks the -/// selected session. +/// One sidebar session name row: attached marker, name, an activity +/// dot while the session is producing output, and a kill target in +/// the last column. Exactly `width` display columns plus SGR codes; +/// the inverse-video highlight alone marks the selected session. pub fn appendSessionRow( alloc: std.mem.Allocator, out: *std.ArrayList(u8), @@ -538,27 +539,25 @@ pub fn appendSessionRow( if (width == 0) return; if (selected) try out.appendSlice(alloc, style_selected); - var scratch: std.ArrayList(u8) = .empty; - defer scratch.deinit(alloc); - // '*': attached by another client. The selected session is // attached by this UI itself, which is not worth a marker. const marker: u8 = if (!selected and entry.attached) '*' else ' '; - try scratch.append(alloc, marker); + try out.append(alloc, marker); if (width >= 12) { - // " x": age right-aligned, kill target last. - var age_buf: [8]u8 = undefined; - const age = fmtAge(&age_buf, entry.idle_ms); - const name_w = width - 2 - age.len - 2 - 1; - try appendClipped(alloc, &scratch, entry.name, name_w); - try scratch.append(alloc, ' '); - try scratch.appendSlice(alloc, age); - try scratch.appendSlice(alloc, " x "); + // " x ": activity dot, kill target last. + const name_w = width - 1 - 1 - 1 - 3; + try appendClipped(alloc, out, entry.name, name_w); + try out.append(alloc, ' '); + if (entry.active) { + try out.appendSlice(alloc, active_dot); + } else { + try out.append(alloc, ' '); + } + try out.appendSlice(alloc, " x "); } else { - try appendClipped(alloc, &scratch, entry.name, width - 1); + try appendClipped(alloc, out, entry.name, width - 1); } - try out.appendSlice(alloc, scratch.items[0..@min(scratch.items.len, width)]); try out.appendSlice(alloc, sgr_reset); } @@ -1167,6 +1166,7 @@ const Ui = struct { .name = try self.alloc.dupe(u8, name), .attached = info.attached, .idle_ms = info.idle_ms, + .active = info.out_idle_ms < active_threshold_ms, .title = try self.alloc.dupe(u8, info.title), }); } @@ -1592,7 +1592,7 @@ const Ui = struct { } const keybind_bar = - " C-a + c new k kill r rename n/p switch d quit C-a last a literal l redraw"; + " C-a + c new k kill r rename n/p switch d quit C-a last a literal l redraw esc cancel"; /// The full-width bar on the last screen row: rename prompt, kill /// confirmation, the keybind list while the prefix is armed, a @@ -1635,19 +1635,6 @@ const Ui = struct { const w = l.sidebar_w; if (y == 0) { - try out.appendSlice(alloc, style_header); - var text: std.ArrayList(u8) = .empty; - defer text.deinit(alloc); - try text.print(alloc, " boo {d} session{s}", .{ - self.sessions.items.len, - if (self.sessions.items.len == 1) "" else "s", - }); - try appendClipped(alloc, out, text.items, w); - try out.appendSlice(alloc, sgr_reset); - return; - } - - if (y == l.rows -| 2) { try out.appendSlice(alloc, style_dim); try appendClipped(alloc, out, " + new session", w); try out.appendSlice(alloc, sgr_reset); @@ -1805,6 +1792,20 @@ test "parser: prefix split across feeds" { try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); } +test "parser: esc backs out of an armed prefix" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x01\x1b", &h); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + try std.testing.expect(!p.pending_prefix); + // The prefix is disarmed: the next byte is plain input again. + try p.feed("x", &h); + try std.testing.expectEqualStrings("x", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + test "parser: sgr mouse press and release" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); @@ -1869,12 +1870,13 @@ test "layout: geometry and hit testing" { try std.testing.expectEqual(@as(u16, 75), l.viewportCols()); try std.testing.expectEqual(@as(u16, 25), l.viewportX()); try std.testing.expectEqual(@as(u16, 23), l.viewportRows()); + try std.testing.expectEqual(@as(usize, 11), l.visibleEntries()); - try std.testing.expectEqual(Layout.Hit.header, l.hit(3, 0)); - // The status bar spans the full width of the last row. + // The new-session button is the top row; the status bar spans + // the full width of the last row. + try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 0)); try std.testing.expectEqual(Layout.Hit.status, l.hit(3, 23)); try std.testing.expectEqual(Layout.Hit.status, l.hit(80, 23)); - try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 22)); try std.testing.expectEqual(Layout.Hit.none, l.hit(24, 5)); // separator // Sessions take two display rows: name, then title. @@ -1897,14 +1899,6 @@ test "layout: narrow terminals shrink the sidebar" { try std.testing.expect(l.viewportCols() > 0); } -test "fmtAge" { - var buf: [8]u8 = undefined; - try std.testing.expectEqualStrings("0s", fmtAge(&buf, 1)); - try std.testing.expectEqualStrings("59s", fmtAge(&buf, 59_999)); - try std.testing.expectEqualStrings("3m", fmtAge(&buf, 3 * 60_000)); - try std.testing.expectEqualStrings("99h", fmtAge(&buf, 1000 * 3_600_000)); -} - test "sidebar session row is exactly the requested width" { const alloc = std.testing.allocator; var out: std.ArrayList(u8) = .empty; @@ -1916,17 +1910,24 @@ test "sidebar session row is exactly the requested width" { .name = &name_buf, .attached = false, .idle_ms = 12_000, + .active = false, .title = &title_buf, }; + // An idle row is pure ASCII: exactly `width` columns and bytes. try appendSessionRow(alloc, &out, entry, 24, false); - // Strip SGR wrapping: not selected, so only the trailing reset. const text = out.items[0 .. out.items.len - sgr_reset.len]; try std.testing.expectEqual(@as(usize, 24), text.len); try std.testing.expect(std.mem.indexOf(u8, text, "work1234") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "12s") != null); try std.testing.expect(std.mem.endsWith(u8, text, "x ")); + // An active session carries the green activity dot. + var live = entry; + live.active = true; + out.clearRetainingCapacity(); + try appendSessionRow(alloc, &out, live, 24, false); + try std.testing.expect(std.mem.indexOf(u8, out.items, active_dot) != null); + // Selected rows are wrapped in inverse video; the highlight is // the only selection marker. out.clearRetainingCapacity(); @@ -1946,6 +1947,7 @@ test "sidebar title row renders the title dim under the name" { .name = &name_buf, .attached = false, .idle_ms = 0, + .active = false, .title = &title_buf, }; diff --git a/test/integration.zig b/test/integration.zig index 16de91b..d39e4fe 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1078,6 +1078,10 @@ test "ui: sidebar lists sessions and the focused session renders in the viewport try ui.waitFor("AA-TYPED-MARK"); const peeked = try h.waitPeekContains("aa", "AA-TYPED-MARK"); defer alloc.free(peeked); + + // The session just produced output, so its sidebar row carries + // the activity dot. + try ui.waitFor("\xe2\x97\x8f"); } test "ui: clicking a session in the sidebar focuses it" { @@ -1115,10 +1119,10 @@ test "ui: create and kill sessions from the ui" { defer ui.deinit(); try ui.waitFor("keep2"); - // C-a c creates a session (named after the cwd or the creating - // pid) and focuses it. - try ui.send("\x01c"); - try ui.waitFor("3 sessions"); + // Clicking '+ new session' (the top sidebar row) creates a + // session (named after the cwd or the creating pid) and focuses + // it. + try ui.send("\x1b[<0;5;1M\x1b[<0;5;1m"); try waitUiSessionCount(&h, 3); // C-a k asks for confirmation, then kills the focused (new) @@ -1126,7 +1130,6 @@ test "ui: create and kill sessions from the ui" { try ui.send("\x01k"); try ui.waitFor("? y/n"); try ui.send("y"); - try ui.waitFor("2 sessions"); try waitUiSessionCount(&h, 2); // The pre-existing sessions survived. @@ -1250,13 +1253,18 @@ test "ui: the status bar reveals keybinds and C-a r renames" { try ui.waitFor("oldname"); try ui.waitFor("Press Ctrl+A for keybinds"); - // Arming the prefix swaps the hint for the keybind list. + // Arming the prefix swaps the hint for the keybind list; Esc + // backs out and the hint returns. try ui.send("\x01"); try ui.waitFor("r rename"); + try ui.waitFor("esc cancel"); + ui.clearOutput(); + try ui.send("\x1b"); + try ui.waitFor("Press Ctrl+A for keybinds"); // C-a r opens the prompt pre-filled with the old name; erase it // and type a new one. - try ui.send("r"); + try ui.send("\x01r"); try ui.waitFor("rename oldname:"); try ui.send("\x7f\x7f\x7f\x7f\x7f\x7f\x7f"); try ui.send("fresh\r"); From de2ba5f3ebe2e1c55889a0a7ef50643da78437da Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 19:38:14 +0000 Subject: [PATCH 4/8] feat(ui): mouse text selection, cleaner armed-prefix input, drop activity dot Dragging in the viewport now selects text when the focused application has not requested mouse reporting: the selection is highlighted in reverse video and copied on release via OSC 52, so it works over SSH and through nested multiplexers. While the C-a prefix is armed, an escape sequence (mouse click, arrow key) cancels the prefix and is reparsed instead of leaking its tail bytes into the focused session's pty. The sidebar activity dot is gone; the title row already shows what a session is doing. The status hint is now 'Keybinds: Ctrl+A' and the keybind bar no longer repeats the prefix. --- README.md | 6 +- src/help.zig | 10 +- src/ui.zig | 219 +++++++++++++++++++++++++++++++++++-------- test/integration.zig | 35 ++++++- 4 files changed, 219 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b99ae65..7694dcb 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ exactly as a human would see it. - Sessions that survive disconnects: detach with `C-a d`, reattach with `boo attach`. - A full-screen session manager: `boo ui` lists sessions in a sidebar - with their titles and a live activity dot, and renders the focused - one next to it. Click to switch, create, kill, or rename sessions; - everything also works from the keyboard. + 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. - 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/help.zig b/src/help.zig index 5273d84..37aa517 100644 --- a/src/help.zig +++ b/src/help.zig @@ -114,9 +114,9 @@ pub const commands = [_]Entry{ \\usage: boo ui \\ \\Manage sessions in a full-screen interface: a sidebar lists - \\every session (window title underneath, a green dot while - \\output is arriving) and the focused session runs in a - \\viewport on the right, rendered live from terminal state. + \\every session (window title underneath) and the focused + \\session runs in a viewport on the right, rendered live from + \\terminal state. \\ \\mouse: \\ click a session focus it (steals politely, like attach) @@ -124,7 +124,9 @@ pub const commands = [_]Entry{ \\ click + new session start a session running $SHELL \\ scroll the sidebar scroll the session list \\ in the viewport forwarded to the application when it - \\ asked for mouse reporting + \\ asked for mouse reporting; otherwise + \\ dragging selects text and copies it on + \\ release (OSC 52) \\ \\keys (prefix C-a, control variants match GNU screen): \\ C-a c create a session and focus it diff --git a/src/ui.zig b/src/ui.zig index c63cf40..77b7055 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -34,9 +34,6 @@ const log = std.log.scoped(.ui); /// Refresh cadence for the sidebar's session list. const refresh_interval_ms: i64 = 1000; -/// A session shows its activity dot while output landed within this -/// window (matches the settle threshold of 'boo wait --idle'). -const active_threshold_ms: i64 = 2000; /// Transient status messages stay visible this long. const message_ttl_ms: i64 = 4000; /// Render coalescing: at most one repaint per interval while output @@ -191,13 +188,21 @@ pub const InputParser = struct { if (self.pending_prefix) { self.pending_prefix = false; + if (byte == 0x1b) { + // Esc backs out of the armed prefix. A lone Esc + // is the cancel key and is consumed; when more + // bytes follow immediately it starts a key or + // mouse sequence, which must be reprocessed so + // its tail is not typed into the application. + if (i + 1 == input.len) { + i += 1; + } + start = i; + continue; + } i += 1; start = i; - // Esc backs out of the armed prefix; the byte is - // consumed without becoming a command. - if (byte != 0x1b) { - try handler.event(.{ .prefix = byte }); - } + try handler.event(.{ .prefix = byte }); continue; } @@ -483,9 +488,6 @@ pub const Entry = struct { name: []u8, attached: bool, idle_ms: i64, - /// Output landed within the activity window: the session is - /// doing something right now. - active: bool, /// Owned by the list; sanitized to printable ASCII. title: []u8, }; @@ -503,9 +505,6 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { const sgr_reset = "\x1b[0m"; const style_selected = "\x1b[7m"; const style_dim = "\x1b[2m"; -/// The activity dot: green, then back to the default foreground so -/// the row's dim/inverse state is preserved. -const active_dot = "\x1b[32m\u{25cf}\x1b[39m"; /// Append `text` clipped to `width` columns, then pad with spaces to /// exactly `width`. Only printable ASCII reaches the writer, so byte @@ -525,10 +524,10 @@ fn appendClipped( while (used < width) : (used += 1) try out.append(alloc, ' '); } -/// One sidebar session name row: attached marker, name, an activity -/// dot while the session is producing output, and a kill target in -/// the last column. Exactly `width` display columns plus SGR codes; -/// the inverse-video highlight alone marks the selected session. +/// One sidebar session name row: attached marker, name, and a kill +/// target in the last column. Exactly `width` display columns plus +/// SGR codes; the inverse-video highlight alone marks the selected +/// session. pub fn appendSessionRow( alloc: std.mem.Allocator, out: *std.ArrayList(u8), @@ -545,15 +544,9 @@ pub fn appendSessionRow( try out.append(alloc, marker); if (width >= 12) { - // " x ": activity dot, kill target last. - const name_w = width - 1 - 1 - 1 - 3; + // " x ": kill target in the last columns. + const name_w = width - 1 - 3; try appendClipped(alloc, out, entry.name, name_w); - try out.append(alloc, ' '); - if (entry.active) { - try out.appendSlice(alloc, active_dot); - } else { - try out.append(alloc, ' '); - } try out.appendSlice(alloc, " x "); } else { try appendClipped(alloc, out, entry.name, width - 1); @@ -694,12 +687,21 @@ const Ui = struct { mouse_pressed: bool = false, mouse_last_cell: ?vt.Coordinate = null, + /// Viewport text selection in viewport cell coordinates, used + /// when the focused application has not requested mouse + /// reporting. Anchor is where the drag started; head follows the + /// pointer. Both ends are inclusive. + select_anchor: ?CellPos = null, + select_head: CellPos = .{ .x = 0, .y = 0 }, + /// Incremented on every attach; detects view switches that happen /// between poll() and the socket read. view_gen: u64 = 0, quitting: bool = false, + const CellPos = struct { x: u16, y: u16 }; + fn deinit(self: *Ui) void { if (self.view) |v| v.destroy(); freeEntries(self.alloc, &self.sessions); @@ -799,6 +801,9 @@ const Ui = struct { log.warn("viewport resize failed: {}", .{err}); }; } + // Cell coordinates shift with the layout, so any in-progress + // selection no longer points at the text the user dragged over. + self.select_anchor = null; self.full_render = true; self.need_render = true; } @@ -967,6 +972,12 @@ const Ui = struct { self.need_render = true; } + // An in-progress viewport selection captures the drag and the + // release wherever the pointer wanders. + if (self.select_anchor != null and !m.isWheel() and (m.isMotion() or m.release)) { + return self.dragSelection(m, x -| self.layout.viewportX(), y); + } + if (m.isWheel() and !m.release) { switch (self.layout.hit(x, y)) { .viewport => return self.forwardMouse(m), @@ -986,7 +997,19 @@ const Ui = struct { } switch (self.layout.hit(x, y)) { - .viewport => return self.forwardMouse(m), + .viewport => |cell| { + // Applications that asked for mouse reporting get the + // events; otherwise a left press starts a selection. + const v = self.liveView() orelse return; + if (v.term.flags.mouse_event != .none) return self.forwardMouse(m); + if (m.release or m.isMotion() or m.code & 3 != 0) return; + self.select_anchor = .{ + .x = @min(cell.x, v.term.cols -| 1), + .y = @min(cell.y, v.term.rows -| 1), + }; + self.select_head = self.select_anchor.?; + self.need_render = true; + }, .session => |s| { if (m.release or m.isMotion()) return; const idx = self.scroll + s.row / Layout.entry_rows; @@ -1063,6 +1086,80 @@ const Ui = struct { if (encoded.len > 0) v.sendInput(encoded) catch self.markViewLost(); } + /// Update an in-progress selection from a drag or release. On + /// release the selected text is copied to the clipboard. + fn dragSelection(self: *Ui, m: Mouse, x: u16, y: u16) void { + const v = self.liveView() orelse { + self.select_anchor = null; + return; + }; + const head: CellPos = .{ + .x = @min(x, v.term.cols -| 1), + .y = @min(y, v.term.rows -| 1), + }; + if (head.x != self.select_head.x or head.y != self.select_head.y) { + self.select_head = head; + self.need_render = true; + } + if (!m.release) return; + + const anchor = self.select_anchor.?; + if (anchor.x != self.select_head.x or anchor.y != self.select_head.y) { + self.copySelection(v); + } + self.select_anchor = null; + self.need_render = true; + } + + /// The selection's inclusive span on viewport row `y`, or null + /// when the row is outside the selection. + fn selectionSpan(self: *Ui, y: u16, cols: u16) ?struct { x0: u16, x1: u16 } { + const anchor = self.select_anchor orelse return null; + if (cols == 0) return null; + var s = anchor; + var e = self.select_head; + if (e.y < s.y or (e.y == s.y and e.x < s.x)) std.mem.swap(CellPos, &s, &e); + if (y < s.y or y > e.y) return null; + const x0: u16 = if (y == s.y) @min(s.x, cols - 1) else 0; + const x1: u16 = if (y == e.y) @min(e.x, cols - 1) else cols - 1; + if (x0 > x1) return null; + return .{ .x0 = x0, .x1 = x1 }; + } + + /// Copy the selected viewport text to the clipboard via OSC 52, + /// which works over SSH and through nested multiplexers. + fn copySelection(self: *Ui, v: *View) void { + const alloc = self.alloc; + + var s = self.select_anchor.?; + var e = self.select_head; + 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; + + var formatter: vt.formatter.ScreenFormatter = .init(screen, .plain); + formatter.content = .{ .selection = vt.Selection.init(start, end, false) }; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + aw.writer.print("{f}", .{formatter}) catch return; + const text = aw.writer.buffered(); + if (text.len == 0) return; + + const encoder = std.base64.standard.Encoder; + var seq: std.ArrayList(u8) = .empty; + defer seq.deinit(alloc); + seq.appendSlice(alloc, "\x1b]52;c;") catch return; + const b64 = seq.addManyAsSlice(alloc, encoder.calcSize(text.len)) catch return; + _ = encoder.encode(b64, text); + seq.appendSlice(alloc, "\x07") catch return; + protocol.writeAll(1, seq.items) catch {}; + + self.setMessage("copied {d} characters", .{text.len}); + } + fn sgrButton(m: Mouse) ?vt.input.MouseButton { if (m.isWheel()) { return if (m.code & 1 != 0) .five else .four; @@ -1166,7 +1263,6 @@ const Ui = struct { .name = try self.alloc.dupe(u8, name), .attached = info.attached, .idle_ms = info.idle_ms, - .active = info.out_idle_ms < active_threshold_ms, .title = try self.alloc.dupe(u8, info.title), }); } @@ -1245,6 +1341,7 @@ const Ui = struct { self.setMessage("attach {s} failed: {s}", .{ name, @errorName(err) }); return; }; + self.select_anchor = null; self.view_gen += 1; self.full_render = true; self.need_render = true; @@ -1592,7 +1689,7 @@ const Ui = struct { } const keybind_bar = - " C-a + c new k kill r rename n/p switch d quit C-a last a literal l redraw esc cancel"; + " c new k kill r rename n/p switch d quit C-a last a literal l redraw esc cancel"; /// The full-width bar on the last screen row: rename prompt, kill /// confirmation, the keybind list while the prefix is armed, a @@ -1623,7 +1720,7 @@ const Ui = struct { } else if (self.message.items.len > 0) { try text.print(alloc, " {s}", .{self.message.items}); } else { - try text.appendSlice(alloc, " Press Ctrl+A for keybinds"); + try text.appendSlice(alloc, " Keybinds: Ctrl+A"); } try appendClipped(alloc, out, text.items, w); try out.appendSlice(alloc, sgr_reset); @@ -1685,6 +1782,18 @@ const Ui = struct { } try out.appendSlice(alloc, sgr_reset); try out.appendSlice(alloc, "\x1b[K"); + + // An in-progress mouse selection is highlighted by repainting + // the selected cells in reverse video over the row content. + if (self.selectionSpan(y, v.term.cols)) |span| { + try out.print(alloc, "\x1b[{d};{d}H", .{ + y + 1, + self.layout.viewportX() + span.x0 + 1, + }); + try out.appendSlice(alloc, style_selected); + try appendPlainSpan(alloc, &v.term, y, span.x0, span.x1, out); + try out.appendSlice(alloc, sgr_reset); + } } fn composeEmptyRow( @@ -1743,6 +1852,30 @@ pub fn appendTermRow( } } +/// Append one row's cells in [x0, x1] inclusive as plain text, with +/// trailing blanks trimmed. Used to repaint the selection highlight +/// over already-rendered row content. +fn appendPlainSpan( + alloc: std.mem.Allocator, + term: *vt.Terminal, + y: u16, + x0: u16, + x1: u16, + 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; + + var formatter: vt.formatter.ScreenFormatter = .init(screen, .plain); + formatter.content = .{ .selection = vt.Selection.init(start, end, false) }; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + aw.writer.print("{f}", .{formatter}) catch return error.OutOfMemory; + try out.appendSlice(alloc, aw.writer.buffered()); +} + // -- Tests -------------------------------------------------------------------- const TestHandler = struct { @@ -1806,6 +1939,23 @@ test "parser: esc backs out of an armed prefix" { try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } +test "parser: a mouse click while the prefix is armed cancels it cleanly" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Esc with trailing bytes is the start of a sequence, not a lone + // cancel: the sequence must parse instead of leaking into the pty. + try p.feed("\x01\x1b[<0;5;7M", &h); + try std.testing.expect(!p.pending_prefix); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + const m = h.events.items[0].mouse; + try std.testing.expectEqual(@as(u16, 0), m.code); + try std.testing.expectEqual(@as(u16, 5), m.x); + try std.testing.expectEqual(@as(u16, 7), m.y); + try std.testing.expect(!m.release); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + test "parser: sgr mouse press and release" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); @@ -1910,7 +2060,6 @@ test "sidebar session row is exactly the requested width" { .name = &name_buf, .attached = false, .idle_ms = 12_000, - .active = false, .title = &title_buf, }; @@ -1921,13 +2070,6 @@ test "sidebar session row is exactly the requested width" { try std.testing.expect(std.mem.indexOf(u8, text, "work1234") != null); try std.testing.expect(std.mem.endsWith(u8, text, "x ")); - // An active session carries the green activity dot. - var live = entry; - live.active = true; - out.clearRetainingCapacity(); - try appendSessionRow(alloc, &out, live, 24, false); - try std.testing.expect(std.mem.indexOf(u8, out.items, active_dot) != null); - // Selected rows are wrapped in inverse video; the highlight is // the only selection marker. out.clearRetainingCapacity(); @@ -1947,7 +2089,6 @@ test "sidebar title row renders the title dim under the name" { .name = &name_buf, .attached = false, .idle_ms = 0, - .active = false, .title = &title_buf, }; diff --git a/test/integration.zig b/test/integration.zig index d39e4fe..fa76aff 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1078,10 +1078,35 @@ test "ui: sidebar lists sessions and the focused session renders in the viewport try ui.waitFor("AA-TYPED-MARK"); const peeked = try h.waitPeekContains("aa", "AA-TYPED-MARK"); defer alloc.free(peeked); +} + +test "ui: dragging in the viewport selects text and copies it via osc 52" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("cp", &.{"cat"}); - // The session just produced output, so its sidebar row carries - // the activity dot. - try ui.waitFor("\xe2\x97\x8f"); + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("+ new session"); + + // The echoed line lands on the session's first row, rendered at + // screen row 1 starting at column 26 (24-column sidebar plus the + // separator). + try h.sendLine("cp", "COPYTEXT"); + try ui.waitFor("COPYTEXT"); + + // Press on the C, drag right, release on the final T. cat never + // asked for mouse reporting, so the UI selects instead of + // forwarding, then copies as OSC 52 with base64("COPYTEXT"). + ui.clearOutput(); + try ui.send("\x1b[<0;26;1M"); + try ui.send("\x1b[<32;30;1M"); + try ui.send("\x1b[<32;33;1M"); + try ui.send("\x1b[<0;33;1m"); + try ui.waitFor("\x1b]52;c;Q09QWVRFWFQ="); + try ui.waitFor("copied"); } test "ui: clicking a session in the sidebar focuses it" { @@ -1251,7 +1276,7 @@ test "ui: the status bar reveals keybinds and C-a r renames" { var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); defer ui.deinit(); try ui.waitFor("oldname"); - try ui.waitFor("Press Ctrl+A for keybinds"); + try ui.waitFor("Keybinds: Ctrl+A"); // Arming the prefix swaps the hint for the keybind list; Esc // backs out and the hint returns. @@ -1260,7 +1285,7 @@ test "ui: the status bar reveals keybinds and C-a r renames" { try ui.waitFor("esc cancel"); ui.clearOutput(); try ui.send("\x1b"); - try ui.waitFor("Press Ctrl+A for keybinds"); + try ui.waitFor("Keybinds: Ctrl+A"); // C-a r opens the prompt pre-filled with the old name; erase it // and type a new one. From f16a886a8461a6b4120d60b4c2c2cfe3f6081350 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 19:54:55 +0000 Subject: [PATCH 5/8] test(test): cover native mouse forwarding through the attach replay A session that enabled button tracking and SGR before the UI attached must get clicks forwarded with viewport-relative coordinates rather than starting a UI selection: the attach replay carries the mouse reporting modes into the view terminal. --- test/integration.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/integration.zig b/test/integration.zig index fa76aff..205329c 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1109,6 +1109,32 @@ test "ui: dragging in the viewport selects text and copies it via osc 52" { try ui.waitFor("copied"); } +test "ui: mouse events forward natively when the application asks for them" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // The session enables button tracking + SGR before the UI + // attaches, so the attach replay must carry the modes to the + // view terminal. cat -v makes the forwarded bytes visible. + try h.startDetached("fwd", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[?1002h\\033[?1006h'; echo RAWREADY; exec cat -v", + }); + const seeded = try h.waitPeekContains("fwd", "RAWREADY"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("RAWREADY"); + + // A click at screen (30, 3) is viewport cell (4, 2); the app gets + // SGR press + release with viewport-relative coordinates instead + // of a UI selection. + try ui.send("\x1b[<0;30;3M\x1b[<0;30;3m"); + try ui.waitFor("^[[<0;5;3M^[[<0;5;3m"); +} + test "ui: clicking a session in the sidebar focuses it" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); From 226f15c499bd76eb669f7915c5c811c78e3d8cc3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 21:30:29 +0000 Subject: [PATCH 6/8] feat(ui): ghost empty state, sidebar gap, and right-edge erase fix The no-sessions view now shows the boo wordmark and ghost with a keybind hint, and a gap row separates the new-session button from the list. composeViewportCell erased the row after drawing it, which ate the last cell of any row touching the terminal's right edge: the cursor rests on that cell in the pending-wrap state and EL erases from the cursor inclusive. Erase first, then draw. The integration harness gains a renderScreen helper that replays captured output through ghostty-vt, so tests can assert on the rendered screen instead of raw byte streams. --- build.zig | 8 ++++ src/ui.zig | 87 +++++++++++++++++++++++++++++++++++++------- test/integration.zig | 85 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 160 insertions(+), 20 deletions(-) diff --git a/build.zig b/build.zig index 37be2d7..9e9f335 100644 --- a/build.zig +++ b/build.zig @@ -53,6 +53,14 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); integration_mod.addOptions("build_options", test_opts); + // The tests render captured client output through a terminal + // emulator to assert what a user would actually see. + if (b.lazyDependency("ghostty", .{ + .target = target, + .optimize = optimize, + })) |dep| { + integration_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); + } const integration_tests = b.addTest(.{ .root_module = integration_mod }); const run_integration_tests = b.addRunArtifact(integration_tests); diff --git a/src/ui.zig b/src/ui.zig index 77b7055..db5e8a8 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -55,6 +55,10 @@ pub const Layout = struct { /// Each session occupies two sidebar rows: name and title. pub const entry_rows: u16 = 2; + /// Sidebar rows above the session list: the new-session button + /// and a separating blank row. + pub const list_top: u16 = 2; + pub fn init(rows: u16, cols: u16) Layout { // Narrow terminals get a proportionally smaller sidebar; the // viewport keeps at least a sliver so the focused session @@ -78,9 +82,9 @@ pub const Layout = struct { } /// Sidebar rows available for session entries between the - /// new-session row and the status bar. + /// new-session button (plus its gap row) and the status bar. pub fn listRows(self: Layout) u16 { - return self.rows -| 2; + return self.rows -| (list_top + 1); } /// Whole session entries that fit in the list area. @@ -108,8 +112,9 @@ pub const Layout = struct { } if (x >= self.sidebar_w) return .none; // separator column if (y == 0) return .new_button; + if (y < list_top) return .none; // gap under the button return .{ .session = .{ - .row = y - 1, + .row = y - list_top, .kill = self.sidebar_w >= 12 and x == self.sidebar_w - 2, } }; } @@ -1737,8 +1742,13 @@ const Ui = struct { try out.appendSlice(alloc, sgr_reset); return; } + if (y < Layout.list_top) { + // Blank gap between the button and the session list. + try appendClipped(alloc, out, "", w); + return; + } - const row = y - 1; + const row = y - Layout.list_top; const idx = self.scroll + row / Layout.entry_rows; if (idx < self.sessions.items.len) { const entry = self.sessions.items[idx]; @@ -1757,9 +1767,14 @@ const Ui = struct { fn composeViewportCell(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; + // Erase before drawing. Erasing afterwards would eat the last + // cell of a row that touches the terminal's right edge: the + // cursor rests on that cell in the pending-wrap state, and EL + // erases from the cursor inclusive. + try out.appendSlice(alloc, "\x1b[K"); + const v = self.view orelse { - try self.composeEmptyRow(y, "no sessions", "press C-a c or click + new session", out); - try out.appendSlice(alloc, "\x1b[K"); + try self.composeNoSessions(y, out); return; }; @@ -1767,12 +1782,10 @@ const Ui = struct { .live => {}, .stolen => { try self.composeEmptyRow(y, "attached elsewhere", "click the session to steal it back", out); - try out.appendSlice(alloc, "\x1b[K"); return; }, .ended, .lost => { try self.composeEmptyRow(y, "session ended", "pick another session on the left", out); - try out.appendSlice(alloc, "\x1b[K"); return; }, } @@ -1781,7 +1794,6 @@ const Ui = struct { try appendTermRow(alloc, &v.term, y, out); } try out.appendSlice(alloc, sgr_reset); - try out.appendSlice(alloc, "\x1b[K"); // An in-progress mouse selection is highlighted by repainting // the selected cells in reverse video over the row content. @@ -1819,6 +1831,51 @@ const Ui = struct { try out.appendSlice(self.alloc, text); try out.appendSlice(self.alloc, sgr_reset); } + + /// The boo wordmark and its ghost, shown when no sessions exist. + const ghost_art = [_][]const u8{ + " _ .-.", + "| |__ ___ ___ (o o)", + "| '_ \\ / _ \\ / _ \\ | O \\", + "| |_) | (_) | (_) | \\ \\", + "|_.__/ \\___/ \\___/ `~~~'", + }; + + /// Empty state for a boo with no sessions at all: 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; + const vw = l.viewportCols(); + + const art_h: u16 = ghost_art.len; + const total: u16 = art_h + 3; // art, blank, two hint lines + const top = (l.viewportRows() -| total) / 2; + if (y < top) return; + const line = y - top; + + if (line < art_h) { + var art_w: usize = 0; + for (ghost_art) |a| art_w = @max(art_w, a.len); + if (art_w >= vw) return; + const pad = (vw - art_w) / 2; + for (0..pad) |_| try out.append(alloc, ' '); + try out.appendSlice(alloc, ghost_art[line]); + return; + } + + const text: []const u8 = switch (line) { + art_h + 1 => "no sessions", + art_h + 2 => "Press Ctrl+A for Keybinds", + else => return, + }; + if (text.len >= vw) return; + const pad = (vw - text.len) / 2; + try out.appendSlice(alloc, style_dim); + for (0..pad) |_| try out.append(alloc, ' '); + try out.appendSlice(alloc, text); + try out.appendSlice(alloc, sgr_reset); + } }; /// Append one row of the terminal's active screen as styled VT bytes. @@ -2020,20 +2077,22 @@ test "layout: geometry and hit testing" { try std.testing.expectEqual(@as(u16, 75), l.viewportCols()); try std.testing.expectEqual(@as(u16, 25), l.viewportX()); try std.testing.expectEqual(@as(u16, 23), l.viewportRows()); - try std.testing.expectEqual(@as(usize, 11), l.visibleEntries()); + try std.testing.expectEqual(@as(usize, 10), l.visibleEntries()); - // The new-session button is the top row; the status bar spans - // the full width of the last row. + // The new-session button is the top row, a blank gap sits under + // it, and the status bar spans the full width of the last row. try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 0)); + try std.testing.expectEqual(Layout.Hit.none, l.hit(3, 1)); try std.testing.expectEqual(Layout.Hit.status, l.hit(3, 23)); try std.testing.expectEqual(Layout.Hit.status, l.hit(80, 23)); try std.testing.expectEqual(Layout.Hit.none, l.hit(24, 5)); // separator // Sessions take two display rows: name, then title. const s = l.hit(3, 5); - try std.testing.expectEqual(@as(u16, 4), s.session.row); + try std.testing.expectEqual(@as(u16, 3), s.session.row); try std.testing.expect(!s.session.kill); - const k = l.hit(22, 5); + const k = l.hit(22, 4); + try std.testing.expectEqual(@as(u16, 2), k.session.row); try std.testing.expect(k.session.kill); const v = l.hit(30, 7); diff --git a/test/integration.zig b/test/integration.zig index 205329c..1c628c2 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -6,6 +6,7 @@ const std = @import("std"); const posix = std.posix; const build_options = @import("build_options"); +const vt = @import("ghostty-vt"); const exe_path: []const u8 = build_options.exe_path; @@ -1048,6 +1049,24 @@ fn waitUiSessionCount(h: *Harness, want: usize) !void { } } +/// Render raw client output through a terminal emulator and return +/// the resulting screen text, one line per row. Raw byte matching +/// cannot tell whether content survives on screen (a later erase can +/// remove it); this can. +fn renderScreen( + alloc: std.mem.Allocator, + bytes: []const u8, + rows: u16, + cols: u16, +) ![]const u8 { + var term = try vt.Terminal.init(alloc, .{ .cols = cols, .rows = rows }); + defer term.deinit(alloc); + var stream = term.vtStream(); + defer stream.deinit(); + stream.nextSlice(bytes); + return term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); +} + test "ui: sidebar lists sessions and the focused session renders in the viewport" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); @@ -1135,6 +1154,59 @@ test "ui: mouse events forward natively when the application asks for them" { try ui.waitFor("^[[<0;5;3M^[[<0;5;3m"); } +test "ui: a row touching the viewport's right edge keeps its last cell" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("edge", &.{"sh"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("+ new session"); + + // Paint a marker whose final cell sits in the session's last + // column (75 wide inside a 100-column UI), which lands in the + // last column of the whole terminal. The marker is assembled + // from a variable so the echoed command line cannot match. + try h.sendLine("edge", "T=EDGE; printf \"\\\\033[3;71H${T}Z\""); + try ui.waitFor("EDGEZ"); + // The status bar repaints after arming the prefix, so once the + // keybind bar shows, the marker row's frame is fully captured. + try ui.send("\x01"); + try ui.waitFor("r rename"); + try ui.send("\x1b"); + + // Erase-to-EOL emitted after a full-width row would eat the last + // cell (the cursor rests on it in the pending-wrap state), so the + // marker must survive on the rendered screen, not just in the + // byte stream. + const screen = try renderScreen(alloc, ui.output.items, 24, 100); + defer alloc.free(screen); + try std.testing.expect(std.mem.indexOf(u8, screen, "EDGEZ") != null); + + // The sidebar separates the new-session button from the first + // session row with a blank gap row. + var lines = std.mem.splitScalar(u8, screen, '\n'); + _ = lines.next(); // button row + const gap = lines.next().?; + try std.testing.expect(std.mem.startsWith(u8, gap, " " ** 24 ++ "\u{2502}")); + const first = lines.next().?; + try std.testing.expect(std.mem.indexOf(u8, first, "edge") != null); +} + +test "ui: the empty state shows the ghost and the keybind hint" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("(o o)"); + try ui.waitFor("no sessions"); + try ui.waitFor("Press Ctrl+A for Keybinds"); +} + test "ui: clicking a session in the sidebar focuses it" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); @@ -1151,10 +1223,11 @@ test "ui: clicking a session in the sidebar focuses it" { defer ui.deinit(); try ui.waitFor("TWO-MARK"); // most recent session focused - // Sessions are sorted by name: "one" on sidebar row 2 (1-based). - // An SGR press + release on that row switches the viewport. + // Sessions are sorted by name: "one" on sidebar row 3 (1-based, + // under the button and its gap row). An SGR press + release on + // that row switches the viewport. ui.clearOutput(); - try ui.send("\x1b[<0;5;2M\x1b[<0;5;2m"); + try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m"); try ui.waitFor("ONE-MARK"); } @@ -1203,8 +1276,8 @@ test "ui: clicking the kill target asks for confirmation" { try ui.waitFor("victim"); // The kill target is the 'x' in the second-to-last sidebar - // column (sidebar width 24 -> 1-based column 23), row 2. - try ui.send("\x1b[<0;23;2M\x1b[<0;23;2m"); + // column (sidebar width 24 -> 1-based column 23), row 3. + try ui.send("\x1b[<0;23;3M\x1b[<0;23;3m"); try ui.waitFor("kill victim? y/n"); try ui.send("y"); try waitUiSessionCount(&h, 0); @@ -1287,7 +1360,7 @@ test "ui: a plain attach steals the focused session" { try ui.waitFor("attached elsewhere"); // Clicking the session in the sidebar steals it back. - try ui.send("\x1b[<0;5;2M\x1b[<0;5;2m"); + try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m"); try thief.waitFor("attached elsewhere"); _ = try thief.waitExit(); } From 6527e44163e0e13cd6b810cc494e269aff685c78 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 21:30:51 +0000 Subject: [PATCH 7/8] feat(ui): never steal sessions automatically, reclaim them when freed An idle UI auto-attached any session its refresh discovered, stealing sessions held by other clients: a second boo ui (or a plain attach) would silently take over whatever appeared, and the loser sat on a dead 'attached elsewhere' view forever. Attachment intent is now explicit. A deliberate click or keypress still steals, but automatic focus (startup, discovery, recovery) only binds sessions no other client holds. A focused session whose attachment broke is reclaimed once it frees up: stolen views recover when the thief lets go, lost sockets when the daemon answers again. A live view also outlives a transient listing failure instead of being torn down and re-attached. The viewport empty state distinguishes a session held elsewhere (click to take it over) from no focus at all. --- src/ui.zig | 122 +++++++++++++++++++++++++++++++++++-------- test/integration.zig | 52 ++++++++++++++++++ 2 files changed, 151 insertions(+), 23 deletions(-) diff --git a/src/ui.zig b/src/ui.zig index db5e8a8..6c5d3d0 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -645,8 +645,7 @@ pub fn run(alloc: std.mem.Allocator, dir: []const u8) !void { ui.host_name = posix.getenv("BOO"); try ui.refreshSessions(); - ui.selectInitial(); - ui.attachSelected(); + if (ui.selected == null) ui.selectInitial(); try ui.loop(pipe_fds[0]); } @@ -664,6 +663,9 @@ const Ui = struct { host_name: ?[]const u8 = null, /// Name of the previously focused session for C-a C-a toggling. last_name: ?[]u8 = null, + /// Session name the current view is attached to; outlives a + /// transient disappearance from the listing, unlike `selected`. + view_name: ?[]u8 = null, /// First visible session row when the list overflows. scroll: usize = 0, view: ?*View = null, @@ -711,6 +713,7 @@ const Ui = struct { if (self.view) |v| v.destroy(); freeEntries(self.alloc, &self.sessions); if (self.last_name) |n| self.alloc.free(n); + if (self.view_name) |n| self.alloc.free(n); if (self.rename_input) |*input| input.deinit(self.alloc); self.message.deinit(self.alloc); for (self.row_cache.items) |*row| row.deinit(self.alloc); @@ -1234,9 +1237,12 @@ const Ui = struct { // -- Session management ---------------------------------------------------- - /// Re-query every session socket. Selection is kept by name, the - /// focused view is dropped when its session disappeared, and a - /// session is auto-focused when the focused one went away. + /// Re-query every session socket. Selection is kept by name and + /// automatic focus never steals: when nothing is focused the most + /// recently active free session is attached, and a focused session + /// whose attachment broke is reclaimed once it frees up. A live + /// view always outlives a transient listing failure; its own + /// socket decides when the attachment is over. fn refreshSessions(self: *Ui) !void { self.next_refresh_ms = std.time.milliTimestamp() + refresh_interval_ms; @@ -1275,9 +1281,11 @@ const Ui = struct { freeEntries(self.alloc, &self.sessions); self.sessions = fresh; - // Restore selection by name. + // Restore selection by name; the focused view's session counts + // even when the sidebar selection was already empty. + const want_name: ?[]const u8 = selected_name orelse self.view_name; self.selected = null; - if (selected_name) |want| { + if (want_name) |want| { for (self.sessions.items, 0..) |entry, i| { if (std.mem.eql(u8, entry.name, want)) { self.selected = i; @@ -1285,17 +1293,24 @@ const Ui = struct { } } } - if (self.selected == null) { - // The focused session is gone; fall back to a neighbor. - if (self.view) |v| { - if (v.state == .live) v.state = .lost; - } - if (self.firstFocusable()) |i| { - self.selected = i; - self.attachSelected(); - } else if (self.view != null) { - self.view.?.destroy(); + + if (self.selected) |i| { + self.maybeReclaim(i); + } else if (self.liveView() != null) { + // The focused session vanished from the listing while its + // socket stays healthy: a transient failure. Keep the view; + // selection returns when the listing recovers. + } 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; } } self.clampScroll(); @@ -1307,14 +1322,39 @@ const Ui = struct { return std.mem.eql(u8, self.sessions.items[idx].name, host); } - fn firstFocusable(self: *Ui) ?usize { - for (self.sessions.items, 0..) |_, i| { - if (!self.isHost(i)) return i; + /// Re-attach the focused session after our attachment broke, once + /// no other client holds it: stolen views recover when the thief + /// lets go, lost sockets when the daemon answers again, and a + /// selection that never attached (no-steal startup) binds as soon + /// as the session frees up. + fn maybeReclaim(self: *Ui, idx: usize) void { + if (self.sessions.items[idx].attached) return; + const broken = if (self.view) |v| + v.state == .stolen or v.state == .lost + else + true; + if (broken) self.attachSelected(); + } + + /// The most recently active session eligible for automatic + /// attachment: never this UI's host, and never a session some + /// other client holds. Automatic focus must not steal; only a + /// deliberate click or keypress may. + fn autoFocusable(self: *Ui) ?usize { + var best: ?usize = null; + for (self.sessions.items, 0..) |entry, i| { + if (self.isHost(i)) continue; + if (entry.attached) continue; + if (best == null or entry.idle_ms < self.sessions.items[best.?].idle_ms) { + best = i; + } } - return null; + return best; } - /// Pick the most recently active session on startup. + /// 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. fn selectInitial(self: *Ui) void { var best: ?usize = null; for (self.sessions.items, 0..) |entry, i| { @@ -1334,6 +1374,8 @@ const Ui = struct { v.destroy(); self.view = null; } + if (self.view_name) |n| self.alloc.free(n); + self.view_name = null; const sock = paths.socketPath(self.alloc, self.dir, name) catch return; defer self.alloc.free(sock); @@ -1346,6 +1388,7 @@ const Ui = struct { self.setMessage("attach {s} failed: {s}", .{ name, @errorName(err) }); return; }; + self.view_name = self.alloc.dupe(u8, name) catch null; self.select_anchor = null; self.view_gen += 1; self.full_render = true; @@ -1774,7 +1817,13 @@ const Ui = struct { try out.appendSlice(alloc, "\x1b[K"); const v = self.view orelse { - try self.composeNoSessions(y, out); + 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); + } return; }; @@ -2071,6 +2120,33 @@ test "parser: focus reports" { try std.testing.expectEqual(InputEvent{ .focus = false }, h.events.items[1]); } +test "ui: automatic focus skips attached sessions and prefers recent ones" { + const alloc = std.testing.allocator; + var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; + defer ui.sessions.deinit(alloc); + + var aa = "aa".*; + var bb = "bb".*; + var cc = "cc".*; + var no_title: [0]u8 = .{}; + try ui.sessions.append(alloc, .{ .name = &aa, .attached = false, .idle_ms = 50, .title = &no_title }); + try ui.sessions.append(alloc, .{ .name = &bb, .attached = true, .idle_ms = 10, .title = &no_title }); + try ui.sessions.append(alloc, .{ .name = &cc, .attached = false, .idle_ms = 90, .title = &no_title }); + + // bb is the most recent but held elsewhere; aa wins among the free. + try std.testing.expectEqual(@as(?usize, 0), ui.autoFocusable()); + + // The session hosting this UI is never an automatic candidate. + ui.host_name = "aa"; + try std.testing.expectEqual(@as(?usize, 2), ui.autoFocusable()); + + // Every session held elsewhere: nothing to attach automatically. + ui.host_name = null; + ui.sessions.items[0].attached = true; + ui.sessions.items[2].attached = true; + try std.testing.expectEqual(@as(?usize, null), ui.autoFocusable()); +} + test "layout: geometry and hit testing" { const l = Layout.init(24, 100); try std.testing.expectEqual(@as(u16, 24), l.sidebar_w); diff --git a/test/integration.zig b/test/integration.zig index 1c628c2..77df806 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1365,6 +1365,58 @@ test "ui: a plain attach steals the focused session" { _ = try thief.waitExit(); } +test "ui: startup leaves a session attached elsewhere alone until it frees up" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("ns", &.{"cat"}); + + var holder = try PtyClient.spawn(&h, &.{ "attach", "ns" }, 24, 80); + defer holder.deinit(); + try h.sendLine("ns", "HELD-MARK"); + try holder.waitFor("HELD-MARK"); + + // The UI starts while another client holds the session: it points + // at the session without stealing it. + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("attached elsewhere"); + try ui.waitFor("click the session to take it over"); + try std.testing.expect(std.mem.indexOf(u8, holder.output.items, "attached elsewhere") == null); + + // Once the holder detaches, the UI binds the session by itself. + try holder.send("\x01d"); + try holder.waitFor("[detached from ns]"); + _ = try holder.waitExit(); + try h.sendLine("ns", "FREED-MARK"); + try ui.waitFor("FREED-MARK"); +} + +test "ui: a stolen view reclaims the session once the thief lets go" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("rc", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try h.sendLine("rc", "FIRST-MARK"); + try ui.waitFor("FIRST-MARK"); + + var thief = try PtyClient.spawn(&h, &.{ "attach", "rc" }, 24, 80); + defer thief.deinit(); + try ui.waitFor("attached elsewhere"); + + // The thief detaches; the UI re-attaches on its own. + try thief.send("\x01d"); + try thief.waitFor("[detached from rc]"); + _ = try thief.waitExit(); + try h.sendLine("rc", "BACK-MARK"); + try ui.waitFor("BACK-MARK"); +} + test "ui: the status bar reveals keybinds and C-a r renames" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); From a025cd37263b02b8b7df1e65342409e1e74df44a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 21:36:07 +0000 Subject: [PATCH 8/8] chore: bump version to 0.5.0 --- build.zig.zon | 2 +- src/main.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 53a59ac..d9ab810 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .boo, - .version = "0.4.0", + .version = "0.5.0", .fingerprint = 0x8b7acdfd255f0e34, .minimum_zig_version = "0.15.2", .dependencies = .{ diff --git a/src/main.zig b/src/main.zig index 5873653..b8378dd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,7 +11,7 @@ const paths = @import("paths.zig"); const protocol = @import("protocol.zig"); const ui = @import("ui.zig"); -pub const version = "0.4.0"; +pub const version = "0.5.0"; /// Exit codes, documented in `boo help`. const exit_runtime: u8 = 1;