feat: interactive agent tree overlay (/agents)
Adds an overlay panel (ctx.ui.custom with overlay options) showing the full agent tree: tree glyphs, colored statuses and icons, live elapsed time, and keyboard navigation (up/down, enter toggles a detail pane with the agent's task and result, esc closes). The panel refreshes itself every 1.5s while open. Also fixes /research-pause and /research-resume to use the same loop-state guards as research_control (previously they could desync the tree from the loop). The live widget header now hints at /agents.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<AgentRecord["status"], (theme: Theme, text: string) => 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<AgentRecord["status"], string> = {
|
||||
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); },
|
||||
};
|
||||
}
|
||||
+3
-2
@@ -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 <goal>", "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"); } });
|
||||
|
||||
+11
-1
@@ -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<void> {
|
||||
const tree = this.required().tree;
|
||||
await ctx.ui.custom<undefined>((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")}`;
|
||||
|
||||
Reference in New Issue
Block a user