From e0f08097ecf43ab502a7a366621d67e70be94043 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 31 Jul 2026 23:58:56 +0300 Subject: [PATCH] style: OpenCode Todo-style right agent panel (bg fill, checklist, pinned footer) --- CHANGELOG.md | 10 +++++++ _panel-preview.ts | 43 +++++++++++++++++++++++++++ src/agent-tree-ui.ts | 70 ++++++++++++++++++++++++++++++++++++-------- src/supervisor.ts | 2 +- 4 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 _panel-preview.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae0652..3a31da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/_panel-preview.ts b/_panel-preview.ts new file mode 100644 index 0000000..c48edb1 --- /dev/null +++ b/_panel-preview.ts @@ -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 = { + 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(); +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")); diff --git a/src/agent-tree-ui.ts b/src/agent-tree-ui.ts index 133b70b..7c6d295 100644 --- a/src/agent-tree-ui.ts +++ b/src/agent-tree-ui.ts @@ -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 */ }, diff --git a/src/supervisor.ts b/src/supervisor.ts index 2064ef7..57b924f 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -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 }, }); }