From aafc68e911e2f9220e797a43851a1747f63e0923 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 22:41:25 -0400 Subject: [PATCH] feat(pi): render subagent monitor views --- .../extensions/subagents/supervisor.test.ts | 2 +- .../agents/pi/extensions/subagents/ui.test.ts | 68 ++++++++++++++++ modules/agents/pi/extensions/subagents/ui.ts | 81 +++++++++++++++---- 3 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 modules/agents/pi/extensions/subagents/ui.test.ts diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts index ec1db7a..7968dfd 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.test.ts @@ -165,7 +165,7 @@ test("ad hoc fallback labels are prompt-derived and reused by widget and result result: label, }, ); - assert.ok(inspectorLines.some((line) => line.includes(`${accepted.id} ${label} independent completed`)), inspectorLines.join("\n")); + assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n")); assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u); }); diff --git a/modules/agents/pi/extensions/subagents/ui.test.ts b/modules/agents/pi/extensions/subagents/ui.test.ts new file mode 100644 index 0000000..523eb4c --- /dev/null +++ b/modules/agents/pi/extensions/subagents/ui.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { SubagentState, SubagentStatus } from "./types.ts"; +import { renderInspector, renderSummary, widget } from "./ui.ts"; + +function status(overrides: Partial & { id: string; label: string; state: SubagentState }): SubagentStatus { + return { + adHoc: true, + context: "independent", + cwd: "/tmp", + elapsedMs: 0, + resultAvailable: false, + startedAt: "2026-08-01T00:00:00.000Z", + tools: "inherit", + ...overrides, + }; +} + +test("compact monitor aggregates visible children by actionable lifecycle group", () => { + assert.deepEqual(renderSummary([]), []); + + assert.deepEqual( + renderSummary([ + status({ id: "queued", label: "Queued", state: "queued" }), + status({ id: "starting", label: "Starting", state: "starting" }), + status({ id: "running", label: "Running", state: "running" }), + status({ id: "settling", label: "Settling", state: "settling" }), + status({ id: "completed", label: "Completed", state: "completed", resultAvailable: true }), + status({ id: "failed", label: "Failed", state: "failed", error: "boom" }), + status({ id: "timed-out", label: "Timed out", state: "timed_out" }), + status({ id: "cancelled", label: "Cancelled", state: "cancelled" }), + ]), + ["subagents: queued 1 · running 2 · settling 1 · completed 1 · failed 1 · timed out 1 · cancelled 1"], + ); +}); + +test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => { + const lines = renderInspector([ + status({ + id: "sg-running", + label: "Audit unusually verbose guest enablement migration plan", + state: "running", + elapsedMs: 65_000, + lastEvent: "message_update", + }), + status({ + id: "sg-completed", + label: "Summarize review", + state: "completed", + elapsedMs: 3_600_000, + lastEvent: "completed", + resultAvailable: true, + }), + status({ id: "sg-failed", label: "Run risky test", state: "failed", elapsedMs: 2_000, error: "exit 1" }), + ]); + + assert.equal(lines.length, 3); + assert.match(lines[0], /^▶ running +1m05s +Audit unusually verbose guest enablement migration plan +last: message_update$/u); + assert.equal(lines[1], "✓ completed 1h00m00s Summarize review result: available"); + assert.equal(lines[2], "✗ failed 2s Run risky test error: exit 1"); + + const rendered = widget([ + status({ id: "sg-running", label: "Audit unusually verbose guest enablement migration plan", state: "running", elapsedMs: 65_000, lastEvent: "message_update" }), + ], true)().render(32); + + assert.deepEqual(rendered, ["▶ running 1m05s Audit unusual…"]); + assert.ok(rendered.every((line) => line.length <= 32)); +}); diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index b02e019..e710a47 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -1,29 +1,80 @@ -import { isTerminalState } from "./status.ts"; -import type { SubagentStatus } from "./types.ts"; +import type { SubagentState, SubagentStatus } from "./types.ts"; + +const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [ + { label: "queued", states: ["queued"] }, + { label: "running", states: ["starting", "running"] }, + { label: "settling", states: ["settling"] }, + { label: "completed", states: ["completed"] }, + { label: "failed", states: ["failed"] }, + { label: "timed out", states: ["timed_out"] }, + { label: "cancelled", states: ["cancelled"] }, + { label: "orphaned", states: ["orphaned"] }, +]; + +const STATE_PRESENTATION: Record = { + queued: { icon: "…", label: "queued" }, + starting: { icon: "◌", label: "starting" }, + running: { icon: "▶", label: "running" }, + settling: { icon: "◒", label: "settling" }, + completed: { icon: "✓", label: "completed" }, + failed: { icon: "✗", label: "failed" }, + cancelled: { icon: "■", label: "cancelled" }, + timed_out: { icon: "⏱", label: "timed out" }, + orphaned: { icon: "?", label: "orphaned" }, +}; export function renderSummary(statuses: SubagentStatus[]): string[] { - const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length; - const queued = statuses.filter((status) => status.state === "queued").length; - const terminal = statuses.filter((status) => isTerminalState(status.state)).length; - if (running === 0 && queued === 0 && terminal === 0) return []; - return [`subagents: ${running} running · ${queued} queued · ${terminal} terminal`]; + const groups = COMPACT_GROUPS.map((group) => ({ + label: group.label, + count: statuses.filter((status) => group.states.includes(status.state)).length, + })).filter((group) => group.count > 0); + + if (groups.length === 0) return []; + return [`subagents: ${groups.map((group) => `${group.label} ${group.count}`).join(" · ")}`]; } export function renderInspector(statuses: SubagentStatus[]): string[] { - const lines = renderSummary(statuses); - for (const status of statuses) { - lines.push( - `${status.id} ${status.label} ${status.context} ${status.state} ${Math.round(status.elapsedMs / 1000)}s ${status.model ?? "inherit"} ${status.tools} ${status.lastEvent ?? "none"} result:${status.resultAvailable ? "yes" : "no"}`, - ); - } - return lines; + return statuses.map((status) => renderStatusRow(status)); } export function widget(statuses: SubagentStatus[], expanded: boolean) { return () => ({ invalidate() {}, render(width: number) { - return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => (line.length > width ? line.slice(0, Math.max(0, width - 1)) : line)); + return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => truncateLine(line, width)); }, }); } + +function renderStatusRow(status: SubagentStatus): string { + const presentation = STATE_PRESENTATION[status.state]; + const marker = statusMarker(status); + return `${presentation.icon} ${presentation.label.padEnd(9)} ${formatDuration(status.elapsedMs)} ${status.label}${marker ? ` ${marker}` : ""}`; +} + +function statusMarker(status: SubagentStatus): string | undefined { + if (status.error) return `error: ${status.error}`; + if (status.resultAvailable) return "result: available"; + if (status.lastEvent) return `last: ${status.lastEvent}`; + if (status.state === "queued") return "waiting"; + if (status.state === "settling") return "settling"; + return undefined; +} + +function formatDuration(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`; + if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} + +function truncateLine(line: string, width: number): string { + if (width <= 0) return ""; + if (line.length <= width) return line; + if (width === 1) return "…"; + return `${line.slice(0, width - 1)}…`; +}