feat: persistent right-side agent panel (OpenCode-style) via /agents-panel

Adds a toggleable non-capturing overlay anchored top-right (36% width,
max 85% height, hidden under 100 columns) that shows the live agent tree:
statuses with icons and colors, elapsed time, auto-refreshed every 1.5s.
Because it is non-capturing, typing in the editor keeps working while the
panel is visible. The panel closes via the toggle or on supervisor shutdown.
The interactive /agents overlay with keyboard navigation and details is
unchanged.
This commit is contained in:
Emil
2026-07-31 23:41:55 +03:00
parent 460cef3d23
commit 7e4146acbc
4 changed files with 55 additions and 7 deletions
+4
View File
@@ -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
+34 -5
View File
@@ -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,
+1
View File
@@ -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 <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) => { 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"); } });
+16 -2
View File
@@ -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<void> {
@@ -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<void> {
const tree = this.required().tree;
await ctx.ui.custom<undefined>((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<void> { 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<void> { 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; }
}