style: OpenCode Todo-style right agent panel (bg fill, checklist, pinned footer)

This commit is contained in:
Emil
2026-07-31 23:58:56 +03:00
parent 18dc4cbb16
commit e0f08097ec
4 changed files with 111 additions and 14 deletions
+10
View File
@@ -2,6 +2,16 @@
## Unreleased
- Restyled the persistent right-side agent panel (`/agents-panel`) to match
OpenCode's Todo/plan sidebar: a full-height background column (no box border,
panel background painted across the whole overlay width) with a bold `▼ agents`
title, a flat OpenCode-style checklist (`[•]` in-progress in warning color,
`[ ]` pending and `[✓]` completed in muted, `[✖]` failed in error), live
per-agent activity lines under running agents, and the footer hint pinned to
the bottom of the column. The panel now sits flush against the right edge of
the terminal and stops two rows above the bottom so the editor and status bar
stay visible.
- Fixed the research loop and agent tree getting permanently stuck after
`research_control stop`: `start` now restarts a stopped/completed run in the
same session (`AgentTree.restart` revives the run, archives old branches, and
+43
View File
@@ -0,0 +1,43 @@
import { Theme } from "@earendil-works/pi-coding-agent";
import { createAgentPanelComponent, agentLastActivity } from "./src/agent-tree-ui.js";
import type { AgentRecord } from "./src/types.js";
const theme = new Theme(
{ accent: "#8abeb7", border: "#5f87ff", borderAccent: "#00d7ff", borderMuted: "#505050", success: "#b5bd68", error: "#cc6666", warning: "#ffff00", muted: "#808080", dim: "#666666", text: "#d4d4d4", thinkingText: "#808080", userMessageText: "#d4d4d4", customMessageText: "#d4d4d4", customMessageLabel: "#9575cd", toolTitle: "#d4d4d4", toolOutput: "#808080", mdHeading: "#f0c674", mdLink: "#81a2be", mdLinkUrl: "#666666", mdCode: "#8abeb7", mdCodeBlock: "#b5bd68", mdCodeBlockBorder: "#808080", mdQuote: "#808080", mdQuoteBorder: "#808080", mdHr: "#808080", mdListBullet: "#8abeb7", toolDiffAdded: "#b5bd68", toolDiffRemoved: "#cc6666", toolDiffContext: "#808080", syntaxComment: "#808080", syntaxKeyword: "#cc6666", syntaxFunction: "#8abeb7", syntaxVariable: "#d4d4d4", syntaxString: "#b5bd68", syntaxNumber: "#f0c674", syntaxType: "#5f87ff", syntaxOperator: "#d4d4d4", syntaxPunctuation: "#808080", thinkingOff: "#666666", thinkingMinimal: "#666666", thinkingLow: "#666666", thinkingMedium: "#666666", thinkingHigh: "#666666", thinkingXhigh: "#666666", thinkingMax: "#666666", bashMode: "#cc6666" },
{ selectedBg: "#3a3a4a", userMessageBg: "#343541", customMessageBg: "#2d2838", toolPendingBg: "#282832", toolSuccessBg: "#283228", toolErrorBg: "#3c2828" },
"truecolor",
);
const base: Omit<AgentRecord, "id" | "status" | "startedAt" | "finishedAt"> = {
runId: "run-132965dd", parentId: "root", children: [], lineage: [], depth: 1,
task: "x", taskFingerprint: "x", expectedOutput: "", completionCriteria: "", specPath: "",
createdAt: new Date(Date.now() - 20 * 60000).toISOString(),
};
const mk = (id: string, status: AgentRecord["status"], startedMin: number, finishedMin?: number): AgentRecord => ({
...base, id, status,
startedAt: new Date(Date.now() - startedMin * 60000).toISOString(),
finishedAt: finishedMin !== undefined ? new Date(Date.now() - finishedMin * 60000).toISOString() : undefined,
});
// Fake tree
const agents = new Map<string, AgentRecord>();
agents.set("root", { ...base, id: "root", parentId: null, depth: 0, status: "completed", startedAt: new Date(Date.now() - 30 * 60000).toISOString(), finishedAt: new Date(Date.now() - 25 * 60000).toISOString() });
agents.set("market-competitors-bb389dda", mk("market-competitors-bb389dda", "running", 3));
agents.set("regulatory-gov-support-3b922f86", mk("regulatory-gov-support-3b922f86", "waiting", 3));
agents.set("llm-infrastructure-niches-b1308f72", mk("llm-infrastructure-niches-b1308f72", "created", 0));
agents.set("science-education-niches-293d399a", mk("science-education-niches-293d399a", "completed", 55, 12));
agents.set("demand-b2b-b2g-57637d25", mk("demand-b2b-b2g-57637d25", "failed", 40, 9));
const tree = {
rootId: "root", runId: "run-132965dd",
list: () => [...agents.values()],
} as never;
const tui = { requestRender() {}, terminal: { rows: 40 } } as never;
const panel = createAgentPanelComponent(tree, tui, theme, () => {});
const out = panel.render(50);
// strip ANSI, show visibly
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
console.log("+" + "-".repeat(50) + "+");
for (const line of out) console.log("|" + strip(line) + "|");
console.log("+" + "-".repeat(50) + "+");
console.log("lines:", out.length, "| last line ends with reset:", out[out.length-1]!.endsWith("\x1b[0m"));
+57 -13
View File
@@ -85,32 +85,76 @@ export function agentLastActivity(sessionFile: string | undefined): string | und
return line || undefined;
}
/** Persistent non-capturing right-side panel: live agent tree without keyboard navigation. */
const PANEL_BG = "toolPendingBg";
/**
* OpenCode-style persistent right-side panel: a full-height background column
* (no box border) holding a flat checklist of agents, mirroring the Todo/plan
* sidebar of OpenCode's TUI: `[•]` in-progress (warning), `[ ]` pending and
* `[✓]` completed (muted), with a bold title and a footer pinned to the bottom.
*/
export function createAgentPanelComponent(
tree: AgentTree,
tui: { requestRender(): void },
tui: { requestRender(): void; terminal: { rows: number } },
theme: Theme,
done: () => void,
): { render(width: number): string[]; invalidate(): void; handleInput(data: string): void; dispose(): void } {
const timer = setInterval(() => tui.requestRender(), 1500);
/** Paint a line with the panel background, padded with spaces to the full overlay width (padding must be inside the bg ANSI codes, otherwise the TUI compositor resets it). */
const paint = (line: string, width: number): string => theme.bg(PANEL_BG, truncateToWidth(line, width, "…", true));
const blank = (width: number): string => theme.bg(PANEL_BG, " ".repeat(width));
const marker = (record: AgentRecord): { glyph: string; color: (s: string) => string } => {
switch (record.status) {
case "running": return { glyph: "•", color: (s) => theme.fg("warning", s) };
case "waiting": return { glyph: " ", color: (s) => theme.fg("muted", s) };
case "completed": return { glyph: "✓", color: (s) => theme.fg("muted", s) };
case "failed": return { glyph: "✖", color: (s) => theme.fg("error", s) };
case "interrupted": return { glyph: "!", color: (s) => theme.fg("warning", s) };
default: return { glyph: " ", color: (s) => theme.fg("dim", s) };
}
};
const row = (record: AgentRecord, width: number, now: number): string => {
const { glyph, color } = marker(record);
const time = elapsed(record, now);
return paint(`${color(`[${glyph}]`)} ${color(shortName(record.id))}${time ? ` ${theme.fg("dim", time)}` : ""}`, width);
};
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)}` : ""}`);
const all = tree.list().filter((agent) => agent.id !== tree.rootId);
const isActive = (agent: AgentRecord): boolean => agent.status === "running" || agent.status === "waiting" || agent.status === "created";
const active = all.filter(isActive);
const finished = all.filter((agent) => !isActive(agent)).sort((a, b) => (b.finishedAt ?? "").localeCompare(a.finishedAt ?? ""));
const visibleActive = active.slice(0, 8);
const visibleFinished = finished.slice(0, 3);
const hidden = Math.max(0, active.length - visibleActive.length + finished.length - visibleFinished.length);
const lines: string[] = [
paint(theme.fg("text", theme.bold("▼ agents")) + theme.fg("dim", ` · ${tree.runId}`), width),
paint(theme.fg("dim", `${active.length} active · ${all.length} agents`), width),
blank(width),
];
for (const record of visibleActive) {
lines.push(row(record, width, now));
if (record.status === "running" || record.status === "waiting") {
const activity = agentLastActivity(record.sessionFile);
if (activity) lines.push(theme.fg("dim", ` ${truncateToWidth(activity, Math.max(20, width - 8))}`));
if (activity) lines.push(paint(theme.fg("dim", ` ${truncateToWidth(activity, Math.max(20, width - 6))}`), width));
}
}
lines.push("", theme.fg("dim", "/agents — details · /agents-panel — hide"));
return lines.map((line) => truncateToWidth(line, width));
if (visibleFinished.length > 0) {
lines.push(blank(width));
for (const record of visibleFinished) lines.push(row(record, width, now));
}
if (hidden > 0) lines.push(paint(theme.fg("dim", `+${hidden} more…`), width));
// Fill the rest of the right column with the panel background, keeping the
// editor + status rows at the bottom visible, and pin the footer at the bottom.
const total = Math.max(1, tui.terminal.rows - 2);
if (lines.length < total) {
while (lines.length < total - 1) lines.push(blank(width));
lines.push(paint(theme.fg("dim", "/agents — details · /agents-panel — hide"), width));
}
return lines;
},
invalidate() { /* rebuilt fresh on every render */ },
handleInput() { /* non-capturing: keys go to the editor */ },
+1 -1
View File
@@ -83,7 +83,7 @@ export class SupervisorIntegration {
return createAgentPanelComponent(tree, tui, theme, () => { this.agentPanelClose = undefined; done(undefined); });
}, {
overlay: true,
overlayOptions: { width: "40%", minWidth: 44, maxHeight: "100%", anchor: "top-right", margin: 1, nonCapturing: true, visible: (termWidth) => termWidth >= 100 },
overlayOptions: { width: "40%", minWidth: 44, maxHeight: "100%", anchor: "top-right", margin: { top: 0, right: 0, bottom: 0, left: 1 }, nonCapturing: true, visible: (termWidth) => termWidth >= 100 },
});
}