diff --git a/CHANGELOG.md b/CHANGELOG.md index 2991ad2..0172181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ once, and optional `subagent_model` routes subagents to a different model/backend (e.g. a local Ollama model) so they never contend with the main session. +- New `/agents` command opens an interactive overlay with the full agent tree: + tree glyphs (├─/└─/│), colored statuses and icons, live elapsed time, + keyboard navigation (↑/↓), and a detail pane (Enter) showing each agent's + task and result. The live widget header now hints at `/agents`. ## 0.1.1 — 2026-07-31 diff --git a/src/agent-tree-ui.ts b/src/agent-tree-ui.ts new file mode 100644 index 0000000..ed1db69 --- /dev/null +++ b/src/agent-tree-ui.ts @@ -0,0 +1,90 @@ +import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { AgentTree } from "./agent-tree.js"; +import type { AgentRecord } from "./types.js"; + +interface TreeRow { record: AgentRecord; prefix: string } + +/** Flatten the agent tree into rows with tree glyphs (├─/└─/│). */ +function buildRows(tree: AgentTree): TreeRow[] { + const rows: TreeRow[] = []; + const root = tree.inspect(tree.rootId); + const walk = (record: AgentRecord, ancestors: boolean[], last: boolean): void => { + const indent = ancestors.map((isLast) => (isLast ? " " : "│ ")).join(""); + const connector = ancestors.length === 0 ? "" : last ? "└─ " : "├─ "; + rows.push({ record, prefix: indent + connector }); + record.children.forEach((childId, index) => walk(tree.inspect(childId), [...ancestors, last], index === record.children.length - 1)); + }; + walk(root, [], false); + return rows; +} + +const STATUS_COLOR: Record string> = { + running: (t, s) => t.fg("accent", s), + waiting: (t, s) => t.fg("accent", s), + completed: (t, s) => t.fg("success", s), + failed: (t, s) => t.fg("error", s), + cancelled: (t, s) => t.fg("dim", s), + interrupted: (t, s) => t.fg("warning", s), + created: (t, s) => t.fg("text", s), + archived: (t, s) => t.fg("dim", s), +}; +const STATUS_ICON: Record = { + running: "▸", waiting: "⏸", completed: "✓", failed: "✖", cancelled: "·", interrupted: "⚠", created: "·", archived: "·", +}; + +function shortName(id: string): string { return id.replace(/-[a-f0-9]{8}$/, ""); } + +function elapsed(record: AgentRecord, now: number): string { + if (!record.startedAt) return ""; + const start = Date.parse(record.startedAt); + const end = record.finishedAt ? Date.parse(record.finishedAt) : (record.status === "running" || record.status === "waiting") ? now : undefined; + if (!end) return ""; + const span = Math.max(0, Math.round((end - start) / 1000)); + return `${String(Math.floor(span / 60)).padStart(2, "0")}:${String(span % 60).padStart(2, "0")}`; +} + +/** Interactive overlay showing the full agent tree with live statuses, keyboard navigation, and a detail pane. */ +export function createAgentTreeOverlay( + tree: AgentTree, + tui: { requestRender(): void }, + theme: Theme, + done: () => void, +): { render(width: number): string[]; invalidate(): void; handleInput(data: string): void; dispose(): void } { + let selected = 0; + let detail = false; + const timer = setInterval(() => tui.requestRender(), 1500); + return { + render(width) { + const rows = buildRows(tree); + if (selected >= rows.length) selected = Math.max(0, rows.length - 1); + const now = Date.now(); + const lines: string[] = [theme.fg("accent", `◆ Agent tree · ${tree.runId} · ${rows.length - 1} agents`), ""]; + for (let i = 0; i < rows.length; i++) { + const { record, prefix } = rows[i]!; + const marker = i === selected ? theme.fg("accent", ">") : " "; + const icon = STATUS_ICON[record.status]; + const status = STATUS_COLOR[record.status](theme, record.status); + const time = elapsed(record, now); + lines.push(`${marker} ${prefix}${theme.fg("text", icon)} ${theme.fg("text", shortName(record.id))} ${status}${time ? ` ${theme.fg("dim", time)}` : ""}`); + } + if (detail && rows[selected]) { + const { record } = rows[selected]!; + lines.push("", theme.fg("dim", "task:"), ...wrapTextWithAnsi(record.task, width - 2).slice(0, 4).map((line) => theme.fg("text", line))); + if (record.result?.summary) lines.push(theme.fg("dim", "result:"), ...wrapTextWithAnsi(record.result.summary, width - 2).slice(0, 5).map((line) => theme.fg("muted", line))); + if (record.error) lines.push(theme.fg("error", `error: ${truncateToWidth(record.error, width - 2)}`)); + } + lines.push("", theme.fg("dim", "↑↓ navigate · enter toggle details · esc close")); + return lines.map((line) => truncateToWidth(line, width)); + }, + invalidate() { /* rebuilt fresh on every render */ }, + handleInput(data) { + const rows = buildRows(tree); + if (matchesKey(data, Key.up)) { if (selected > 0) { selected--; tui.requestRender(); } } + else if (matchesKey(data, Key.down)) { if (selected < rows.length - 1) { selected++; tui.requestRender(); } } + else if (matchesKey(data, Key.enter)) { detail = !detail; tui.requestRender(); } + else if (matchesKey(data, Key.escape)) { done(); } + }, + dispose() { clearInterval(timer); }, + }; +} diff --git a/src/index.ts b/src/index.ts index c56556b..f504ee2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,10 +11,11 @@ export default function hypothesisMachine(pi: ExtensionAPI): void { pi.on("session_shutdown", async () => { await supervisor.shutdown(); }); pi.registerCommand("team", { description: "Show the recursive research team", handler: async (_args, ctx) => { ctx.ui.notify(supervisor.team(), "info"); } }); + pi.registerCommand("agents", { description: "Show the interactive agent tree overlay", handler: async (_args, ctx) => { await supervisor.showAgentTree(ctx); } }); pi.registerCommand("research", { description: "Start a bounded research run", handler: async (args, ctx) => { const goal = args.trim(); if (!goal) { ctx.ui.notify("Usage: /research ", "warning"); return; } const state = supervisor.loop; if (!state || !supervisor.tree) throw new Error("Not initialized"); supervisor.tree.setGoal(goal); state.setGoal(goal); state.start(); const prompt = `Research goal: ${goal}\nUse research_control and the recursive agent tools. Create specialized children only when useful. Record each iteration and stop on the coded conditions. Report important progress without flooding the chat.`; if (ctx.isIdle()) pi.sendUserMessage(prompt); else pi.sendUserMessage(prompt, { deliverAs: "followUp" }); } }); pi.registerCommand("research-status", { description: "Show research loop state", handler: async (_args, ctx) => { ctx.ui.notify(JSON.stringify(supervisor.loop?.snapshot() ?? {}, null, 2), "info"); } }); - pi.registerCommand("research-pause", { description: "Pause spawning and the loop", handler: async (_args, ctx) => { supervisor.loop?.pause(); supervisor.tree?.pause(); ctx.ui.notify("Research paused", "info"); } }); - pi.registerCommand("research-resume", { description: "Resume a paused loop", handler: async (_args, ctx) => { supervisor.loop?.resume(); supervisor.tree?.resume(); ctx.ui.notify("Research resumed", "info"); } }); + pi.registerCommand("research-pause", { description: "Pause spawning and the loop", handler: async (_args, ctx) => { if (supervisor.loop?.snapshot().status !== "running") { ctx.ui.notify("Cannot pause a non-running loop", "warning"); return; } supervisor.loop?.pause(); supervisor.tree?.pause(); ctx.ui.notify("Research paused", "info"); } }); + pi.registerCommand("research-resume", { description: "Resume a paused loop", handler: async (_args, ctx) => { if (supervisor.loop?.snapshot().status !== "paused") { ctx.ui.notify("Only a paused loop can resume", "warning"); return; } supervisor.loop?.resume(); supervisor.tree?.resume(); ctx.ui.notify("Research resumed", "info"); } }); pi.registerCommand("research-stop", { description: "Stop the run and cancel all branches", handler: async (_args, ctx) => { supervisor.loop?.stop(); await supervisor.tree?.stop(); ctx.ui.notify("Research stopped; active branches cancelled", "warning"); } }); pi.registerCommand("findings", { description: "List synthesized findings", handler: async (_args, ctx) => { ctx.ui.notify(JSON.stringify(supervisor.findings(), null, 2), "info"); } }); pi.registerCommand("hypotheses", { description: "List stored hypotheses", handler: async (_args, ctx) => { ctx.ui.notify(JSON.stringify(supervisor.findings("hypothesis"), null, 2), "info"); } }); diff --git a/src/supervisor.ts b/src/supervisor.ts index 3070b3b..a700617 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { defineTool, ModelRuntime, type ExtensionAPI, type ExtensionContext, type ModelRegistry, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { createAgentTreeOverlay } from "./agent-tree-ui.js"; import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; import { AgentTree } from "./agent-tree.js"; @@ -63,6 +64,15 @@ export class SupervisorIntegration { team(): string { return this.required().tree.render(); } findings(kind?: string): unknown { return this.required().memory.list(kind); } + /** Open the interactive agent-tree overlay. */ + async showAgentTree(ctx: ExtensionContext): Promise { + const tree = this.required().tree; + await ctx.ui.custom((tui, theme, _kb, done) => createAgentTreeOverlay(tree, tui, theme, () => done(undefined)), { + overlay: true, + overlayOptions: { width: "65%", minWidth: 60, maxHeight: "75%", anchor: "center", visible: (termWidth) => termWidth >= 60 }, + }); + } + /** Live subagent dashboard widget above the editor, refreshed on a light timer. */ private installAgentWidget(ctx: ExtensionContext): void { ctx.ui.setWidget("hm-agents", (tui, theme) => { @@ -83,7 +93,7 @@ export class SupervisorIntegration { const active = agents.filter((agent) => agent.status === "running" || agent.status === "waiting"); if (active.length === 0 && state.status !== "running") return []; const now = Date.now(); - const lines: string[] = [theme.fg("accent", `◆ ${state.runId} · ${state.status} · iter ${state.iteration} · ${active.length} active`)]; + const lines: string[] = [theme.fg("accent", `◆ ${state.runId} · ${state.status} · iter ${state.iteration} · ${active.length} active`) + theme.fg("dim", " /agents")]; for (const agent of active) { const elapsed = agent.startedAt ? Math.max(0, Math.round((now - Date.parse(agent.startedAt)) / 1000)) : 0; const stamp = `${String(Math.floor(elapsed / 60)).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`;