From e962be0d34f853c8fd2be49a4b11d84deacb1952 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 08:46:18 +0000 Subject: [PATCH] feat!: drop multi-window support and name sessions after the cwd Sessions now own exactly one command. The windows CLI command, the windows wire command, and all window-management key bindings (C-a c, n, p, 0-9, C-a C-a, k, w) are gone; C-a d/C-d detach, C-a l/C-l redraw, and C-a a literal C-a remain. The info reply carries an output-idle field so wait --idle works without window listings, and ls/peek lose their window columns and JSON keys. boo new without a name now uses the basename of the working directory (sanitized to the session-name character set), falling back to the process id when that name is taken or unusable. Bump version to 0.2.0. --- README.md | 57 +++++------ build.zig.zon | 2 +- src/daemon.zig | 236 +++++++++++-------------------------------- src/help.zig | 89 +++++++--------- src/keys.zig | 40 ++------ src/main.zig | 150 ++++----------------------- src/paths.zig | 54 +++++++++- src/window.zig | 5 +- test/integration.zig | 122 +++++++++++----------- 9 files changed, 263 insertions(+), 492 deletions(-) diff --git a/README.md b/README.md index 8ad387a..826ef6d 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,30 @@ Sessions that haunt your terminal. A GNU `screen` style terminal multiplexer built on [libghostty](https://github.com/ghostty-org/ghostty) (`libghostty-vt`), written in Zig. -Every window's output is parsed through Ghostty's terminal emulation -core, so boo always knows the exact screen state of every window: +Every session's output is parsed through Ghostty's terminal emulation +core, so boo always knows the exact screen state of every session: contents, styles, cursor, scrollback, and terminal modes. That state is -used to rehydrate your terminal on attach and window switches, to -answer terminal queries for background windows, and to let scripts and -AI agents read the screen exactly as a human would see it. +used to rehydrate your terminal on attach, to answer terminal queries +for detached sessions, and to let scripts and AI agents read the screen +exactly as a human would see it. ## Features - Sessions that survive disconnects: detach with `C-a d`, reattach with `boo attach`. -- Multiple windows per session with screen-style `C-a` key bindings. +- 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, cursor position, scrolling regions, window title, and terminal modes (alt screen, bracketed paste, mouse reporting, kitty keyboard, ...). - Screen-style terminal etiquette: the attached client renders inside your terminal's alternate screen, so attaching never disturbs your shell scrollback and detaching restores your pre-attach view. - Alternate-screen switches by apps inside a window are tracked in + Alternate-screen switches by apps inside a session are tracked in terminal state and repainted, never passed through raw. - Agent-friendly automation primitives: `send`, `peek`, `wait`, and `--json` output, all usable without a terminal. -- Resize propagation end to end (SIGWINCH -> client -> daemon -> window +- Resize propagation end to end (SIGWINCH -> client -> daemon -> PTY -> application). ## Install @@ -70,6 +71,9 @@ boo kill work # end a session boo exorcise # end every session ``` +With no name, `boo new` names the session after the current directory, +falling back to the process id when that name is taken or unusable. + Run `boo help` for the full overview, `boo help ` for flags and examples, and `boo help --all` to print every help page at once. @@ -80,13 +84,7 @@ Bindings follow GNU screen's defaults, including the `C-x` variants | Keys | Action | |-----------|-------------------------------------| -| `C-a c`, `C-a C-c` | new window | -| `C-a n` / `C-a p` / `C-a ` | next / previous window | -| `C-a 0`..`C-a 9` | select window by number | -| `C-a C-a` | toggle to the previously used window | | `C-a d`, `C-a C-d` | detach | -| `C-a k`, `C-a C-k` | kill the current window | -| `C-a w`, `C-a C-w` | list windows in the message line | | `C-a l`, `C-a C-l` | redraw | | `C-a a` | send a literal `C-a` | @@ -107,7 +105,7 @@ boo kill build # 5. clean up - **Reading state**: `peek` prints the rendered screen reconstructed from terminal state, not a raw byte log: ordered, fully redrawn, and stable. `--scrollback` includes history; `--json` adds size, cursor, - window id, and title. + and title. - **Waiting**: `wait --for ` blocks until the screen contains the text; `wait --idle ` until output settles; `--timeout ` exits 4 instead of hanging forever. No more sleep-and-poll loops. @@ -115,8 +113,7 @@ boo kill build # 5. clean up implicit newline, no quoting layer to fight. `--enter` submits, `--key Enter,C-c,Up` names control keys, and stdin mode is binary safe. -- **Machine-readable output**: `ls --json`, `windows --json`, and - `peek --json`. +- **Machine-readable output**: `ls --json` and `peek --json`. - **Exit codes**: `0` success, `1` error, `2` usage error, `3` no such session, `4` wait timed out. @@ -132,34 +129,32 @@ See `boo help automation` for the full page. ``` your terminal <-(raw tty)-> boo client <-(unix socket)-> session daemon - |- window 0: PTY + ghostty-vt Terminal - |- window 1: PTY + ghostty-vt Terminal - `- ... + `- PTY + ghostty-vt Terminal ``` - The **client** puts your TTY in raw mode and shuttles bytes over a framed Unix-socket protocol (`src/protocol.zig`). -- The **daemon** (forked on session creation) owns the windows. Each - window is a PTY-attached child whose output feeds a persistent +- The **daemon** (forked on session creation) owns the session's + command: a PTY-attached child whose output feeds a persistent `ghostty-vt` `TerminalStream` (`src/window.zig`). -- The **active window** is passed through to your terminal byte for - byte. On attach and window switches the daemon sanitizes your - terminal and replays the window from libghostty state using its VT - `TerminalFormatter`. -- Terminal queries (DSR, DA, XTWINOPS, ...) from background or detached - windows are answered by libghostty's stream handler; for the active - passthrough window your real terminal answers, avoiding double - replies. +- While attached, output is passed through to your terminal byte for + byte. On attach the daemon sanitizes your terminal and replays the + screen from libghostty state using its VT `TerminalFormatter`. +- Terminal queries (DSR, DA, XTWINOPS, ...) while detached are answered + by libghostty's stream handler; while attached your real terminal + answers, avoiding double replies. ## Caveats 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. - 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. -- Windows run with `TERM=xterm-256color`. +- Sessions run with `TERM=xterm-256color`. ## License diff --git a/build.zig.zon b/build.zig.zon index 4984f45..1bada97 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .boo, - .version = "0.1.1", + .version = "0.2.0", .fingerprint = 0x8b7acdfd255f0e34, .minimum_zig_version = "0.15.2", .dependencies = .{ diff --git a/src/daemon.zig b/src/daemon.zig index fa1eb80..1a10825 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -1,9 +1,9 @@ -//! The session daemon: owns windows (PTY + libghostty terminal state), -//! accepts client connections on a Unix socket, routes input/output, and -//! executes control commands. +//! The session daemon: owns the window (PTY + libghostty terminal +//! state), accepts client connections on a Unix socket, routes +//! input/output, and executes control commands. //! //! Single-threaded poll(2) loop. One client may be attached at a time -//! (attaching steals); any number of transient control (-X) connections +//! (attaching steals); any number of transient control connections //! may come and go. const std = @import("std"); @@ -53,10 +53,7 @@ pub const Daemon = struct { alloc: std.mem.Allocator, opts: Options, - windows: std.ArrayList(*Window) = .empty, - next_window_id: u16 = 0, - active: ?usize = null, - last_active_id: ?u16 = null, + win: ?*Window = null, conns: std.ArrayList(*Conn) = .empty, key_parser: keys.Parser = .{}, @@ -103,15 +100,13 @@ pub const Daemon = struct { .flags = 0, }, null); - _ = try self.createWindow(opts.argv); - self.active = 0; + self.win = try createWindow(self.alloc, opts.name, opts.argv, self.rows, self.cols); try self.loop(); } fn deinit(self: *Daemon) void { - for (self.windows.items) |w| w.destroy(); - self.windows.deinit(self.alloc); + if (self.win) |w| w.destroy(); for (self.conns.items) |c| { posix.close(c.fd); c.decoder.deinit(); @@ -152,10 +147,11 @@ pub const Daemon = struct { try fds.append(self.alloc, .{ .fd = c.fd, .events = posix.POLL.IN, .revents = 0 }); try refs.append(self.alloc, .{ .conn = c }); } - for (self.windows.items) |w| { - if (w.pty_fd < 0 or w.dead) continue; - try fds.append(self.alloc, .{ .fd = w.pty_fd, .events = posix.POLL.IN, .revents = 0 }); - try refs.append(self.alloc, .{ .window = w }); + if (self.liveWindow()) |w| { + if (w.pty_fd >= 0) { + try fds.append(self.alloc, .{ .fd = w.pty_fd, .events = posix.POLL.IN, .revents = 0 }); + try refs.append(self.alloc, .{ .window = w }); + } } _ = try posix.poll(fds.items, -1); @@ -172,8 +168,8 @@ pub const Daemon = struct { } self.sweep(); - if (self.windows.items.len == 0) { - self.broadcastExit("all windows closed"); + if (self.liveWindow() == null) { + self.broadcastExit("command exited"); break; } } @@ -241,7 +237,7 @@ pub const Daemon = struct { } conn.attached = true; self.key_parser = .{}; - self.resizeAll(size.rows, size.cols); + self.resizeWindow(size.rows, size.cols); self.updatePassthrough(); try self.repaintTo(conn); }, @@ -256,17 +252,17 @@ pub const Daemon = struct { try h.daemon.handleKeyCommand(h.conn, cmd); } }; - // When the active window runs the kitty keyboard - // protocol, the client's terminal mirrors it and sends - // the prefix key CSI-u encoded. - const kitty = if (self.activeWindow()) |w| w.kittyKeysActive() else false; + // When the window runs the kitty keyboard protocol, + // the client's terminal mirrors it and sends the + // prefix key CSI-u encoded. + const kitty = if (self.liveWindow()) |w| w.kittyKeysActive() else false; try self.key_parser.feed(msg.payload, kitty, Handler{ .daemon = self, .conn = conn }); }, .resize => { if (!conn.attached) return; const size = try protocol.SizePayload.decode(msg.payload); - self.resizeAll(size.rows, size.cols); + self.resizeWindow(size.rows, size.cols); }, .detach_req => { @@ -282,36 +278,10 @@ pub const Daemon = struct { fn handleKeyCommand(self: *Daemon, conn: *Conn, cmd: keys.Command) !void { switch (cmd) { - .forward => |bytes| if (self.activeWindow()) |w| { + .forward => |bytes| if (self.liveWindow()) |w| { w.writeInput(bytes) catch {}; }, - .new_window => { - const idx = try self.createWindow(&.{}); - self.switchTo(idx); - }, - .next_window => self.switchRelative(1), - .prev_window => self.switchRelative(-1), - .other_window => { - if (self.last_active_id) |id| { - if (self.windowIndexById(id)) |idx| self.switchTo(idx); - } - }, - .select_window => |n| { - if (self.windowIndexById(n)) |idx| { - self.switchTo(idx); - } else { - self.message(conn, "no window {d}", .{n}); - } - }, .detach => self.detachConn(conn, "detached"), - .kill_window => if (self.activeWindow()) |w| { - posix.kill(w.child_pid, posix.SIG.HUP) catch {}; - }, - .list_windows => { - const list = try self.windowList(); - defer self.alloc.free(list); - self.message(conn, "{s}", .{list}); - }, .redraw => try self.repaintTo(conn), .unknown => |byte| if (std.ascii.isPrint(byte)) self.message(conn, "unknown key: ^A {c}", .{byte}) @@ -335,17 +305,17 @@ pub const Daemon = struct { conn.send(.err, "usage: send "); return; } - if (self.activeWindow()) |w| { + if (self.liveWindow()) |w| { w.writeInput(argv[1]) catch { conn.send(.err, "window write failed"); return; }; self.last_activity_ms = now; conn.send(.ok, ""); - } else conn.send(.err, "no active window"); + } else conn.send(.err, "no window"); } else if (std.mem.eql(u8, cmd, "peek")) { const scrollback = argv.len > 1 and std.mem.eql(u8, argv[1], "scrollback"); - if (self.activeWindow()) |w| { + if (self.liveWindow()) |w| { const text = if (scrollback) try w.plainScrollback(self.alloc) else @@ -357,12 +327,11 @@ pub const Daemon = struct { var out: std.ArrayList(u8) = .empty; defer out.deinit(self.alloc); const cursor = &w.term.screens.active.cursor; - try out.print(self.alloc, "{d}\t{d}\t{d}\t{d}\t{d}\t", .{ + try out.print(self.alloc, "{d}\t{d}\t{d}\t{d}\t", .{ self.rows, self.cols, cursor.y + 1, cursor.x + 1, - w.id, }); for (w.title()) |byte| { if (byte < 0x20 or byte == 0x7f) continue; @@ -374,38 +343,28 @@ pub const Daemon = struct { out.shrinkRetainingCapacity(protocol.max_payload); } conn.send(.ok, out.items); - } else conn.send(.err, "no active window"); - } else if (std.mem.eql(u8, cmd, "windows")) { - var out: std.ArrayList(u8) = .empty; - defer out.deinit(self.alloc); - for (self.windows.items, 0..) |w, i| { - if (out.items.len > 0) try out.append(self.alloc, '\n'); - const active: u8 = if (self.active != null and self.active.? == i) '1' else '0'; - const idle: i64 = @max(0, now - w.last_output_ms); - try out.print(self.alloc, "{d}\t{c}\t{d}\t{s}\t", .{ w.id, active, idle, w.command_title }); - for (w.title()) |byte| { - if (byte < 0x20 or byte == 0x7f) continue; - try out.append(self.alloc, byte); - } - } - conn.send(.ok, out.items); + } else conn.send(.err, "no window"); } else if (std.mem.eql(u8, cmd, "info")) { var attached = false; for (self.conns.items) |c| { if (c.attached and !c.closed) attached = true; } const idle: i64 = @max(0, now - self.last_activity_ms); + const out_idle: i64 = if (self.liveWindow()) |w| + @max(0, now - w.last_output_ms) + else + 0; var out: std.ArrayList(u8) = .empty; defer out.deinit(self.alloc); - try out.print(self.alloc, "{s}\t{d}\t{s}\t{d}\t", .{ + try out.print(self.alloc, "{s}\t{s}\t{d}\t{d}\t", .{ self.opts.name, - self.windows.items.len, if (attached) "Attached" else "Detached", idle, + out_idle, }); - // Active window title last; sanitized, so it cannot - // contain the tabs that separate the fields. - if (self.activeWindow()) |w| { + // Window title last; sanitized, so it cannot contain the + // tabs that separate the fields. + if (self.liveWindow()) |w| { for (w.title()) |byte| { if (byte < 0x20 or byte == 0x7f) continue; try out.append(self.alloc, byte); @@ -414,7 +373,7 @@ pub const Daemon = struct { conn.send(.ok, out.items); } else if (std.mem.eql(u8, cmd, "quit")) { conn.send(.ok, ""); - for (self.windows.items) |w| { + if (self.win) |w| { posix.kill(w.child_pid, posix.SIG.HUP) catch {}; } self.broadcastExit("session terminated"); @@ -428,7 +387,7 @@ pub const Daemon = struct { const n = posix.read(win.pty_fd, buf) catch |err| n: { // EIO means the slave side is fully closed: window is done. if (err != error.InputOutput) { - log.warn("window {d} read error: {}", .{ win.id, err }); + log.warn("window read error: {}", .{err}); } break :n 0; }; @@ -473,8 +432,8 @@ pub const Daemon = struct { } } - /// Remove closed conns and dead windows. Runs after every poll - /// dispatch so iteration above never sees mutation. + /// Remove closed conns. Runs after every poll dispatch so + /// iteration above never sees mutation. fn sweep(self: *Daemon) void { var ci: usize = 0; while (ci < self.conns.items.len) { @@ -488,62 +447,32 @@ pub const Daemon = struct { self.alloc.destroy(c); _ = self.conns.swapRemove(ci); } - - var wi: usize = 0; - var active_died = false; - while (wi < self.windows.items.len) { - const w = self.windows.items[wi]; - if (!w.dead) { - wi += 1; - continue; - } - if (self.active) |a| { - if (a == wi) active_died = true; - } - if (self.last_active_id == w.id) self.last_active_id = null; - w.destroy(); - _ = self.windows.orderedRemove(wi); - // Fix up active index after removal. - if (self.active) |a| { - if (a > wi) self.active = a - 1; - } - } - - if (self.windows.items.len == 0) return; - if (active_died or self.active == null or self.active.? >= self.windows.items.len) { - self.switchTo(@min( - self.active orelse 0, - self.windows.items.len - 1, - )); - } } // -- Window management ------------------------------------------------ - fn createWindow(self: *Daemon, argv: []const []const u8) !usize { - var env = try std.process.getEnvMap(self.alloc); + fn createWindow( + alloc: std.mem.Allocator, + session_name: []const u8, + argv: []const []const u8, + rows: u16, + cols: u16, + ) !*Window { + var env = try std.process.getEnvMap(alloc); defer env.deinit(); try env.put("TERM", "xterm-256color"); - try env.put("BOO", self.opts.name); + try env.put("BOO", session_name); var default_argv: [1][]const u8 = .{env.get("SHELL") orelse "/bin/sh"}; const child_argv: []const []const u8 = if (argv.len > 0) argv else &default_argv; - const id = self.next_window_id; - var idbuf: [8]u8 = undefined; - try env.put("WINDOW", std.fmt.bufPrint(&idbuf, "{d}", .{id}) catch unreachable); - - const win = try Window.create(self.alloc, id, child_argv, &env, self.rows, self.cols); - errdefer win.destroy(); - try self.windows.append(self.alloc, win); - self.next_window_id += 1; - return self.windows.items.len - 1; + return Window.create(alloc, child_argv, &env, rows, cols); } - fn activeWindow(self: *Daemon) ?*Window { - const idx = self.active orelse return null; - if (idx >= self.windows.items.len) return null; - return self.windows.items[idx]; + fn liveWindow(self: *Daemon) ?*Window { + const w = self.win orelse return null; + if (w.dead) return null; + return w; } fn attachedConn(self: *Daemon) ?*Conn { @@ -553,60 +482,24 @@ pub const Daemon = struct { return null; } - fn windowIndexById(self: *Daemon, id: u16) ?usize { - for (self.windows.items, 0..) |w, i| { - if (w.id == id) return i; - } - return null; - } - - fn switchTo(self: *Daemon, idx: usize) void { - if (idx >= self.windows.items.len) return; - if (self.active) |a| { - if (a != idx and a < self.windows.items.len) { - self.last_active_id = self.windows.items[a].id; - } - } - self.active = idx; - self.updatePassthrough(); - if (self.attachedConn()) |conn| { - self.repaintTo(conn) catch |err| { - log.warn("repaint failed: {}", .{err}); - }; - } - } - - fn switchRelative(self: *Daemon, dir: i32) void { - const len = self.windows.items.len; - if (len == 0) return; - const cur = self.active orelse 0; - const next = if (dir > 0) - (cur + 1) % len - else - (cur + len - 1) % len; - self.switchTo(next); - } - fn updatePassthrough(self: *Daemon) void { const attached = self.attachedConn() != null; - for (self.windows.items, 0..) |w, i| { - w.passthrough = attached and self.active != null and self.active.? == i; - } + if (self.liveWindow()) |w| w.passthrough = attached; } - fn resizeAll(self: *Daemon, rows: u16, cols: u16) void { + fn resizeWindow(self: *Daemon, rows: u16, cols: u16) void { if (rows == 0 or cols == 0) return; self.rows = rows; self.cols = cols; - for (self.windows.items) |w| { + if (self.liveWindow()) |w| { w.resize(rows, cols) catch |err| { - log.warn("resize window {d} failed: {}", .{ w.id, err }); + log.warn("resize window failed: {}", .{err}); }; } } fn repaintTo(self: *Daemon, conn: *Conn) !void { - const win = self.activeWindow() orelse return; + const win = self.liveWindow() orelse return; const bytes = try win.repaint(self.alloc); defer self.alloc.free(bytes); // The repaint covers everything fed so far; resume passthrough @@ -631,19 +524,6 @@ pub const Daemon = struct { self.quitting = true; } - fn windowList(self: *Daemon) ![]u8 { - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(self.alloc); - for (self.windows.items, 0..) |w, i| { - if (out.items.len > 0) try out.append(self.alloc, '\n'); - const marker: u8 = if (self.active != null and self.active.? == i) '*' else ' '; - const line = try std.fmt.allocPrint(self.alloc, "{d}{c} {s}", .{ w.id, marker, w.title() }); - defer self.alloc.free(line); - try out.appendSlice(self.alloc, line); - } - return out.toOwnedSlice(self.alloc); - } - fn message(self: *Daemon, conn: *Conn, comptime fmt: []const u8, args: anytype) void { const text = std.fmt.allocPrint(self.alloc, fmt, args) catch return; defer self.alloc.free(text); diff --git a/src/help.zig b/src/help.zig index 1a8cf8b..1acfe0b 100644 --- a/src/help.zig +++ b/src/help.zig @@ -29,9 +29,8 @@ pub const overview = \\ new [name] [-d] [-- cmd...] start a session (attach unless -d) \\ attach, at [name] attach a session (steals politely) \\ ls [--json] list sessions - \\ windows [name] [--json] list a session's windows - \\ send [-s name] [text] type into a session's active window - \\ peek [name] print the active window's screen + \\ send [-s name] [text] type into a session + \\ peek [name] print the session's screen \\ wait [name] block until output matches or settles \\ kill [name | --all] end a session \\ exorcise end every session @@ -73,8 +72,9 @@ pub const commands = [_]Entry{ \\lose the connection. \\ \\Names may contain letters, digits, '.', '_', and '-'. The - \\default name is the starting process id. Everything after - \\'--' is the command to run in the first window. + \\default name is the name of the current directory, or the + \\process id when that name is taken or unusable. Everything + \\after '--' is the command to run in the session. \\ \\flags: \\ -d, --detached start without attaching and print the @@ -117,29 +117,13 @@ pub const commands = [_]Entry{ .body = \\usage: boo ls [--json] \\ - \\List sessions: name, window count, attach state, idle time - \\(time since the last window output or client input), and the - \\active window's title. Stale sockets left by crashed daemons - \\are cleaned up. + \\List sessions: name, attach state, idle time (time since the + \\last output or client input), and the session's title. Stale + \\sockets left by crashed daemons are cleaned up. \\ \\flags: \\ --json emit a JSON array: - \\ [{"name","windows","attached","idle_ms","title"}] - \\ - , - }, - .{ - .name = "windows", - .body = - \\usage: boo windows [name] [--json] - \\ - \\List a session's windows. The active window is marked with - \\'*'. Titles come from the application (OSC 0/2), falling - \\back to the window's launch command. - \\ - \\flags: - \\ --json emit a JSON array: - \\ [{"id","active","idle_ms","command","title"}] + \\ [{"name","attached","idle_ms","title"}] \\ , }, @@ -148,10 +132,10 @@ pub const commands = [_]Entry{ .body = \\usage: boo send [-s session] [text] [flags] \\ - \\Type into a session's active window, exactly as if the text - \\had been typed at the keyboard. Text is sent literally: no - \\escape processing and no implicit newline, so there is never - \\a quoting layer to fight. With no text and no --key, bytes + \\Type into a session, exactly as if the text had been typed + \\at the keyboard. Text is sent literally: no escape + \\processing and no implicit newline, so there is never a + \\quoting layer to fight. With no text and no --key, bytes \\are read from stdin (binary safe, NUL excluded). \\ \\flags: @@ -176,14 +160,14 @@ pub const commands = [_]Entry{ .body = \\usage: boo peek [name] [--scrollback] [--json] \\ - \\Print the active window's rendered screen: what a human - \\attached right now would see, reconstructed from terminal - \\state (not a raw byte log). Safe to run while attached. + \\Print the session's rendered screen: what a human attached + \\right now would see, reconstructed from terminal state (not + \\a raw byte log). Safe to run while attached. \\ \\flags: \\ --scrollback include the full scrollback history - \\ --json emit {"session","window","title","rows", - \\ "cols","cursor":{"row","col"},"screen"} + \\ --json emit {"session","title","rows","cols", + \\ "cursor":{"row","col"},"screen"} \\ \\examples: \\ boo peek build | tail -20 @@ -202,8 +186,8 @@ pub const commands = [_]Entry{ \\flags: \\ --for until the rendered screen contains \\ (plain substring match) - \\ --idle until the active window has produced no - \\ output for + \\ --idle until the session has produced no output + \\ for \\ --timeout give up and exit 4 (default: 30s) \\ \\Durations are an integer with a unit: 500ms, 2s, 1m. @@ -219,9 +203,9 @@ pub const commands = [_]Entry{ .body = \\usage: boo kill [name | --all] \\ - \\End a session: every window's process receives SIGHUP and - \\the daemon exits. With multiple sessions a name is required - \\unless --all is given (also available as 'boo exorcise'). + \\End a session: its process receives SIGHUP and the daemon + \\exits. With multiple sessions a name is required unless + \\--all is given (also available as 'boo exorcise'). \\ \\examples: \\ boo kill build @@ -269,15 +253,13 @@ pub const topics = [_]Entry{ .body = \\Key bindings inside an attached session (prefix C-a) \\ - \\ C-a c new window C-a d detach - \\ C-a n / p next / prev window C-a k kill window - \\ C-a 0-9 select window C-a w list windows - \\ C-a C-a previous window C-a l redraw - \\ C-a a send a literal C-a + \\ C-a d detach + \\ C-a l redraw + \\ C-a a send a literal C-a \\ - \\Control variants match GNU screen: C-a C-d detaches, - \\C-a C-c opens a window, and so on. Detaching leaves the - \\session running; 'boo attach' brings it back. + \\Control variants match GNU screen: C-a C-d detaches and + \\C-a C-l redraws. Detaching leaves the session running; + \\'boo attach' brings it back. \\ , }, @@ -298,7 +280,7 @@ pub const topics = [_]Entry{ \\reading state: \\ peek prints the rendered screen, not a raw byte stream: \\ ordered, fully redrawn, and stable. --scrollback includes - \\ history; --json adds size, cursor, window id, and title. + \\ history; --json adds size, cursor, and title. \\ \\waiting (instead of sleep): \\ boo wait --for screen contains @@ -311,19 +293,16 @@ pub const topics = [_]Entry{ \\ control keys; stdin mode is binary safe. \\ \\machine-readable output: - \\ boo ls --json [{"name","windows","attached","idle_ms", - \\ "title"}] - \\ boo windows --json [{"id","active","idle_ms","command","title"}] - \\ boo peek --json {"session","window","title","rows","cols", - \\ "cursor":{"row","col"},"screen"} + \\ boo ls --json [{"name","attached","idle_ms","title"}] + \\ boo peek --json {"session","title","rows","cols", + \\ "cursor":{"row","col"},"screen"} \\ \\exit codes: \\ 0 success 1 error 2 usage error \\ 3 no such session 4 wait timed out \\ \\tips: - \\ - Sessions are cheap; prefer one session per task over - \\ window juggling. + \\ - Sessions are cheap; use one session per task. \\ - 'boo new -d' prints the session name on stdout. \\ - Pick unique session names so [name] prefixes stay \\ unambiguous. diff --git a/src/keys.zig b/src/keys.zig index bcd411e..3efbb6a 100644 --- a/src/keys.zig +++ b/src/keys.zig @@ -15,16 +15,9 @@ const std = @import("std"); pub const escape_byte: u8 = 0x01; // C-a pub const Command = union(enum) { - /// Bytes to forward to the active window. + /// Bytes to forward to the window. forward: []const u8, - new_window, - next_window, - prev_window, - other_window, - select_window: u4, detach, - kill_window, - list_windows, redraw, unknown: u8, }; @@ -207,14 +200,7 @@ pub const Parser = struct { fn dispatch(byte: u8, handler: anytype) !void { switch (byte) { - 'c', 0x03 => try handler.command(.new_window), - 'n', ' ', 0x0e => try handler.command(.next_window), - 'p', 0x10, 0x08, 0x7f => try handler.command(.prev_window), - escape_byte => try handler.command(.other_window), - '0'...'9' => try handler.command(.{ .select_window = @intCast(byte - '0') }), 'd', 0x04 => try handler.command(.detach), - 'k', 0x0b => try handler.command(.kill_window), - 'w', 0x17 => try handler.command(.list_windows), 'l', 0x0c => try handler.command(.redraw), 'a' => try handler.command(.{ .forward = &.{escape_byte} }), else => try handler.command(.{ .unknown = byte }), @@ -286,21 +272,20 @@ test "prefix commands" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("ab\x01cde\x013f", false, &h); + try p.feed("ab\x01lde\x01df", false, &h); try std.testing.expectEqualStrings("abdef", h.forwarded.items); try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len); - try std.testing.expectEqual(Command.new_window, h.cmds.items[0]); - try std.testing.expectEqual(Command{ .select_window = 3 }, h.cmds.items[1]); + try std.testing.expectEqual(Command.redraw, h.cmds.items[0]); + try std.testing.expectEqual(Command.detach, h.cmds.items[1]); } -test "literal escape via C-a a and C-a C-a toggles" { +test "literal escape via C-a a" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x01a\x01\x01", false, &h); + try p.feed("\x01a", false, &h); try std.testing.expectEqualStrings("\x01", h.forwarded.items); - try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); - try std.testing.expectEqual(Command.other_window, h.cmds.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } test "prefix split across feeds" { @@ -319,15 +304,10 @@ test "control variants match screen defaults" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x01\x04\x01\x03\x01\x0e\x01\x10\x01\x0b\x01\x17\x01\x0c", false, &h); - try std.testing.expectEqual(@as(usize, 7), h.cmds.items.len); + try p.feed("\x01\x04\x01\x0c", false, &h); + try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len); try std.testing.expectEqual(Command.detach, h.cmds.items[0]); - try std.testing.expectEqual(Command.new_window, h.cmds.items[1]); - try std.testing.expectEqual(Command.next_window, h.cmds.items[2]); - try std.testing.expectEqual(Command.prev_window, h.cmds.items[3]); - try std.testing.expectEqual(Command.kill_window, h.cmds.items[4]); - try std.testing.expectEqual(Command.list_windows, h.cmds.items[5]); - try std.testing.expectEqual(Command.redraw, h.cmds.items[6]); + try std.testing.expectEqual(Command.redraw, h.cmds.items[1]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); } diff --git a/src/main.zig b/src/main.zig index 2e383f3..e34ca73 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,7 @@ const help = @import("help.zig"); const paths = @import("paths.zig"); const protocol = @import("protocol.zig"); -pub const version = "0.1.1"; +pub const version = "0.2.0"; /// Exit codes, documented in `boo help`. const exit_runtime: u8 = 1; @@ -65,7 +65,6 @@ 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, "ls") or eql(cmd, "list")) return cmdLs(alloc, rest); - if (eql(cmd, "windows")) return cmdWindows(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); @@ -179,12 +178,13 @@ fn pickMostRecent(alloc: std.mem.Allocator, dir: []const u8) !?[]u8 { const SessionInfo = struct { /// Full info payload: - /// name \t windows \t Attached|Detached \t idle_ms \t title. + /// name \t Attached|Detached \t idle_ms \t out_idle_ms \t title. text: []u8, - windows: u32, attached: bool, idle_ms: i64, - /// Active window title; slices into `text`. + /// Time since the window last produced output; drives wait --idle. + out_idle_ms: i64, + /// Window title; slices into `text`. title: []const u8, }; @@ -202,17 +202,17 @@ fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !?Se var it = std.mem.splitScalar(u8, result.text, '\t'); _ = it.next() orelse return error.BadResponse; // name - const windows = std.fmt.parseInt(u32, it.next() orelse return error.BadResponse, 10) catch - return error.BadResponse; const attached = std.mem.eql(u8, it.next() orelse return error.BadResponse, "Attached"); const idle_ms = std.fmt.parseInt(i64, it.next() orelse return error.BadResponse, 10) catch return error.BadResponse; + const out_idle_ms = std.fmt.parseInt(i64, it.next() orelse return error.BadResponse, 10) catch + return error.BadResponse; const title = it.rest(); return .{ .text = result.text, - .windows = windows, .attached = attached, .idle_ms = idle_ms, + .out_idle_ms = out_idle_ms, .title = title, }; } @@ -286,8 +286,8 @@ fn createSession( detached: bool, cmd_argv: []const []const u8, ) !void { - var name_buf: [32]u8 = undefined; - const name = name_opt orelse paths.defaultName(&name_buf); + var name_buf: [paths.max_name_len]u8 = undefined; + const name = name_opt orelse paths.defaultName(&name_buf, dir); paths.validateName(name) catch usageFail("new", "invalid session name '{s}'", .{name}); @@ -411,8 +411,7 @@ fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { if (i > 0) try out.append(alloc, ','); try out.appendSlice(alloc, "{\"name\":"); try appendJsonString(alloc, &out, entry.name); - const tail = try std.fmt.allocPrint(alloc, ",\"windows\":{d},\"attached\":{},\"idle_ms\":{d},\"title\":", .{ - entry.info.windows, + const tail = try std.fmt.allocPrint(alloc, ",\"attached\":{},\"idle_ms\":{d},\"title\":", .{ entry.info.attached, entry.info.idle_ms, }); @@ -430,12 +429,11 @@ fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { } try appendPadded(alloc, &out, "NAME", name_width); - try out.appendSlice(alloc, " WINDOWS STATE IDLE TITLE\n"); + try out.appendSlice(alloc, " STATE IDLE TITLE\n"); for (infos.items) |entry| { try appendPadded(alloc, &out, entry.name, name_width); var idle_buf: [32]u8 = undefined; - const line = try std.fmt.allocPrint(alloc, " {d: >7} {s: <8} {s: <4} {s}\n", .{ - entry.info.windows, + const line = try std.fmt.allocPrint(alloc, " {s: <8} {s: <4} {s}\n", .{ if (entry.info.attached) "attached" else "detached", fmtIdle(&idle_buf, entry.info.idle_ms), entry.info.title, @@ -446,87 +444,6 @@ fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { try stdoutWrite(out.items); } -fn cmdWindows(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { - var json = false; - var name_arg: ?[]const u8 = null; - for (args) |arg| { - if (isHelpFlag(arg)) return printHelpPage("windows"); - if (std.mem.eql(u8, arg, "--json")) { - json = true; - } else if (arg.len > 0 and arg[0] == '-') { - usageFail("windows", "unknown flag '{s}'", .{arg}); - } else if (name_arg == null) { - name_arg = arg; - } else { - usageFail("windows", "unexpected argument '{s}'", .{arg}); - } - } - - const dir = try paths.socketDir(alloc); - defer alloc.free(dir); - const name = try resolveSession(alloc, dir, name_arg, .read); - defer alloc.free(name); - - const result = try mustControl(alloc, dir, name, &.{"windows"}); - defer alloc.free(result.text); - if (!result.ok) fail(exit_runtime, "{s}", .{result.text}); - - var out: std.ArrayList(u8) = .empty; - defer out.deinit(alloc); - - if (json) try out.append(alloc, '['); - var first = true; - var lines = std.mem.splitScalar(u8, result.text, '\n'); - while (lines.next()) |line| { - if (line.len == 0) continue; - const win = parseWindowLine(line) orelse continue; - if (json) { - if (!first) try out.append(alloc, ','); - const head = try std.fmt.allocPrint(alloc, "{{\"id\":{d},\"active\":{},\"idle_ms\":{d},\"command\":", .{ - win.id, win.active, win.idle_ms, - }); - defer alloc.free(head); - try out.appendSlice(alloc, head); - try appendJsonString(alloc, &out, win.command); - try out.appendSlice(alloc, ",\"title\":"); - try appendJsonString(alloc, &out, win.title); - try out.append(alloc, '}'); - } else { - const marker: u8 = if (win.active) '*' else ' '; - const text = try std.fmt.allocPrint(alloc, "{d}{c} {s}\n", .{ win.id, marker, win.title }); - defer alloc.free(text); - try out.appendSlice(alloc, text); - } - first = false; - } - if (json) try out.appendSlice(alloc, "]\n"); - try stdoutWrite(out.items); -} - -const WindowLine = struct { - id: u32, - active: bool, - idle_ms: i64, - command: []const u8, - title: []const u8, -}; - -/// Wire format: id \t active \t idle_ms \t command \t title. -fn parseWindowLine(line: []const u8) ?WindowLine { - var rest = line; - const id_str = cutTab(&rest) orelse return null; - const active_str = cutTab(&rest) orelse return null; - const idle_str = cutTab(&rest) orelse return null; - const command = cutTab(&rest) orelse return null; - return .{ - .id = std.fmt.parseInt(u32, id_str, 10) catch return null, - .active = std.mem.eql(u8, active_str, "1"), - .idle_ms = std.fmt.parseInt(i64, idle_str, 10) catch return null, - .command = command, - .title = rest, - }; -} - fn cutTab(rest: *[]const u8) ?[]const u8 { const idx = std.mem.indexOfScalar(u8, rest.*, '\t') orelse return null; const field = rest.*[0..idx]; @@ -665,13 +582,12 @@ const Peek = struct { cols: u32, cursor_row: u32, cursor_col: u32, - window_id: u32, title: []const u8, screen: []const u8, }; -/// Wire format: rows \t cols \t cur_row \t cur_col \t window_id \t title -/// on the first line, then the screen dump. +/// Wire format: rows \t cols \t cur_row \t cur_col \t title on the +/// first line, then the screen dump. fn parsePeek(payload: []const u8) ?Peek { const nl = std.mem.indexOfScalar(u8, payload, '\n') orelse return null; var rest = payload[0..nl]; @@ -679,13 +595,11 @@ fn parsePeek(payload: []const u8) ?Peek { const cols = cutTab(&rest) orelse return null; const cur_row = cutTab(&rest) orelse return null; const cur_col = cutTab(&rest) orelse return null; - const window_id = cutTab(&rest) orelse return null; return .{ .rows = std.fmt.parseInt(u32, rows, 10) catch return null, .cols = std.fmt.parseInt(u32, cols, 10) catch return null, .cursor_row = std.fmt.parseInt(u32, cur_row, 10) catch return null, .cursor_col = std.fmt.parseInt(u32, cur_col, 10) catch return null, - .window_id = std.fmt.parseInt(u32, window_id, 10) catch return null, .title = rest, .screen = payload[nl + 1 ..], }; @@ -736,13 +650,7 @@ fn cmdPeek(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { defer out.deinit(alloc); try out.appendSlice(alloc, "{\"session\":"); try appendJsonString(alloc, &out, name); - const mid = try std.fmt.allocPrint( - alloc, - ",\"window\":{d},\"title\":", - .{peek.window_id}, - ); - defer alloc.free(mid); - try out.appendSlice(alloc, mid); + try out.appendSlice(alloc, ",\"title\":"); try appendJsonString(alloc, &out, peek.title); const geo = try std.fmt.allocPrint( alloc, @@ -813,15 +721,10 @@ fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { fail(exit_runtime, "malformed peek response", .{}); if (std.mem.indexOf(u8, peek.screen, needle) != null) return; } else { - const result = try mustControl(alloc, dir, name, &.{"windows"}); - defer alloc.free(result.text); - if (!result.ok) fail(exit_runtime, "{s}", .{result.text}); - var lines = std.mem.splitScalar(u8, result.text, '\n'); - while (lines.next()) |line| { - if (line.len == 0) continue; - const win = parseWindowLine(line) orelse continue; - if (win.active and win.idle_ms >= idle_ms) return; - } + const info = try sessionInfo(alloc, dir, name) orelse + fail(exit_no_session, "no session named {s}", .{name}); + defer alloc.free(info.text); + if (info.out_idle_ms >= idle_ms) return; } if (std.time.milliTimestamp() >= deadline) { fail(exit_timeout, "wait: timed out after {s}", .{timeout_str}); @@ -1069,23 +972,12 @@ test "appendJsonString escapes" { try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\u0001\"", out.items); } -test "parseWindowLine" { - const win = parseWindowLine("3\t1\t250\tbash\tmy\ttitle").?; - try std.testing.expectEqual(@as(u32, 3), win.id); - try std.testing.expect(win.active); - try std.testing.expectEqual(@as(i64, 250), win.idle_ms); - try std.testing.expectEqualStrings("bash", win.command); - try std.testing.expectEqualStrings("my\ttitle", win.title); - try std.testing.expectEqual(@as(?WindowLine, null), parseWindowLine("nope")); -} - test "parsePeek" { - const peek = parsePeek("24\t80\t3\t7\t1\tvim\nline1\nline2").?; + const peek = parsePeek("24\t80\t3\t7\tvim\nline1\nline2").?; try std.testing.expectEqual(@as(u32, 24), peek.rows); try std.testing.expectEqual(@as(u32, 80), peek.cols); try std.testing.expectEqual(@as(u32, 3), peek.cursor_row); try std.testing.expectEqual(@as(u32, 7), peek.cursor_col); - try std.testing.expectEqual(@as(u32, 1), peek.window_id); try std.testing.expectEqualStrings("vim", peek.title); try std.testing.expectEqualStrings("line1\nline2", peek.screen); } diff --git a/src/paths.zig b/src/paths.zig index d553636..6d261d8 100644 --- a/src/paths.zig +++ b/src/paths.zig @@ -52,9 +52,43 @@ pub fn socketPath(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) ! return std.fs.path.join(alloc, &.{ dir, file }); } -/// Default session name for sessions created without -S: the creating -/// process id, like GNU screen's pid prefix. -pub fn defaultName(buf: []u8) []const u8 { +/// Map an arbitrary string onto the session-name character set: bytes +/// outside the allowed set become '-' and overlong input is truncated. +/// Returns null when the result still fails validation, e.g. empty input +/// or a leading '.' or '-'. +fn sanitizeName(buf: []u8, base: []const u8) ?[]const u8 { + const len = @min(base.len, @min(buf.len, max_name_len)); + if (len == 0) return null; + for (base[0..len], buf[0..len]) |c, *out| { + out.* = switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '.', '_', '-' => c, + else => '-', + }; + } + const name = buf[0..len]; + validateName(name) catch return null; + return name; +} + +/// Default session name for sessions created without a name: the basename +/// of the current directory when it is usable and no session socket with +/// that name exists in dir, otherwise the creating process id (like GNU +/// screen's pid prefix). +pub fn defaultName(buf: []u8, dir: []const u8) []const u8 { + cwd: { + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd = std.posix.getcwd(&cwd_buf) catch break :cwd; + const name = sanitizeName(buf, std.fs.path.basename(cwd)) orelse break :cwd; + var sock_buf: [std.fs.max_path_bytes]u8 = undefined; + const sock = std.fmt.bufPrint( + &sock_buf, + "{s}/{s}.sock", + .{ dir, name }, + ) catch break :cwd; + // An existing socket means the name is taken; fall back to the pid. + std.fs.cwd().access(sock, .{}) catch return name; + break :cwd; + } return std.fmt.bufPrint(buf, "{d}", .{std.c.getpid()}) catch unreachable; } @@ -95,6 +129,20 @@ test "validateName" { try std.testing.expectError(error.InvalidSessionName, validateName("sp ace")); } +test "sanitizeName" { + var buf: [max_name_len]u8 = undefined; + try std.testing.expectEqualStrings("my-proj", sanitizeName(&buf, "my proj").?); + try std.testing.expectEqualStrings("a.b_c-1", sanitizeName(&buf, "a.b_c-1").?); + try std.testing.expectEqualStrings("h--llo", sanitizeName(&buf, "héllo").?); + try std.testing.expect(sanitizeName(&buf, "") == null); + try std.testing.expect(sanitizeName(&buf, ".hidden") == null); + try std.testing.expect(sanitizeName(&buf, "-flag") == null); + try std.testing.expectEqualStrings( + "x" ** max_name_len, + sanitizeName(&buf, "x" ** 100).?, + ); +} + test "socketPath" { const alloc = std.testing.allocator; const p = try socketPath(alloc, "/run/gs", "work"); diff --git a/src/window.zig b/src/window.zig index f580529..83428a5 100644 --- a/src/window.zig +++ b/src/window.zig @@ -14,7 +14,6 @@ const log = std.log.scoped(.window); pub const Window = struct { alloc: std.mem.Allocator, - id: u16, /// PTY master; -1 once the child is gone. pty_fd: posix.fd_t, @@ -52,7 +51,6 @@ pub const Window = struct { /// @fieldParentPtr. pub fn create( alloc: std.mem.Allocator, - id: u16, argv: []const []const u8, env: *std.process.EnvMap, rows: u16, @@ -70,7 +68,6 @@ pub const Window = struct { self.* = .{ .alloc = alloc, - .id = id, .pty_fd = spawned.master, .child_pid = spawned.pid, .command_title = try alloc.dupe(u8, argv[0]), @@ -128,7 +125,7 @@ pub const Window = struct { if (self.passthrough and !self.feeding_discarded) return; if (self.pty_fd < 0) return; protocol.writeAll(self.pty_fd, data) catch |err| { - log.warn("window {d}: failed writing query response: {}", .{ self.id, err }); + log.warn("window: failed writing query response: {}", .{err}); }; } diff --git a/test/integration.zig b/test/integration.zig index e2d9b08..0ec35c3 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -60,9 +60,24 @@ const Harness = struct { } fn run(self: *Harness, argv: []const []const u8) !std.process.Child.RunResult { + return self.runIn(null, argv); + } + + /// Run a CLI command with an explicit working directory; cwd null + /// inherits the test runner's directory. + fn runIn( + self: *Harness, + cwd: ?[]const u8, + argv: []const []const u8, + ) !std.process.Child.RunResult { + // exe_path is relative to the build root; resolve it so a + // custom cwd does not break spawning. + var exe_buf: [std.fs.max_path_bytes]u8 = undefined; + const exe_abs = try std.fs.cwd().realpath(exe_path, &exe_buf); + var full_argv: std.ArrayList([]const u8) = .empty; defer full_argv.deinit(self.alloc); - try full_argv.append(self.alloc, exe_path); + try full_argv.append(self.alloc, exe_abs); try full_argv.appendSlice(self.alloc, argv); var env = try std.process.getEnvMap(self.alloc); @@ -72,6 +87,7 @@ const Harness = struct { return std.process.Child.run(.{ .allocator = self.alloc, .argv = full_argv.items, + .cwd = cwd, .env_map = &env, }); } @@ -121,7 +137,7 @@ const Harness = struct { fn waitSessionUp(self: *Harness, session: []const u8) !void { var deadline = Deadline.init(default_timeout_ms); while (true) { - const result = try self.run(&.{ "windows", session }); + const result = try self.run(&.{ "peek", session }); defer self.alloc.free(result.stdout); defer self.alloc.free(result.stderr); if (result.term == .Exited and result.term.Exited == 0) return; @@ -129,12 +145,12 @@ const Harness = struct { } } - /// Type text into the session's active window, followed by Enter. + /// Type text into the session, followed by Enter. fn sendLine(self: *Harness, session: []const u8, text: []const u8) !void { try self.runOk(&.{ "send", "-s", session, text, "--enter" }); } - /// Poll the active window's screen contents until `needle` shows up. + /// Poll the session's screen contents until `needle` shows up. /// Returns the matching peek output; caller frees. fn waitPeekContains(self: *Harness, session: []const u8, needle: []const u8) ![]u8 { var deadline = Deadline.init(default_timeout_ms); @@ -469,33 +485,40 @@ test "window size: initial attach size and SIGWINCH resize reach the app" { } } -test "multiple windows: create, switch, and list" { +test "default session name comes from the working directory" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); defer h.deinit(); - var client = try PtyClient.spawn(&h, &.{ "new", "t6", "--", "cat" }, 24, 80); - defer client.deinit(); - try h.waitSessionUp("t6"); - - try client.send("first-window-data\r"); - try client.waitFor("first-window-data"); - - // C-a c: new window (running the default shell); give it a marker. - try client.send("\x01c"); - try client.send("echo second-window-data\r"); - try client.waitFor("second-window-data"); - - // C-a p: back to window 0; the repaint comes from ghostty state. - client.clearOutput(); - try client.send("\x01p"); - try client.waitFor("first-window-data"); + // A directory whose basename is a valid session name. Living under + // h.dir gets it cleaned up with the harness; the daemon ignores + // non-socket entries there. + const proj = try std.fs.path.join(alloc, &.{ h.dir, "spooky-proj" }); + defer alloc.free(proj); + try std.fs.cwd().makePath(proj); + + const first = try h.runIn(proj, &.{ "new", "-d", "--", "cat" }); + defer alloc.free(first.stdout); + defer alloc.free(first.stderr); + try std.testing.expect(first.term.Exited == 0); + try std.testing.expectEqualStrings("spooky-proj\n", first.stdout); + try h.waitSessionUp("spooky-proj"); + + // The name is taken now, so the next session falls back to the + // creating process id. + const second = try h.runIn(proj, &.{ "new", "-d", "--", "cat" }); + defer alloc.free(second.stdout); + defer alloc.free(second.stderr); + try std.testing.expect(second.term.Exited == 0); + const pid_name = std.mem.trimRight(u8, second.stdout, "\n"); + try std.testing.expect(pid_name.len > 0); + for (pid_name) |c| try std.testing.expect(std.ascii.isDigit(c)); - const result = try h.run(&.{ "windows", "t6" }); - defer alloc.free(result.stdout); - defer alloc.free(result.stderr); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "0*") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "1 ") != null); + 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, "spooky-proj") != null); + try std.testing.expect(std.mem.indexOf(u8, ls.stdout, pid_name) != null); } test "session listing shows attach state" { @@ -510,8 +533,8 @@ test "session listing shows attach state" { defer alloc.free(detached.stderr); try std.testing.expect(std.mem.indexOf(u8, detached.stdout, "listed") != null); try std.testing.expect(std.mem.indexOf(u8, detached.stdout, "detached") != null); - // The listing includes the active window's title (the launch - // command until the app sets one). + // The listing includes the session's title (the launch command + // until the app sets one). try std.testing.expect(std.mem.indexOf(u8, detached.stdout, "TITLE") != null); try std.testing.expect(std.mem.indexOf(u8, detached.stdout, "cat") != null); @@ -543,7 +566,7 @@ test "kill ends the session and notifies the attached client" { try std.testing.expectEqual(@as(u32, 0), try client.waitExit()); // The socket is gone: session commands now fail with exit 3. - try h.runExit(&.{ "windows", "t8" }, 3); + try h.runExit(&.{ "peek", "t8" }, 3); } test "attach without a tty fails cleanly" { @@ -698,29 +721,23 @@ test "reattach restores the window title" { try h.startDetached("ttl", &.{"sh"}); - // Set the title from inside the window. The title is assembled + // Set the title from inside the session. The title is assembled // from two printf arguments so the echoed command line (which // ends up in the repainted screen content) never contains the // assembled marker. try h.sendLine("ttl", "printf '\\033]2;TTL-%s\\007' MARK"); - // The window list reflects the OSC title once processed. + // The session listing reflects the OSC title once processed. var deadline = Deadline.init(default_timeout_ms); while (true) { - const result = try h.run(&.{ "windows", "ttl" }); + const result = try h.run(&.{"ls"}); const found = std.mem.indexOf(u8, result.stdout, "TTL-MARK") != null; alloc.free(result.stdout); alloc.free(result.stderr); if (found) break; - try deadline.tick("window title never updated"); + try deadline.tick("session title never updated"); } - // The session listing shows the same title. - 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, "TTL-MARK") != null); - // Reattach: the repaint must restore the title on the client's // terminal, not just the screen contents. var client = try PtyClient.spawn(&h, &.{ "attach", "ttl" }, 24, 80); @@ -769,7 +786,7 @@ test "help: overview, command pages, topics, and version" { try std.testing.expect(std.mem.startsWith(u8, ver.stdout, "boo ")); } -test "ls and windows emit machine-readable JSON" { +test "ls emits machine-readable JSON" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); defer h.deinit(); @@ -788,25 +805,9 @@ test "ls and windows emit machine-readable JSON" { const obj = sessions[0].object; try std.testing.expectEqualStrings("js", obj.get("name").?.string); try std.testing.expectEqual(false, obj.get("attached").?.bool); - try std.testing.expectEqual(@as(i64, 1), obj.get("windows").?.integer); try std.testing.expect(obj.get("idle_ms").?.integer >= 0); try std.testing.expectEqualStrings("cat", obj.get("title").?.string); } - - const wins = try h.run(&.{ "windows", "js", "--json" }); - defer alloc.free(wins.stdout); - defer alloc.free(wins.stderr); - try std.testing.expect(wins.term.Exited == 0); - { - var parsed = try std.json.parseFromSlice(std.json.Value, alloc, wins.stdout, .{}); - defer parsed.deinit(); - const windows = parsed.value.array.items; - try std.testing.expectEqual(@as(usize, 1), windows.len); - const obj = windows[0].object; - try std.testing.expectEqual(@as(i64, 0), obj.get("id").?.integer); - try std.testing.expectEqual(true, obj.get("active").?.bool); - try std.testing.expectEqualStrings("cat", obj.get("command").?.string); - } } test "peek --json includes geometry, cursor, and screen content" { @@ -828,7 +829,6 @@ test "peek --json includes geometry, cursor, and screen content" { defer parsed.deinit(); const obj = parsed.value.object; try std.testing.expectEqualStrings("pj", obj.get("session").?.string); - try std.testing.expectEqual(@as(i64, 0), obj.get("window").?.integer); try std.testing.expect(obj.get("rows").?.integer > 0); try std.testing.expect(obj.get("cols").?.integer > 0); try std.testing.expect(obj.get("cursor").?.object.get("row").?.integer >= 1); @@ -921,7 +921,7 @@ test "exorcise banishes every session" { if (empty) break; try deadline.tick("sessions survived the exorcism"); } - try h.runExit(&.{ "windows", "ghost1" }, 3); + try h.runExit(&.{ "peek", "ghost1" }, 3); } test "exit codes distinguish usage, missing sessions, and ambiguity" { @@ -932,8 +932,8 @@ test "exit codes distinguish usage, missing sessions, and ambiguity" { try h.startDetached("alike", &.{"cat"}); // Unique prefix resolves; ambiguous prefix and unknown names exit 3. - try h.runOk(&.{ "windows", "alp" }); - try h.runExit(&.{ "windows", "al" }, 3); + try h.runOk(&.{ "peek", "alp" }); + try h.runExit(&.{ "peek", "al" }, 3); try h.runExit(&.{ "attach", "nosuchzz" }, 3); try h.runExit(&.{ "kill", "nosuchzz" }, 3); @@ -1005,5 +1005,5 @@ test "agent loop: new, send, wait, peek, kill" { defer alloc.free(content); try h.runOk(&.{ "kill", "agent" }); - try h.runExit(&.{ "windows", "agent" }, 3); + try h.runExit(&.{ "peek", "agent" }, 3); }