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:
Emil
2026-07-31 23:31:18 +03:00
parent 5ea92990eb
commit d9bc32d909
4 changed files with 108 additions and 3 deletions
+4
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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")}`;