diff --git a/CHANGELOG.md b/CHANGELOG.md index 0172181..c1cfd72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ 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`. +- New `/agents-panel` command toggles a persistent OpenCode-style right-side + panel with the live agent tree: non-capturing (typing keeps working), + auto-refreshed every 1.5 s, hidden on terminals narrower than 100 columns, + and closed automatically on shutdown. ## 0.1.1 — 2026-07-31 diff --git a/src/agent-tree-ui.ts b/src/agent-tree-ui.ts index ed1db69..d056539 100644 --- a/src/agent-tree-ui.ts +++ b/src/agent-tree-ui.ts @@ -3,19 +3,19 @@ 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 } +interface TreeRow { record: AgentRecord; prefix: string; depth: number } /** 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 walk = (record: AgentRecord, ancestors: boolean[], last: boolean, depth: number): 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)); + rows.push({ record, prefix: indent + connector, depth }); + record.children.forEach((childId, index) => walk(tree.inspect(childId), [...ancestors, last], index === record.children.length - 1, depth + 1)); }; - walk(root, [], false); + walk(root, [], false, 0); return rows; } @@ -44,6 +44,35 @@ function elapsed(record: AgentRecord, now: number): string { return `${String(Math.floor(span / 60)).padStart(2, "0")}:${String(span % 60).padStart(2, "0")}`; } +/** Persistent non-capturing right-side panel: live agent tree without keyboard navigation. */ +export function createAgentPanelComponent( + tree: AgentTree, + tui: { requestRender(): void }, + theme: Theme, + done: () => void, +): { render(width: number): string[]; invalidate(): void; handleInput(data: string): void; dispose(): void } { + const timer = setInterval(() => tui.requestRender(), 1500); + return { + render(width) { + const rows = buildRows(tree).filter((row) => row.depth > 0); + const now = Date.now(); + const active = rows.filter((row) => row.record.status === "running" || row.record.status === "waiting").length; + const lines: string[] = [theme.fg("accent", `◆ agents`) + theme.fg("dim", ` · ${tree.runId}`), theme.fg("dim", `${active} active · ${rows.length} agents`), ""]; + for (const { record, prefix } of rows) { + const icon = STATUS_ICON[record.status]; + const status = STATUS_COLOR[record.status](theme, record.status); + const time = elapsed(record, now); + lines.push(`${prefix}${theme.fg("text", icon)} ${theme.fg("text", shortName(record.id))} ${status}${time ? ` ${theme.fg("dim", time)}` : ""}`); + } + lines.push("", theme.fg("dim", "/agents — details · /agents-panel — hide")); + return lines.map((line) => truncateToWidth(line, width)); + }, + invalidate() { /* rebuilt fresh on every render */ }, + handleInput() { /* non-capturing: keys go to the editor */ }, + dispose() { clearInterval(timer); }, + }; +} + /** Interactive overlay showing the full agent tree with live statuses, keyboard navigation, and a detail pane. */ export function createAgentTreeOverlay( tree: AgentTree, diff --git a/src/index.ts b/src/index.ts index f504ee2..2769829 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export default function hypothesisMachine(pi: ExtensionAPI): void { 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("agents-panel", { description: "Toggle a persistent right-side agent panel", handler: async (_args, ctx) => { if (supervisor.agentPanelClose) { supervisor.agentPanelClose(); ctx.ui.notify("Agent panel closed", "info"); return; } if (!supervisor.tree) { ctx.ui.notify("Hypothesis Machine is not initialized", "warning"); return; } void supervisor.showAgentPanel(ctx); ctx.ui.notify("Agent panel opened (needs a wide terminal)", "info"); } }); 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) => { 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"); } }); diff --git a/src/supervisor.ts b/src/supervisor.ts index 7c8dc49..44f8f4c 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -1,7 +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 { createAgentPanelComponent, createAgentTreeOverlay } from "./agent-tree-ui.js"; import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; import { AgentTree } from "./agent-tree.js"; @@ -24,6 +24,8 @@ export class SupervisorIntegration { private lastScheduledIteration = 0; private widgetTimer: NodeJS.Timeout | undefined; private requestAgentRender: (() => void) | undefined; + /** Closes the persistent right-side agent panel if it is open. */ + agentPanelClose: (() => void) | undefined; constructor(private readonly pi: ExtensionAPI) {} async start(ctx: ExtensionContext): Promise { @@ -73,6 +75,18 @@ export class SupervisorIntegration { }); } + /** Open a persistent non-capturing agent panel on the right side (OpenCode-style). */ + async showAgentPanel(ctx: ExtensionContext): Promise { + const tree = this.required().tree; + await ctx.ui.custom((tui, theme, _kb, done) => { + this.agentPanelClose = () => done(undefined); + return createAgentPanelComponent(tree, tui, theme, () => { this.agentPanelClose = undefined; done(undefined); }); + }, { + overlay: true, + overlayOptions: { width: "36%", minWidth: 44, maxHeight: "85%", anchor: "top-right", margin: 1, nonCapturing: true, visible: (termWidth) => termWidth >= 100 }, + }); + } + /** Live subagent dashboard widget above the editor, refreshed on a light timer. */ private installAgentWidget(ctx: ExtensionContext): void { ctx.ui.setWidget("hm-agents", (tui, theme) => { @@ -108,5 +122,5 @@ export class SupervisorIntegration { private shortName(id: string): string { return id.replace(/-[a-f0-9]{8}$/, ""); } continueIfNeeded(ctx: ExtensionContext): void { const loop = this.loop; if (!loop || !ctx.isIdle()) return; const state = loop.snapshot(); if (state.status !== "running" || state.iteration <= this.lastScheduledIteration) return; this.lastScheduledIteration = state.iteration; if (ctx.hasUI) ctx.ui.setStatus("hypothesis-machine", `HM ${state.runId} · iteration ${state.iteration + 1}`); this.pi.sendUserMessage(`Continue bounded research run ${state.runId} with iteration ${state.iteration + 1}. Reassess unknowns and contradictions, use agents only where they add information, then call research_control record_iteration. Stop when its coded state is no longer running.`); } - async shutdown(): Promise { if (this.widgetTimer) { clearInterval(this.widgetTimer); this.widgetTimer = undefined; } this.requestAgentRender = undefined; await this.tree?.shutdown(); this.memory?.close(); this.tree = undefined; } + async shutdown(): Promise { this.agentPanelClose?.(); this.agentPanelClose = undefined; if (this.widgetTimer) { clearInterval(this.widgetTimer); this.widgetTimer = undefined; } this.requestAgentRender = undefined; await this.tree?.shutdown(); this.memory?.close(); this.tree = undefined; } }