From 94597bbe64197f803f986e8bbf7a132e8574b722 Mon Sep 17 00:00:00 2001 From: Krishna Penukonda Date: Mon, 3 Aug 2026 21:26:36 +0530 Subject: [PATCH] feat(pi-subagents): add read-only subagent UI --- extensions/pi-subagents/README.md | 4 + .../pi-subagents/src/extension/index.ts | 165 +++++++++++++- .../test/unit/subagents-ui.test.ts | 206 ++++++++++++++++++ 3 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 extensions/pi-subagents/test/unit/subagents-ui.test.ts diff --git a/extensions/pi-subagents/README.md b/extensions/pi-subagents/README.md index 685cfb9..a9eadd6 100644 --- a/extensions/pi-subagents/README.md +++ b/extensions/pi-subagents/README.md @@ -27,6 +27,10 @@ spawn_subagent({ Completed children are final. Running children continue their original task until completion or failure. The extension exposes only the spawn tool. +## Subagent list + +Use `/subagents` or `Ctrl+Shift+S` in TUI mode to open the current session's subagent list. Use Up/Down to navigate, Enter to view read-only details, and Escape to close. + ## Child environment Child Pi sessions keep normal Pi capabilities: tools, skills, extensions, and project context are not hidden or restricted by this extension. Every child system prompt receives this identity line: diff --git a/extensions/pi-subagents/src/extension/index.ts b/extensions/pi-subagents/src/extension/index.ts index 9e8f567..0030ed5 100644 --- a/extensions/pi-subagents/src/extension/index.ts +++ b/extensions/pi-subagents/src/extension/index.ts @@ -13,7 +13,14 @@ import type { ExtensionContext, ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { Text } from "@earendil-works/pi-tui"; +import { + Container, + Key, + matchesKey, + type SelectItem, + SelectList, + Text, +} from "@earendil-works/pi-tui"; import { checkSubagentDepth, getSubagentDepthEnv, @@ -802,7 +809,148 @@ function fitToWidth(text: string, width: number): string { } function sanitizePreview(text: string): string { - return text.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); + return text + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +type SubagentDisplayStatus = "running" | "completed" | "error" | "timed-out"; + +function displayStatus(record: PersistedSubagentRecord): SubagentDisplayStatus { + if (record.running) return "running"; + if (record.error && /\b(?:timed?\s*out|timeout)\b/i.test(record.error)) + return "timed-out"; + if (record.error) return "error"; + return "completed"; +} + +function elapsedMs(record: PersistedSubagentRecord): number { + const end = record.running + ? Date.now() + : (record.completedAt ?? record.updatedAt); + return Math.max(0, end - record.createdAt); +} + +function formatElapsed(milliseconds: number): string { + const seconds = Math.floor(milliseconds / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function subagentSelectItems(records: PersistedSubagentRecord[]): SelectItem[] { + return records.map((record) => ({ + value: record.id, + label: `${displayStatus(record)} ${formatElapsed(elapsedMs(record))} ${record.id.slice(0, 8)}`, + description: sanitizePreview(record.taskPreview), + })); +} + +async function showSubagentDetails( + ctx: ExtensionContext, + record: PersistedSubagentRecord, +): Promise { + const status = displayStatus(record); + const lines = [ + "Subagent details", + "", + `ID: ${record.id}`, + `Status: ${status}`, + `Elapsed: ${formatElapsed(elapsedMs(record))}`, + `Task: ${sanitizePreview(record.taskPreview) || "(empty)"}`, + `Working directory: ${record.cwd}`, + `Model: ${record.model ?? "(inherited)"}`, + `Started: ${new Date(record.createdAt).toISOString()}`, + ...(record.completedAt + ? [`Completed: ${new Date(record.completedAt).toISOString()}`] + : []), + `Result: ${record.outputFile ?? "(unavailable)"}`, + `Stderr: ${record.stderrFile}`, + ...(record.error ? [`Error: ${sanitizePreview(record.error)}`] : []), + "", + "enter/esc close", + ]; + + await ctx.ui.custom((_tui, theme, _keybindings, done) => { + const text = new Text( + lines + .map((line, index) => + index === 0 ? theme.fg("accent", theme.bold(line)) : line, + ) + .join("\n"), + 1, + 0, + ); + return { + render: (width: number) => text.render(width), + invalidate: () => text.invalidate(), + handleInput(data: string) { + if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) + done(undefined); + }, + }; + }); +} + +async function showSubagents(ctx: ExtensionContext): Promise { + const mode = (ctx as ExtensionContext & { mode?: string }).mode; + if (mode ? mode !== "tui" : !ctx.hasUI) { + ctx.ui.notify("/subagents requires TUI mode", "error"); + return; + } + + const records = reconcileStore(parentSessionId(ctx)).records; + if (records.length === 0) { + ctx.ui.notify("No subagents for this session", "info"); + return; + } + + const selectedId = await ctx.ui.custom( + (tui, theme, _keybindings, done) => { + const container = new Container(); + container.addChild( + new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0), + ); + const selectList = new SelectList( + subagentSelectItems(records), + Math.min(records.length, 10), + { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), + }, + ); + selectList.onSelect = (item) => done(item.value); + selectList.onCancel = () => done(null); + container.addChild(selectList); + container.addChild( + new Text( + theme.fg("dim", "↑↓ navigate • enter details • esc cancel"), + 1, + 0, + ), + ); + return { + render: (width: number) => container.render(width), + invalidate: () => container.invalidate(), + handleInput(data: string) { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }, + ); + + if (!selectedId) return; + const selected = records.find((record) => record.id === selectedId); + if (selected) await showSubagentDetails(ctx, selected); } function formatRunningLine(record: PersistedSubagentRecord): string { @@ -1416,6 +1564,19 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { pi.registerTool(statusTool); pi.registerTool(tailTool); + if (typeof pi.registerCommand === "function") { + pi.registerCommand("subagents", { + description: "List subagents", + handler: async (_args, ctx) => showSubagents(ctx), + }); + } + if (typeof pi.registerShortcut === "function") { + pi.registerShortcut(Key.ctrlShift("s"), { + description: "List subagents", + handler: showSubagents, + }); + } + if (typeof pi.on === "function") { pi.on("session_start", (_event, ctx) => { rememberUiContext(ctx); diff --git a/extensions/pi-subagents/test/unit/subagents-ui.test.ts b/extensions/pi-subagents/test/unit/subagents-ui.test.ts new file mode 100644 index 0000000..e8ea679 --- /dev/null +++ b/extensions/pi-subagents/test/unit/subagents-ui.test.ts @@ -0,0 +1,206 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import registerSubagentExtension from "../../src/extension/index.ts"; + +interface TestRecord { + id: string; + taskPreview: string; + running: boolean; + pid?: number; + createdAt: number; + updatedAt: number; + completedAt?: number; + error?: string; + cwd?: string; + model?: string; + outputFile?: string; + stdoutFile?: string; + stderrFile?: string; +} + +function setup(records: TestRecord[]) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-ui-")); + const sessionFile = path.join(cwd, "session.jsonl"); + const storeDir = path.join(cwd, "subagents"); + fs.mkdirSync(storeDir, { recursive: true }); + fs.writeFileSync( + path.join(storeDir, "subagents.json"), + JSON.stringify({ + records: records.map((record) => ({ + parentSessionId: cwd, + cwd, + stdoutFile: path.join(cwd, `${record.id}.stdout.log`), + stderrFile: path.join(cwd, `${record.id}.stderr.log`), + ...record, + })), + }), + ); + + const commands = new Map(); + const shortcuts = new Map(); + const pi = { + registerTool() {}, + registerCommand(name: string, definition: any) { + commands.set(name, definition); + }, + registerShortcut(key: string, definition: any) { + shortcuts.set(key, definition); + }, + on() {}, + }; + registerSubagentExtension(pi as never); + + const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const renders: string[][] = []; + const notifications: Array<{ message: string; level: string }> = []; + const ui = { + notify(message: string, level: string) { + notifications.push({ message, level }); + }, + async custom(factory: any) { + let result: unknown; + let done = false; + const component = factory( + { requestRender() {} }, + theme, + {}, + (value: unknown) => { + result = value; + done = true; + }, + ); + renders.push(component.render(240)); + const action = actions.shift() ?? "escape"; + if (action === "down-enter" || action === "down-up-enter") { + component.handleInput?.("\x1b[B"); + if (action === "down-up-enter") component.handleInput?.("\x1b[A"); + component.handleInput?.("\r"); + } else { + component.handleInput?.(action === "enter" ? "\r" : "\x1b"); + } + assert.equal(done, true, `UI action ${action} should close current view`); + return result; + }, + }; + const ctx = { + cwd, + mode: "tui", + hasUI: true, + ui, + sessionManager: { + getSessionFile: () => sessionFile, + getSessionId: () => sessionFile, + }, + }; + const actions: string[] = []; + + return { + commands, + shortcuts, + ctx, + renders, + notifications, + actions, + cleanup: () => fs.rmSync(cwd, { recursive: true, force: true }), + }; +} + +test("registers /subagents and Ctrl+Shift+S", () => { + const harness = setup([]); + try { + assert.equal(harness.commands.get("subagents")?.description, "List subagents"); + assert.equal(harness.shortcuts.get("ctrl+shift+s")?.description, "List subagents"); + } finally { + harness.cleanup(); + } +}); + +test("/subagents lists all statuses, sanitizes previews, and opens selected read-only details", async () => { + const now = Date.now(); + const harness = setup([ + { id: "running-12345678", taskPreview: "run\n\x1b[31mred\x1b[0m\u0007 task", running: true, pid: process.pid, createdAt: now - 5_000, updatedAt: now }, + { id: "completed-1234", taskPreview: "done task", running: false, createdAt: now - 8_000, updatedAt: now - 2_000, completedAt: now - 2_000, model: "openai/test" }, + { id: "error-12345678", taskPreview: "error task", running: false, createdAt: now - 7_000, updatedAt: now - 1_000, completedAt: now - 1_000, error: "provider failed" }, + { id: "timeout-123456", taskPreview: "timeout task", running: false, createdAt: now - 9_000, updatedAt: now - 1_000, completedAt: now - 1_000, error: "Operation timed out" }, + ]); + try { + harness.actions.push("down-enter", "escape"); + await harness.commands.get("subagents").handler("", harness.ctx); + + assert.equal(harness.renders.length, 2); + const list = harness.renders[0].join("\n"); + assert.match(list, /running\s+\d+s/); + assert.match(list, /completed\s+6s/); + assert.match(list, /error\s+6s/); + assert.match(list, /timed-out\s+8s/); + assert.match(list, /run red task/); + assert.doesNotMatch(list, /\x1b|\u0007|run\nred/); + assert.match(list, /↑↓ navigate • enter details • esc cancel/); + + const details = harness.renders[1].join("\n"); + assert.match(details, /Subagent details/); + assert.match(details, /ID: completed-1234/); + assert.match(details, /Status: completed/); + assert.match(details, /Model: openai\/test/); + assert.match(details, /Task: done task/); + assert.match(details, /enter\/esc close/); + } finally { + harness.cleanup(); + } +}); + +test("Up reverses Down before Enter", async () => { + const now = Date.now(); + const harness = setup([ + { id: "first-123456789", taskPreview: "first", running: false, createdAt: now - 2_000, updatedAt: now, completedAt: now }, + { id: "second-12345678", taskPreview: "second", running: false, createdAt: now - 1_000, updatedAt: now, completedAt: now }, + ]); + try { + harness.actions.push("down-up-enter", "enter"); + await harness.commands.get("subagents").handler("", harness.ctx); + assert.match(harness.renders[1].join("\n"), /ID: first-123456789/); + } finally { + harness.cleanup(); + } +}); + +test("shortcut and command open the same list and Escape cancels", async () => { + const now = Date.now(); + const harness = setup([ + { id: "same-ui-12345678", taskPreview: "same UI", running: false, createdAt: now - 1_000, updatedAt: now, completedAt: now }, + ]); + try { + harness.actions.push("escape", "escape"); + await harness.commands.get("subagents").handler("", harness.ctx); + await harness.shortcuts.get("ctrl+shift+s").handler(harness.ctx); + assert.equal(harness.renders.length, 2); + assert.deepEqual(harness.renders[1], harness.renders[0]); + assert.match(harness.renders[0].join("\n"), /same UI/); + } finally { + harness.cleanup(); + } +}); + +test("headless legacy context does not open custom TUI", async () => { + const now = Date.now(); + const harness = setup([ + { id: "headless-123456", taskPreview: "headless", running: false, createdAt: now - 1_000, updatedAt: now, completedAt: now }, + ]); + try { + (harness.ctx as typeof harness.ctx & { mode?: string }).mode = undefined; + harness.ctx.hasUI = false; + await harness.commands.get("subagents").handler("", harness.ctx); + assert.deepEqual(harness.renders, []); + assert.deepEqual(harness.notifications, [ + { message: "/subagents requires TUI mode", level: "error" }, + ]); + } finally { + harness.cleanup(); + } +});