diff --git a/README.md b/README.md index 61da21f..7694dcb 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ 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 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, @@ -63,9 +67,11 @@ 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 +boo rename work api # rename a session boo kill work # end a session boo kill --all # end every session ``` @@ -87,6 +93,11 @@ 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`), 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 Everything except `attach` works without a terminal, which makes boo a @@ -149,11 +160,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/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/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/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/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 1bdca9a..37aa517 100644 --- a/src/help.zig +++ b/src/help.zig @@ -27,11 +27,13 @@ pub const overview = \\commands: \\ new [name] [-d] [-- cmd...] start a session (attach unless -d) \\ attach, at attach a session (steals politely) + \\ ui manage sessions in a full-screen UI \\ ls [--json] list sessions \\ send [flags] type into a session \\ 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 \\ @@ -106,6 +108,46 @@ pub const commands = [_]Entry{ \\ , }, + .{ + .name = "ui", + .body = + \\usage: boo ui + \\ + \\Manage sessions in a full-screen interface: a sidebar lists + \\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) + \\ 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; 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 + \\ 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 + \\ 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. + \\ + \\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", @@ -207,6 +249,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 = @@ -244,6 +300,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 09f35a1..b8378dd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,8 +9,9 @@ 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.4.0"; +pub const version = "0.5.0"; /// Exit codes, documented in `boo help`. const exit_runtime: u8 = 1; @@ -64,11 +65,13 @@ 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); 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}); @@ -146,7 +149,7 @@ fn resolveSession( fail(exit_no_session, "no session matching '{s}' (run 'boo ls')", .{want}); } -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, @@ -159,7 +162,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 { @@ -329,6 +332,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| { @@ -733,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}); } @@ -957,4 +1005,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..6c5d3d0 --- /dev/null +++ b/src/ui.zig @@ -0,0 +1,2276 @@ +//! 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, +/// 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; + + /// 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 + // 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); + } + + /// 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; + } + + /// Sidebar rows available for session entries between the + /// new-session button (plus its gap row) and the status bar. + pub fn listRows(self: Layout) u16 { + return self.rows -| (list_top + 1); + } + + /// 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) { + /// 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, + 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 (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 .new_button; + if (y < list_top) return .none; // gap under the button + return .{ .session = .{ + .row = y - list_top, + .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; + 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; + 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); +} + +// -- Sidebar rendering -------------------------------------------------------- + +const sgr_reset = "\x1b[0m"; +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 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), + entry: Entry, + width: u16, + selected: bool, +) !void { + if (width == 0) return; + if (selected) try out.appendSlice(alloc, style_selected); + + // '*': 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 out.append(alloc, marker); + + if (width >= 12) { + // " x ": kill target in the last columns. + const name_w = width - 1 - 3; + try appendClipped(alloc, out, entry.name, name_w); + try out.appendSlice(alloc, " x "); + } else { + try appendClipped(alloc, out, entry.name, width - 1); + } + 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; + +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(); + if (ui.selected == null) ui.selectInitial(); + + 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, + /// 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, + + 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, + + /// 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, + + /// 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); + 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); + 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.viewportRows(), self.layout.viewportCols()) catch |err| { + 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; + } + + // -- 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); + } + }; + // 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) { + .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(); + }, + } + } + + /// 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), + 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} is not bound (press Ctrl+A alone for keybinds)", .{byte}); + } else { + self.setMessage("^A ^{c} is not bound (press Ctrl+A alone for keybinds)", .{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; + } + + // 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), + 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 => |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; + if (idx >= self.sessions.items.len) return; + if (s.kill and s.row % Layout.entry_rows == 0) { + 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(); + } + + /// 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; + } + 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 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; + + 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; 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 (want_name) |want| { + for (self.sessions.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.name, want)) { + self.selected = i; + break; + } + } + } + + 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(); + 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); + } + + /// 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 best; + } + + /// Startup fallback when every session is attached elsewhere: + /// select the most recently active one without attaching, so the + /// sidebar has a focus target but nothing is stolen. + 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; + } + 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); + self.view = View.create( + self.alloc, + sock, + self.layout.viewportRows(), + self.layout.viewportCols(), + ) catch |err| { + 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; + 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 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; + + 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.visibleEntries(); + 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 visible = self.layout.visibleEntries(); + const idx = self.selected orelse return; + if (idx < self.scroll) self.scroll = idx; + if (idx >= self.scroll + visible) { + self.scroll = idx + 1 - visible; + } + } + + // -- 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 = .{}; + 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.viewportRows() -| 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; + } + + /// 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}"); + try out.appendSlice(alloc, sgr_reset); + try self.composeViewportCell(y, out); + } + + const keybind_bar = + " 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 + /// 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, " Keybinds: Ctrl+A"); + } + 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; + const w = l.sidebar_w; + + if (y == 0) { + try out.appendSlice(alloc, style_dim); + try appendClipped(alloc, out, " + new session", w); + 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 - Layout.list_top; + const idx = self.scroll + row / Layout.entry_rows; + if (idx < self.sessions.items.len) { + 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; + } + + try appendClipped(alloc, out, "", w); + } + + 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 { + 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; + }; + + switch (v.state) { + .live => {}, + .stolen => { + try self.composeEmptyRow(y, "attached elsewhere", "click the session to steal it back", out); + return; + }, + .ended, .lost => { + try self.composeEmptyRow(y, "session ended", "pick another session on the left", out); + return; + }, + } + + if (y < v.term.rows) { + try appendTermRow(alloc, &v.term, y, out); + } + try out.appendSlice(alloc, sgr_reset); + + // 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( + self: *Ui, + y: u16, + comptime line1: []const u8, + comptime line2: []const u8, + out: *std.ArrayList(u8), + ) !void { + const l = self.layout; + const mid = l.viewportRows() / 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); + } + + /// 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. +/// 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\\"); + } +} + +/// 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 { + 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: 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: 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(); + 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 "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); + 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, 10), l.visibleEntries()); + + // 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, 3), s.session.row); + try std.testing.expect(!s.session.kill); + 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); + 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 "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, + }; + + // An idle row is pure ASCII: exactly `width` columns and bytes. + try appendSessionRow(alloc, &out, entry, 24, false); + 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.endsWith(u8, text, "x ")); + + // 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); +} + +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" { + 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 f342f9d..77df806 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; @@ -1001,3 +1002,484 @@ test "agent loop: new, send, wait, peek, kill" { try h.runOk(&.{ "kill", "agent" }); 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 { + 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"); + } +} + +/// 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); + 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: 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"}); + + 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: 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: 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); + 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 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;3M\x1b[<0;5;3m"); + 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"); + + // 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) + // session. + try ui.send("\x01k"); + try ui.waitFor("? y/n"); + try ui.send("y"); + 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 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); +} + +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; + // 24 rows - 1 status bar = 23 viewport rows. + 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, "23 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, "29 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;3M\x1b[<0;5;3m"); + try thief.waitFor("attached elsewhere"); + _ = 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); + 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("Keybinds: Ctrl+A"); + + // 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("Keybinds: Ctrl+A"); + + // C-a r opens the prompt pre-filled with the old name; erase it + // and type a new one. + try ui.send("\x01r"); + 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); + 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); +}