From 0cf059a5a77ac30de717b55a85f94c5981585cd7 Mon Sep 17 00:00:00 2001 From: benshi <807629978@qq.com> Date: Mon, 22 Jun 2026 15:17:22 +0000 Subject: [PATCH] feat: add 'boo inspect ' for detailed session info ls gives a one-line-per-session overview; inspect shows a single session in depth. A new daemon 'inspect' control command reports name, attach state + client count, child PID, command, working directory, terminal size, idle and last-output times, screen mode (primary/alt), and title as keyvalue lines (split on the first tab only, so free-text values keep their spaces). The client resolves a unique name prefix like the other commands, prints an aligned human view by default, and emits a single JSON object with --json (matching ls --json style). A missing session exits 3, consistent with kill/rename. Help page and overview updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.zig | 60 ++++++++++++++++++++++++ src/help.zig | 16 +++++++ src/main.zig | 108 +++++++++++++++++++++++++++++++++++++++++++ test/integration.zig | 72 +++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+) diff --git a/src/daemon.zig b/src/daemon.zig index 957e8e5..8b1985b 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -442,6 +442,66 @@ pub const Daemon = struct { } } conn.send(.ok, out.items); + } else if (std.mem.eql(u8, cmd, "inspect")) { + // A richer report than `info`, for `boo inspect`: one + // keyvalue per line. The client splits each line on its + // first tab only, so cwd, command, and title values may + // themselves contain spaces. + var clients: usize = 0; + for (self.conns.items) |c| { + if (c.attached and !c.closed) clients += 1; + } + const w = self.liveWindow(); + const idle: i64 = @max(0, now - self.last_activity_ms); + const out_idle: i64 = if (w) |lw| @max(0, now - lw.last_output_ms) else 0; + const pid: posix.pid_t = if (w) |lw| lw.child_pid else 0; + const screen: []const u8 = if (w) |lw| + (if (lw.onAltScreen()) "alt" else "primary") + else + "primary"; + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(self.alloc); + try out.print(self.alloc, "name\t{s}\n", .{self.owned_name orelse self.opts.name}); + try out.print(self.alloc, "state\t{s}\n", .{if (clients > 0) "Attached" else "Detached"}); + try out.print(self.alloc, "clients\t{d}\n", .{clients}); + try out.print(self.alloc, "pid\t{d}\n", .{pid}); + + // command: the requested argv, or the resolved shell for a + // session started with no command. + try out.appendSlice(self.alloc, "command\t"); + if (self.opts.argv.len > 0) { + for (self.opts.argv, 0..) |a, i| { + if (i > 0) try out.append(self.alloc, ' '); + try out.appendSlice(self.alloc, a); + } + } else if (w) |lw| { + try out.appendSlice(self.alloc, lw.command_title); + } + try out.append(self.alloc, '\n'); + + try out.appendSlice(self.alloc, "cwd\t"); + if (w) |lw| { + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + if (cwd.ofPid(&cwd_buf, lw.child_pid)) |d| try out.appendSlice(self.alloc, d); + } + try out.append(self.alloc, '\n'); + + try out.print(self.alloc, "rows\t{d}\n", .{self.rows}); + try out.print(self.alloc, "cols\t{d}\n", .{self.cols}); + try out.print(self.alloc, "idle_ms\t{d}\n", .{idle}); + try out.print(self.alloc, "out_idle_ms\t{d}\n", .{out_idle}); + try out.print(self.alloc, "screen\t{s}\n", .{screen}); + + // title last; sanitized, so it carries no tabs or newlines. + try out.appendSlice(self.alloc, "title\t"); + if (w) |lw| { + for (lw.title()) |byte| { + if (byte < 0x20 or byte == 0x7f) continue; + try out.append(self.alloc, byte); + } + } + conn.send(.ok, out.items); } else if (std.mem.eql(u8, cmd, "cwd")) { // Report the session command's current working directory so // a new session created from `boo ui` can be born there. diff --git a/src/help.zig b/src/help.zig index dab99b6..d7e25b9 100644 --- a/src/help.zig +++ b/src/help.zig @@ -29,6 +29,7 @@ pub const overview = \\ attach, at, a attach a session (steals politely) \\ ui, i manage sessions in a full-screen UI \\ ls [--json] list sessions + \\ inspect [--json] show one session in detail \\ \\ Interaction \\ send [flags] type into a session @@ -195,6 +196,21 @@ pub const commands = [_]Entry{ \\ , }, + .{ + .name = "inspect", + .body = + \\usage: boo inspect [--json] + \\ + \\Show one session in detail: name, attach state and client + \\count, child PID, command, working directory, terminal size, + \\idle time (and time since last output), screen mode, title, and + \\socket path. Accepts a unique name prefix. + \\ + \\flags: + \\ --json emit a single JSON object with the same fields + \\ + , + }, .{ .name = "send", .body = diff --git a/src/main.zig b/src/main.zig index 017a2e2..99b558c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,6 +67,7 @@ pub fn main() !void { if (eql(cmd, "attach") or eql(cmd, "at") or eql(cmd, "a")) return cmdAttach(alloc, rest); if (eql(cmd, "ui") or eql(cmd, "i")) return cmdUi(alloc, rest); if (eql(cmd, "ls") or eql(cmd, "list")) return cmdLs(alloc, rest); + if (eql(cmd, "inspect")) return cmdInspect(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); @@ -499,6 +500,113 @@ fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { try stdoutWrite(out.items); } +fn cmdInspect(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("inspect"); + if (std.mem.eql(u8, arg, "--json")) { + json = true; + } else if (arg.len > 0 and arg[0] == '-') { + usageFail("inspect", "unknown flag '{s}'", .{arg}); + } else if (name_arg == null) { + name_arg = arg; + } else { + usageFail("inspect", "unexpected argument '{s}'", .{arg}); + } + } + const want = name_arg orelse usageFail("inspect", "usage: boo inspect [--json]", .{}); + + 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, &.{"inspect"}); + defer alloc.free(result.text); + if (!result.ok) fail(exit_runtime, "inspect failed: {s}", .{result.text}); + + const sock = try paths.socketPath(alloc, dir, name); + defer alloc.free(sock); + + if (json) { + try inspectJson(alloc, result.text, sock); + } else { + try inspectHuman(alloc, result.text, sock); + } +} + +/// Look up a value in the daemon's keyvalue-per-line inspect +/// payload. Each line is split on its first tab only, so a value may +/// itself contain spaces or tabs. Returns "" when the key is absent. +fn inspectField(text: []const u8, key: []const u8) []const u8 { + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue; + if (std.mem.eql(u8, line[0..tab], key)) return line[tab + 1 ..]; + } + return ""; +} + +fn inspectHuman(alloc: std.mem.Allocator, text: []const u8, sock: []const u8) !void { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + + const state: []const u8 = if (std.mem.eql(u8, inspectField(text, "state"), "Attached")) + "attached" + else + "detached"; + const idle_ms = std.fmt.parseInt(i64, inspectField(text, "idle_ms"), 10) catch 0; + const out_idle_ms = std.fmt.parseInt(i64, inspectField(text, "out_idle_ms"), 10) catch 0; + var ibuf: [32]u8 = undefined; + var obuf: [32]u8 = undefined; + + try out.print(alloc, "Session: {s}\n", .{inspectField(text, "name")}); + try out.print(alloc, "State: {s}\n", .{state}); + try out.print(alloc, "Clients: {s}\n", .{inspectField(text, "clients")}); + try out.print(alloc, "PID: {s}\n", .{inspectField(text, "pid")}); + try out.print(alloc, "Command: {s}\n", .{inspectField(text, "command")}); + try out.print(alloc, "Directory: {s}\n", .{inspectField(text, "cwd")}); + try out.print(alloc, "Size: {s}x{s}\n", .{ inspectField(text, "rows"), inspectField(text, "cols") }); + try out.print(alloc, "Idle: {s} (output {s})\n", .{ fmtIdle(&ibuf, idle_ms), fmtIdle(&obuf, out_idle_ms) }); + try out.print(alloc, "Screen: {s}\n", .{inspectField(text, "screen")}); + try out.print(alloc, "Title: {s}\n", .{inspectField(text, "title")}); + try out.print(alloc, "Socket: {s}\n", .{sock}); + try stdoutWrite(out.items); +} + +fn inspectJson(alloc: std.mem.Allocator, text: []const u8, sock: []const u8) !void { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + + const attached = std.mem.eql(u8, inspectField(text, "state"), "Attached"); + const clients = std.fmt.parseInt(i64, inspectField(text, "clients"), 10) catch 0; + const pid = std.fmt.parseInt(i64, inspectField(text, "pid"), 10) catch 0; + const rows = std.fmt.parseInt(i64, inspectField(text, "rows"), 10) catch 0; + const cols = std.fmt.parseInt(i64, inspectField(text, "cols"), 10) catch 0; + const idle_ms = std.fmt.parseInt(i64, inspectField(text, "idle_ms"), 10) catch 0; + const out_idle_ms = std.fmt.parseInt(i64, inspectField(text, "out_idle_ms"), 10) catch 0; + + try out.appendSlice(alloc, "{\"name\":"); + try appendJsonString(alloc, &out, inspectField(text, "name")); + const head = try std.fmt.allocPrint(alloc, ",\"attached\":{},\"clients\":{d},\"pid\":{d},\"command\":", .{ attached, clients, pid }); + defer alloc.free(head); + try out.appendSlice(alloc, head); + try appendJsonString(alloc, &out, inspectField(text, "command")); + try out.appendSlice(alloc, ",\"cwd\":"); + try appendJsonString(alloc, &out, inspectField(text, "cwd")); + const mid = try std.fmt.allocPrint(alloc, ",\"rows\":{d},\"cols\":{d},\"idle_ms\":{d},\"out_idle_ms\":{d},\"screen\":", .{ rows, cols, idle_ms, out_idle_ms }); + defer alloc.free(mid); + try out.appendSlice(alloc, mid); + try appendJsonString(alloc, &out, inspectField(text, "screen")); + try out.appendSlice(alloc, ",\"title\":"); + try appendJsonString(alloc, &out, inspectField(text, "title")); + try out.appendSlice(alloc, ",\"socket\":"); + try appendJsonString(alloc, &out, sock); + try out.appendSlice(alloc, "}\n"); + try stdoutWrite(out.items); +} + fn cutTab(rest: *[]const u8) ?[]const u8 { const idx = std.mem.indexOfScalar(u8, rest.*, '\t') orelse return null; const field = rest.*[0..idx]; diff --git a/test/integration.zig b/test/integration.zig index 113a56b..d5b4b80 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1210,6 +1210,78 @@ test "ui: a session that rang the bell is marked your turn" { try ui.waitFor("\u{25CF}"); } +test "inspect: shows detailed fields for a session" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("work", &.{"cat"}); + + const res = try h.run(&.{ "inspect", "work" }); + defer alloc.free(res.stdout); + defer alloc.free(res.stderr); + try std.testing.expect(res.term == .Exited and res.term.Exited == 0); + + // The detailed view names the session, its command, its working + // directory (absolute), and the screen mode a plain 'cat' is in. + try std.testing.expect(std.mem.indexOf(u8, res.stdout, "work") != null); + try std.testing.expect(std.mem.indexOf(u8, res.stdout, "cat") != null); + try std.testing.expect(std.mem.indexOf(u8, res.stdout, "detached") != null); + try std.testing.expect(std.mem.indexOf(u8, res.stdout, "primary") != null); + try std.testing.expect(std.mem.indexOf(u8, res.stdout, "/") != null); +} + +test "inspect: --json emits the session's fields" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("js", &.{"cat"}); + + const res = try h.run(&.{ "inspect", "js", "--json" }); + defer alloc.free(res.stdout); + defer alloc.free(res.stderr); + try std.testing.expect(res.term == .Exited and res.term.Exited == 0); + + var parsed = try std.json.parseFromSlice(std.json.Value, alloc, res.stdout, .{}); + defer parsed.deinit(); + const obj = parsed.value.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, 0), obj.get("clients").?.integer); + try std.testing.expect(obj.get("pid").?.integer > 0); + try std.testing.expectEqual(@as(i64, 24), obj.get("rows").?.integer); + try std.testing.expectEqual(@as(i64, 80), obj.get("cols").?.integer); + try std.testing.expectEqualStrings("cat", obj.get("command").?.string); + try std.testing.expectEqualStrings("primary", obj.get("screen").?.string); + try std.testing.expect(obj.get("idle_ms").?.integer >= 0); + try std.testing.expect(obj.get("out_idle_ms").?.integer >= 0); + try std.testing.expect(std.mem.startsWith(u8, obj.get("cwd").?.string, "/")); + try std.testing.expectEqualStrings("cat", obj.get("title").?.string); +} + +test "inspect: unknown session exits 3" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.runExit(&.{ "inspect", "ghost" }, 3); +} + +test "inspect: resolves a unique prefix" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("alpha", &.{"cat"}); + + const res = try h.run(&.{ "inspect", "al" }); + defer alloc.free(res.stdout); + defer alloc.free(res.stderr); + try std.testing.expect(res.term == .Exited and res.term.Exited == 0); + try std.testing.expect(std.mem.indexOf(u8, res.stdout, "alpha") != null); +} + test "peek --json includes geometry, cursor, and screen content" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);