diff --git a/extensions/shared/activity-status.test.ts b/extensions/shared/activity-status.test.ts index 4fc7adae..3a595399 100644 --- a/extensions/shared/activity-status.test.ts +++ b/extensions/shared/activity-status.test.ts @@ -60,7 +60,7 @@ test("status text names its own view command", () => { done: 2, failed: 0, }), - "subagents: ■ 1 running · ■ 2 done · /subagents to view", + "subagents: 1 running · 2 done · /subagents to view", ); assert.equal( formatActivityStatus(identityTheme, "workflows", { @@ -68,6 +68,6 @@ test("status text names its own view command", () => { done: 0, failed: 3, }), - "workflows: ■ 3 failed · /workflows to view", + "workflows: 3 failed · /workflows to view", ); }); diff --git a/extensions/shared/activity-status.ts b/extensions/shared/activity-status.ts index 1baf1048..386187dd 100644 --- a/extensions/shared/activity-status.ts +++ b/extensions/shared/activity-status.ts @@ -8,8 +8,6 @@ export interface ActivityCounts { failed: number; } -const SQUARE = "■"; - /** * Settled work is an unread notice, not a session tally: `done`/`failed` stay * visible until the user's next explicit request acknowledges them, while @@ -49,15 +47,18 @@ export function formatActivityStatus( label: "subagents" | "workflows", counts: ActivityCounts, ) { + // No status glyphs here: the footer line is a static string refreshed on + // events, so a spinner would freeze between updates — the colored words + // carry the state on their own. const parts: string[] = []; if (counts.running > 0) { - parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`)); + parts.push(theme.fg("warning", `${counts.running} running`)); } if (counts.done > 0) { - parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`)); + parts.push(theme.fg("success", `${counts.done} done`)); } if (counts.failed > 0) { - parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`)); + parts.push(theme.fg("error", `${counts.failed} failed`)); } parts.push(theme.fg("accent", `/${label}`) + theme.fg("dim", " to view")); diff --git a/extensions/subagents/docs/design-plan.md b/extensions/subagents/docs/design-plan.md index 007dbc0a..c9bcaf77 100644 --- a/extensions/subagents/docs/design-plan.md +++ b/extensions/subagents/docs/design-plan.md @@ -86,8 +86,8 @@ the parent conversation. ### 1.4 UI (carried over into v2 essentially as-is) -1. **Footer status** (`ctx.ui.setStatus("subagents", ...)`): `subagents: ■ 2 running · - ■ 1 done · ■ 1 failed · /subagents to view` (warning/success/error colored squares; +1. **Footer status** (`ctx.ui.setStatus("subagents", ...)`): `subagents: 2 running · + 1 done · 1 failed · /subagents to view` (warning/success/error colored words; cleared when no subagents). Driven by manager change listener. 2. **`subagent-result` message renderer**: status icon (`■`/`x`) + bold accent header `subagent sa-N · title · finished/failed`; collapsed = first 8 body lines + diff --git a/extensions/workflows/dashboard.test.ts b/extensions/workflows/dashboard.test.ts index a69fab48..b738a2cb 100644 --- a/extensions/workflows/dashboard.test.ts +++ b/extensions/workflows/dashboard.test.ts @@ -13,6 +13,7 @@ import test from "node:test"; import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; import type { Theme, WorkflowDetails } from "./model.ts"; +import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; // runsDir() resolves against getAgentDir(), which reads this env var. const agentDir = mkdtempSync(join(tmpdir(), "my-pi-setup-workflows-")); @@ -305,7 +306,14 @@ test("direct workflow navigation drills right and returns left through every lev cost: 0, turns: 1, }, - transcript: [{ role: "user", text: "Write the draft" }], + transcript: [ + { role: "user", text: "Write the draft" }, + { + role: "tool", + name: "bash", + text: '{"command":"git status\u001b]52;c;clipboard\u0007"}', + }, + ], }, ], }; @@ -348,7 +356,10 @@ test("direct workflow navigation drills right and returns left through every lev assert.match(dashboard.render(120).at(-1) ?? "", /select agent/); dashboard.handleInput("right"); - assert.match(dashboard.render(120).join("\n"), /Transcript/); + const transcript = dashboard.render(120).join("\n"); + assert.match(transcript, /Transcript/); + assert.match(transcript, /git status/); + assert.doesNotMatch(transcript, /clipboard|\u001b/); dashboard.handleInput("left"); assert.match(dashboard.render(120).at(-1) ?? "", /select agent/); @@ -363,6 +374,51 @@ test("direct workflow navigation drills right and returns left through every lev } }); +test("live workflow dashboard repaints on the shared spinner cadence", (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + writeRun("wf_123abc", Date.now()); + const details: WorkflowDetails = { + runId: "wf_123abc", + sessionId: SESSION, + name: "spinner", + status: "running", + background: false, + startedAt: Date.now(), + phases: [], + agents: [], + }; + let renders = 0; + const dashboard = new WorkflowDashboard( + { + terminal: { rows: 30 }, + requestRender() { + renders += 1; + }, + } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => new Map([[details.runId, details]]), + SESSION, + new Set(), + 0, + () => {}, + ); + try { + t.mock.timers.tick(SPINNER_INTERVAL_MS - 1); + assert.equal(renders, 0); + t.mock.timers.tick(1); + assert.equal(renders, 1); + } finally { + dashboard.dispose(); + } +}); + function saveReport(runId: string) { writeRun(runId, Date.now() - 1_000); const tui = { diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 0e2032ce..47a3652a 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -5,8 +5,8 @@ * name 5/5 agents · 31m18s · done * description * ╭ Phases ────────────╮ ╭ Gather · 3 agents ──────────────────────────────╮ - * │ ❯ ■ Gather 3/3 │ │ ■ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│ - * │ ■ Verify 1/1 │ │ ■ Other bot feedback gpt-5 · 9%/372k 4m43s│ + * │ ❯ ✓ Gather 3/3 │ │ ✓ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│ + * │ ⠿ Verify 1/1 │ │ ⠿ Other bot feedback gpt-5 · 9%/372k 4m43s│ * ╰────────────────────╯ ╰─────────────────────────────────────────────────╯ * up/down select · right enter · left back · s save report */ @@ -23,15 +23,17 @@ import { matchesKey, type TUI, truncateToWidth, - visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; +import { contextPercent } from "../shared/context-utilization.ts"; +import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; import { panelFrame, type ScreenHint, screenTitleLine, hintLine as sharedHintLine, } from "../shared/screen-chrome.ts"; +import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { isAcceptanceLedger } from "./acceptance.ts"; import { projectWorkflowGraph } from "./graph-projection.ts"; @@ -53,11 +55,11 @@ import { phaseGroups, resolveWorkflowRunTarget, resultJson, - SQUARE, sanitizeLine, shortenHome, - stateSquare, + stateGlyph, statusColor, + statusGlyph, statusWord, type Theme, type TranscriptEntry, @@ -670,7 +672,7 @@ export class WorkflowDashboard { this.refresh(); this.tui.requestRender(); } - }, 500); + }, SPINNER_INTERVAL_MS); } dispose() { @@ -906,17 +908,6 @@ export class WorkflowDashboard { return lines.map((line) => truncateToWidth(line, width, "")); } - /** Compose `left ... right` within `width`, truncating left when needed. */ - private split(left: string, right: string, width: number): string { - const rightWidth = visibleWidth(right); - let text = left; - if (visibleWidth(text) + rightWidth + 1 > width) { - text = truncateToWidth(text, Math.max(0, width - rightWidth - 2), "…"); - } - const pad = Math.max(1, width - visibleWidth(text) - rightWidth); - return text + " ".repeat(pad) + right; - } - /** Bordered panel with a title in the top border, padded to exact height. */ private panel( title: string, @@ -1002,8 +993,8 @@ export class WorkflowDashboard { ) + theme.fg(statusColor(d.status), statusWord(d.status)) + " "; - const left = ` ${marker} ${statusSquareFor(d, theme)} ${label} ${theme.fg("dim", d.runId)}`; - return this.split(left, right, width - 2); + const left = ` ${marker} ${statusGlyph(d.status, theme, Date.now())} ${label} ${theme.fg("dim", d.runId)}`; + return fitNavigationSides(left, right, width - 2); }); lines.push(...this.panel("Runs", rows, width, panelHeight)); lines.push( @@ -1033,26 +1024,35 @@ export class WorkflowDashboard { const { done, failed } = countStates(d); const settled = done + failed; + // Same language as the transcript card: the glyph carries the state, the + // status word only shows for terminal states. const right = theme.fg( "dim", - `${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `, + `${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)}`, ) + - theme.fg(statusColor(d.status), statusWord(d.status)) + - " "; + (d.status === "running" + ? " " + : theme.fg("dim", " · ") + + theme.fg(statusColor(d.status), statusWord(d.status)) + + " "); lines.push( - this.split( - " " + theme.bold(theme.fg("accent", d.name ?? d.runId)), + fitNavigationSides( + ` ${statusGlyph(d.status, theme, Date.now())} ${theme.bold(theme.fg("accent", d.name ?? d.runId))}`, right, width, ), ); const totals = formatUsage(aggregateUsage(d.agents)); - const graphSummary = d.graph ? workflowGraphSummary(d.graph) : undefined; + // A graph with no edges is a flat swarm — "N nodes · 0 edges" is noise. + const graphSummary = + d.graph && d.graph.edges.length > 0 + ? workflowGraphSummary(d.graph) + : undefined; const subRight = [graphSummary, totals].filter(Boolean).join(" · "); const subLeft = " " + theme.fg("muted", d.description ?? d.runId); lines.push( - this.split( + fitNavigationSides( subLeft, subRight ? theme.fg("dim", `${subRight} `) : " ", width, @@ -1089,7 +1089,7 @@ export class WorkflowDashboard { const groupDone = group.agents.filter( (a) => a.state !== "running", ).length; - const square = groupSquare(group, theme); + const square = groupGlyph(group, theme); const title = selected && this.detailFocus === "phases" ? theme.fg("accent", group.title) @@ -1098,7 +1098,11 @@ export class WorkflowDashboard { group.agents.length > 0 ? theme.fg("dim", `${groupDone}/${group.agents.length} `) : theme.fg("dim", "- "); - return this.split(` ${marker} ${square} ${title}`, counts, sidebarInner); + return fitNavigationSides( + ` ${marker} ${square} ${title}`, + counts, + sidebarInner, + ); }); // Right: agents in the selected phase. @@ -1110,6 +1114,10 @@ export class WorkflowDashboard { 0, ...selectedGroup.agents.map((a) => a.label.length), ); + const models = new Set( + selectedGroup.agents.map((a) => a.model).filter(Boolean), + ); + const mixedModels = models.size > 1; const agentWindow = this.windowed( selectedGroup.agents, this.agentIndex, @@ -1122,9 +1130,18 @@ export class WorkflowDashboard { selected && this.detailFocus === "agents" ? theme.fg("accent", "❯") : " "; + // The model repeats on every row when the run is homogeneous; only + // mixed fleets earn a per-row model. Context occupancy matters while + // an agent runs; once settled, its cost is the elapsed on the right. + const percent = contextPercent({ + tokens: agent.usage.contextTokens, + contextWindow: agent.contextWindow, + }); const stats = [ - agent.model, - agentContext(agent), + mixedModels ? agent.model : undefined, + agent.state === "running" && percent !== undefined + ? `${percent}%` + : undefined, agent.acceptance ? `acceptance:${agent.acceptance.status}` : undefined, @@ -1135,16 +1152,16 @@ export class WorkflowDashboard { selected && this.detailFocus === "agents" ? theme.fg("accent", agent.label.padEnd(Math.min(maxLabel, 40))) : theme.fg("text", agent.label.padEnd(Math.min(maxLabel, 40))); - const left = ` ${marker} ${stateSquare(agent.state, theme)} ${label} ${theme.fg("dim", stats)}`; + const left = ` ${marker} ${stateGlyph(agent.state, theme, Date.now())} ${label}${stats ? ` ${theme.fg("dim", stats)}` : ""}`; const right = theme.fg( "dim", `${formatElapsed(agent.startedAt, agent.finishedAt)} `, ); - agentRows.push(this.split(left, right, agentsInner)); + agentRows.push(fitNavigationSides(left, right, agentsInner)); if (agent.error) { agentRows.push( truncateToWidth( - ` ${theme.fg("error", sanitizeLine(agent.error, 2_000))}`, + ` ${theme.fg("error", displayError(agent.error))}`, agentsInner, "…", ), @@ -1237,14 +1254,30 @@ export class WorkflowDashboard { } for (const entry of agent.transcript) { + // Tool calls are one-liners: the arrow shows direction, the accent name + // says what ran, and the arguments collapse to a single compact line + // instead of a vertical JSON block. + if (entry.role === "tool") { + const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown"; + const args = compactInlineJson(entry.text); + rows.push( + ` ${theme.fg("muted", "→")} ${theme.fg("accent", name)}${args ? theme.fg("dim", ` ${args}`) : ""}`, + ); + continue; + } const label = transcriptLabel(entry); const color = transcriptColor(entry); + const marker = entry.role === "toolResult" ? "←" : "●"; rows.push( - ` ${theme.fg(color, SQUARE)} ${theme.bold(theme.fg(color, label))}`, + ` ${theme.fg(color, marker)} ${theme.bold(theme.fg(color, label))}`, ); const contentWidth = Math.max(8, width - 4); const styled = theme.fg( - entry.role === "thinking" ? "dim" : entry.isError ? "error" : "text", + entry.role === "thinking" || entry.role === "toolResult" + ? "dim" + : entry.isError + ? "error" + : "text", sanitizeTerminalText(entry.text), ); for (const line of wrapTextWithAnsi(styled, contentWidth)) { @@ -1275,14 +1308,14 @@ export class WorkflowDashboard { .join(" · ") + " ", ); lines.push( - this.split( - ` ${stateSquare(agent.state, theme)} ${theme.bold(theme.fg("accent", agent.label))}`, + fitNavigationSides( + ` ${stateGlyph(agent.state, theme, Date.now())} ${theme.bold(theme.fg("accent", agent.label))}`, right, width, ), ); lines.push( - this.split( + fitNavigationSides( ` ${theme.fg("muted", `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`)}`, theme.fg("dim", `${agent.transcript.length} entries `), width, @@ -1320,13 +1353,38 @@ export class WorkflowDashboard { } } +/** + * Agent errors often arrive as an HTTP status plus a JSON body + * (`429: {"message":"user rate limit exceeded …"}`); the panel row keeps the + * status code and the message, dropping the braces and quotes. + */ +function displayError(error: string) { + const clean = sanitizeLine(error, 2_000); + const match = clean.match(/^(\d{3})[:\s]*\{\s*"message"\s*:\s*"([^"]+)"/); + if (match) return `${match[1]}: ${match[2]}`; + return clean; +} + function transcriptLabel(entry: TranscriptEntry): string { - if (entry.role === "user") return "USER"; - if (entry.role === "assistant") return "ASSISTANT"; - if (entry.role === "thinking") return "THINKING"; + if (entry.role === "user") return "user"; + if (entry.role === "assistant") return "assistant"; + if (entry.role === "thinking") return "thinking"; const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown"; - if (entry.role === "tool") return `TOOL ${name}`; - return `RESULT ${name}`; + return name; +} + +/** + * Tool arguments arrive pretty-printed over many lines; the transcript shows + * them inline. Non-JSON text passes through flattened. + */ +function compactInlineJson(text: string) { + const flat = sanitizeTerminalText(text).trim(); + if (!flat) return ""; + try { + return JSON.stringify(JSON.parse(flat)); + } catch { + return flat.replace(/\s+/g, " "); + } } function transcriptColor( @@ -1340,17 +1398,13 @@ function transcriptColor( return "muted"; } -function statusSquareFor(details: WorkflowDetails, theme: Theme): string { - return theme.fg(statusColor(details.status), SQUARE); -} - -function groupSquare(group: PhaseGroup, theme: Theme): string { - if (group.agents.length === 0) return theme.fg("dim", SQUARE); +function groupGlyph(group: PhaseGroup, theme: Theme) { + if (group.agents.length === 0) return theme.fg("dim", "○"); if (group.agents.some((a) => a.state === "running")) - return theme.fg("warning", SQUARE); + return theme.fg("warning", spinnerFrame(Date.now())); if (group.agents.some((a) => a.state === "error")) - return theme.fg("error", SQUARE); - return theme.fg("success", SQUARE); + return theme.fg("error", "✗"); + return theme.fg("success", "✓"); } /** Open the dashboard as a full-screen overlay. */ @@ -1360,10 +1414,10 @@ export async function showWorkflowDashboard( initialRunId?: string, startedSince = 0, onAbort?: (runId: string) => boolean, -): Promise { +) { await ctx.ui.custom( (tui, theme, keybindings, done) => { - const dashboard: WorkflowDashboard = new WorkflowDashboard( + const dashboard = new WorkflowDashboard( tui, theme, keybindings, diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 1688c7c5..805201af 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -40,15 +40,24 @@ import { keyHint, type SessionManager, } from "@earendil-works/pi-coding-agent"; -import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; +import { + Container, + Markdown, + Spacer, + Text, + truncateToWidth, +} from "@earendil-works/pi-tui"; import { type Static, Type } from "typebox"; import { formatActivityStatus } from "../shared/activity-status.ts"; import { waitBounded } from "../shared/child-session.ts"; +import { contextPercent } from "../shared/context-utilization.ts"; import { registerEditorLayer, removeEditorLayer, } from "../shared/editor-layers.ts"; +import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; import { loadSetupConfig } from "../shared/setup-config.ts"; +import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; import { OPENPI_TOOL_SURFACE, patchOwnedTools, @@ -118,12 +127,12 @@ import { refreshWorkflowGraph, resolveWorkflowRunTarget, resultJson, - SQUARE, sanitizeLine, sanitizeWorkflowDisplayLine, sanitizeWorkflowDisplayText, - stateSquare, + stateGlyph, statusColor, + statusGlyph, statusWord, type WorkflowDetails, } from "./model.ts"; @@ -174,6 +183,292 @@ import { const PREVIEW_LENGTH = 200; const EMIT_INTERVAL_MS = 120; +/** Header of a workflow card: identity and phase on the left, metrics right. */ +function runHeader( + details: WorkflowDetails, + theme: Parameters[1], + now: number, +) { + const { done, failed } = countStates(details); + const settled = done + failed; + const elapsed = formatElapsed(details.startedAt, details.finishedAt); + // A just-launched run has no agents and a 0s clock; the metrics join in + // once there is something real to report. + const counts = + details.agents.length > 0 + ? `${settled}/${details.agents.length} agents` + : undefined; + const metrics = [counts, counts || elapsed !== "0s" ? elapsed : undefined] + .filter(Boolean) + .join(" · "); + let left = + `${statusGlyph(details.status, theme, now)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + + theme.fg( + "accent", + sanitizeWorkflowDisplayLine(details.name ?? details.runId), + ); + if (details.status === "running" && details.currentPhase) { + left += theme.fg( + "muted", + ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`, + ); + } + // The glyph already carries the run state, so the status word only stays + // for terminal states. + let right = theme.fg("dim", metrics); + if (details.status !== "running") { + right += + theme.fg("dim", `${metrics ? " · " : ""}`) + + theme.fg(statusColor(details.status), statusWord(details.status)); + } + if (failed) right += theme.fg("error", ` · ${failed} failed`); + return { left, right }; +} + +/** + * Collapsed workflow card, rebuilt per repaint. Metrics right-align like the + * below-editor strip, and each agent row carries one number only: context + * occupancy says a running agent is alive, elapsed says what a settled one + * cost. A swarm shows the first few agents and summarizes the rest instead + * of flooding the chat. + */ +function buildCollapsedRows( + details: WorkflowDetails, + theme: Parameters[1], + width: number, + now: number, + totals: string, +) { + const header = runHeader(details, theme, now); + const rows = [fitNavigationSides(header.left, header.right, width)]; + const collapsedAgents = details.agents.slice(0, 8); + for (const agent of collapsedAgents) { + const percent = contextPercent({ + tokens: agent.usage.contextTokens, + contextWindow: agent.contextWindow, + }); + const stat = + agent.state === "running" + ? percent === undefined + ? undefined + : `${percent}%` + : formatElapsed(agent.startedAt, agent.finishedAt); + const left = ` ${stateGlyph(agent.state, theme, now)} ${theme.fg( + "accent", + sanitizeWorkflowDisplayLine(agent.label), + )}`; + rows.push( + stat + ? fitNavigationSides(left, theme.fg("dim", stat), width) + : truncateToWidth(left, width, "…"), + ); + } + const hiddenAgents = details.agents.length - collapsedAgents.length; + if (hiddenAgents > 0) { + rows.push(` ${theme.fg("dim", `… ${hiddenAgents} more`)}`); + } + // Only the tail collapsed: the newest lines are the ones that say where + // the run is now. + for (const entry of (details.logs ?? []).slice(-3)) { + rows.push( + truncateToWidth( + ` ${theme.fg("muted", "›")} ${theme.fg( + "dim", + sanitizeWorkflowDisplayLine(entry.text), + )}`, + width, + "…", + ), + ); + } + if (totals) rows.push(` ${theme.fg("dim", `Total: ${totals}`)}`); + if (details.error) { + rows.push( + truncateToWidth( + ` ${theme.fg( + "error", + `Error: ${sanitizeWorkflowDisplayLine(details.error)}`, + )}`, + width, + "…", + ), + ); + } + // Expanding only earns its hint when there is more to see. + if ( + details.agents.length > 0 || + (details.logs ?? []).length > 0 || + details.description || + details.result !== undefined || + details.error + ) { + rows.push( + theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`), + ); + } + return rows; +} + +function buildExpandedWorkflow( + details: WorkflowDetails, + theme: Parameters[1], + now: number, + totals: string, +) { + const header = runHeader(details, theme, now); + const container = new Container(); + container.addChild( + new Text( + header.right ? `${header.left} ${header.right}` : header.left, + 0, + 0, + ), + ); + if (details.description) { + container.addChild( + new Text( + theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)), + 0, + 0, + ), + ); + } + + for (const group of phaseGroups(details)) { + container.addChild(new Spacer(1)); + container.addChild( + new Text( + theme.fg( + "muted", + `─── ${sanitizeWorkflowDisplayLine(group.title)} ───`, + ), + 0, + 0, + ), + ); + for (const agent of group.agents) { + const usage = formatUsage(agent.usage, agent.model); + const context = agentContext(agent); + let line = `${stateGlyph(agent.state, theme, now)} ${theme.fg( + "accent", + sanitizeWorkflowDisplayLine(agent.label), + )} ${theme.fg( + "dim", + [context, formatElapsed(agent.startedAt, agent.finishedAt)] + .filter(Boolean) + .join(" · "), + )}`; + if (usage) + line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`; + container.addChild(new Text(line, 0, 0)); + if (agent.error) { + container.addChild( + new Text( + ` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`, + 0, + 0, + ), + ); + } else if (agent.preview) { + const preview = sanitizeWorkflowDisplayText( + agent.preview, + PREVIEW_LENGTH, + ) + .split("\n") + .slice(0, 2) + .join(" "); + container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0)); + } + } + } + + if (details.logs && details.logs.length > 0) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0)); + if (details.logsDropped) { + container.addChild( + new Text( + theme.fg("dim", `(${details.logsDropped} earlier line(s) dropped)`), + 0, + 0, + ), + ); + } + for (const entry of details.logs) { + container.addChild( + new Text( + `${theme.fg("muted", "›")} ${theme.fg( + "dim", + sanitizeWorkflowDisplayLine(entry.text), + )}`, + 0, + 0, + ), + ); + } + } + + if (details.error) { + container.addChild(new Spacer(1)); + container.addChild( + new Text( + theme.fg( + "error", + `Error: ${sanitizeWorkflowDisplayLine(details.error)}`, + ), + 0, + 0, + ), + ); + } + + if (details.result !== undefined) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0)); + container.addChild( + new Markdown( + `\`\`\`json\n${resultJson(details.result)}\n\`\`\``, + 0, + 0, + getMarkdownTheme(), + ), + ); + } + + if (totals) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0)); + } + return container; +} + +interface WorkflowRenderState { + spinnerTimer?: ReturnType; +} + +function syncWorkflowSpinner( + state: WorkflowRenderState, + isRunning: () => boolean, + invalidate: () => void, +) { + if (!isRunning()) { + if (state.spinnerTimer) clearInterval(state.spinnerTimer); + state.spinnerTimer = undefined; + return; + } + if (state.spinnerTimer) return; + const spinnerTimer = setInterval(() => { + if (state.spinnerTimer !== spinnerTimer) return; + if (!isRunning()) { + clearInterval(spinnerTimer); + state.spinnerTimer = undefined; + } + invalidate(); + }, SPINNER_INTERVAL_MS); + state.spinnerTimer = spinnerTimer; + spinnerTimer.unref?.(); +} + /** * Test-only injection seam for execute-level tests: production never sets it. * The underscore-prefixed setter name makes any accidental production use @@ -1725,14 +2020,14 @@ export default function workflows(pi: ExtensionAPI) { const description = (meta as WorkflowMeta).description; if (description) text += `\n ${theme.fg("dim", description)}`; for (const phase of meta.phases.slice(0, 8)) { - text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${ + text += `\n ${theme.fg("dim", "○")} ${theme.fg("accent", phase.title)}${ phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : "" }`; } return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded, isPartial }, theme, context) { const details = result.details as WorkflowDetails | undefined; if (!details) { const first = result.content[0]; @@ -1742,189 +2037,40 @@ export default function workflows(pi: ExtensionAPI) { 0, ); } + const currentDetails = () => + activeRuns.get(details.runId)?.details ?? + settledRuns.get(details.runId) ?? + details; + syncWorkflowSpinner( + context.state as WorkflowRenderState, + () => + currentDetails().status === "running" && + (isPartial || activeRuns.has(details.runId)), + context.invalidate, + ); - const { done, failed } = countStates(details); - const settled = done + failed; - const elapsed = formatElapsed(details.startedAt, details.finishedAt); - let header = - `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + - `${theme.fg( - "accent", - sanitizeWorkflowDisplayLine(details.name ?? details.runId), - )} ` + - theme.fg( - "dim", - `${settled}/${details.agents.length} agents · ${elapsed} · `, - ) + - theme.fg(statusColor(details.status), statusWord(details.status)); - if (failed) header += theme.fg("error", ` · ${failed} failed`); - if (details.background) header += theme.fg("dim", " (background)"); - if (details.status === "running" && details.currentPhase) { - header += theme.fg( - "muted", - ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`, - ); - } - const totals = formatUsage(aggregateUsage(details.agents)); - - if (!expanded) { - let text = header; - for (const agent of details.agents) { - const context = agentContext(agent); - text += `\n ${stateSquare(agent.state, theme)} ${theme.fg( - "accent", - sanitizeWorkflowDisplayLine(agent.label), - )}${ - agent.phase - ? theme.fg( - "dim", - ` (${sanitizeWorkflowDisplayLine(agent.phase)})`, - ) - : "" - }${theme.fg( - "dim", - `${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`, - )}`; - } - // Only the tail collapsed: the newest lines are the ones that say - // where the run is now. - for (const entry of (details.logs ?? []).slice(-3)) { - text += `\n ${theme.fg("muted", "›")} ${theme.fg( - "dim", - sanitizeWorkflowDisplayLine(entry.text), - )}`; - } - if (totals) text += `\n ${theme.fg("dim", `Total: ${totals}`)}`; - if (details.error) - text += `\n ${theme.fg( - "error", - `Error: ${sanitizeWorkflowDisplayLine(details.error)}`, - )}`; - text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`; - return new Text(text, 0, 0); - } - - const container = new Container(); - container.addChild(new Text(header, 0, 0)); - if (details.description) { - container.addChild( - new Text( - theme.fg("dim", sanitizeWorkflowDisplayLine(details.description)), - 0, - 0, - ), - ); - } - - for (const group of phaseGroups(details)) { - container.addChild(new Spacer(1)); - container.addChild( - new Text( - theme.fg( - "muted", - `─── ${sanitizeWorkflowDisplayLine(group.title)} ───`, - ), - 0, - 0, - ), - ); - for (const agent of group.agents) { - const usage = formatUsage(agent.usage, agent.model); - const context = agentContext(agent); - let line = `${stateSquare(agent.state, theme)} ${theme.fg( - "accent", - sanitizeWorkflowDisplayLine(agent.label), - )} ${theme.fg( - "dim", - [context, formatElapsed(agent.startedAt, agent.finishedAt)] - .filter(Boolean) - .join(" · "), - )}`; - if (usage) - line += ` ${theme.fg("dim", sanitizeWorkflowDisplayLine(usage))}`; - container.addChild(new Text(line, 0, 0)); - if (agent.error) { - container.addChild( - new Text( - ` ${theme.fg("error", sanitizeWorkflowDisplayLine(agent.error))}`, - 0, - 0, - ), + return { + render(width: number) { + const current = currentDetails(); + const totals = formatUsage(aggregateUsage(current.agents)); + if (!expanded) { + return buildCollapsedRows( + current, + theme, + width, + Date.now(), + totals, ); - } else if (agent.preview) { - const preview = sanitizeWorkflowDisplayText( - agent.preview, - PREVIEW_LENGTH, - ) - .split("\n") - .slice(0, 2) - .join(" "); - container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0)); } - } - } - - if (details.logs && details.logs.length > 0) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "─── log ───"), 0, 0)); - if (details.logsDropped) { - container.addChild( - new Text( - theme.fg( - "dim", - `(${details.logsDropped} earlier line(s) dropped)`, - ), - 0, - 0, - ), - ); - } - for (const entry of details.logs) { - container.addChild( - new Text( - `${theme.fg("muted", "›")} ${theme.fg( - "dim", - sanitizeWorkflowDisplayLine(entry.text), - )}`, - 0, - 0, - ), - ); - } - } - - if (details.error) { - container.addChild(new Spacer(1)); - container.addChild( - new Text( - theme.fg( - "error", - `Error: ${sanitizeWorkflowDisplayLine(details.error)}`, - ), - 0, - 0, - ), - ); - } - - if (details.result !== undefined) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0)); - container.addChild( - new Markdown( - `\`\`\`json\n${resultJson(details.result)}\n\`\`\``, - 0, - 0, - getMarkdownTheme(), - ), - ); - } - - if (totals) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0)); - } - return container; + return buildExpandedWorkflow( + current, + theme, + Date.now(), + totals, + ).render(width); + }, + invalidate() {}, + }; }, }); @@ -2064,17 +2210,10 @@ export default function workflows(pi: ExtensionAPI) { .join("") ?? ""); const safeBody = sanitizeWorkflowDisplayText(body); if (!details) return new Text(safeBody, 0, 0); - const { done, failed } = countStates(details); - const settled = done + failed; - let header = - `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + - `${theme.fg( - "accent", - sanitizeWorkflowDisplayLine(details.name ?? details.runId), - )} ` + - theme.fg("dim", `${settled}/${details.agents.length} agents · `) + - theme.fg(statusColor(details.status), statusWord(details.status)); - if (failed) header += theme.fg("error", ` · ${failed} failed`); + const headerParts = runHeader(details, theme, Date.now()); + const header = headerParts.right + ? `${headerParts.left} ${headerParts.right}` + : headerParts.left; if (expanded) return new Text(`${header}\n\n${safeBody}`, 0, 0); const preview = safeBody.split("\n").slice(0, 8).join("\n"); return new Text( diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index ea43df66..f291c2ef 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -9,6 +9,7 @@ import { type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { formatContextUtilization } from "../shared/context-utilization.ts"; +import { spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import type { WorktreeCleanup } from "../shared/worktree.ts"; import type { AcceptanceLedger } from "./acceptance.ts"; @@ -334,19 +335,29 @@ export function createUsageReader(agents: readonly AgentRecord[]) { }; } -/** Colored square state indicator (no emojis/glyphs). */ -export const SQUARE = "■"; - -export function stateSquare(state: AgentState, theme: Theme): string { - if (state === "done") return theme.fg("success", SQUARE); - if (state === "error") return theme.fg("error", SQUARE); - return theme.fg("warning", SQUARE); +/** + * One status indicator per state, shared by the transcript card, the + * dashboard, and the strips. Running spins on the package-wide cadence so + * every live view animates in step. + */ +export function stateGlyph( + state: AgentState, + theme: Theme, + now: number, +): string { + if (state === "done") return theme.fg("success", "✓"); + if (state === "error") return theme.fg("error", "✗"); + return theme.fg("warning", spinnerFrame(now)); } -export function statusSquare(status: WorkflowStatus, theme: Theme): string { - if (status === "completed") return theme.fg("success", SQUARE); - if (status === "running") return theme.fg("warning", SQUARE); - return theme.fg("error", SQUARE); +export function statusGlyph( + status: WorkflowStatus, + theme: Theme, + now: number, +): string { + if (status === "completed") return theme.fg("success", "✓"); + if (status === "running") return theme.fg("warning", spinnerFrame(now)); + return theme.fg("error", "✗"); } export function statusWord(status: WorkflowStatus): string { diff --git a/extensions/workflows/navigation.test.ts b/extensions/workflows/navigation.test.ts index 6fea1ec6..dd579966 100644 --- a/extensions/workflows/navigation.test.ts +++ b/extensions/workflows/navigation.test.ts @@ -12,6 +12,7 @@ import { WorkflowStripWidget, workflowStripInput, } from "./navigation.ts"; +import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; import type { Theme, WorkflowDetails } from "./model.ts"; test("Down focuses an available workflow only from an empty editor", () => { @@ -201,3 +202,26 @@ test("the workflow strip stays one line, bounded, and exposes its navigation hin widget.dispose(); } }); + +test("workflow strip repaints on the shared spinner cadence", (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + let renders = 0; + const widget = new WorkflowStripWidget( + { + requestRender() { + renders += 1; + }, + } as unknown as TUI, + theme, + new WorkflowStripState(), + () => ({ runId: "wf_test", details: workflow() }), + ); + try { + t.mock.timers.tick(SPINNER_INTERVAL_MS - 1); + assert.equal(renders, 0); + t.mock.timers.tick(1); + assert.equal(renders, 1); + } finally { + widget.dispose(); + } +}); diff --git a/extensions/workflows/navigation.ts b/extensions/workflows/navigation.ts index 42922bbc..da39b549 100644 --- a/extensions/workflows/navigation.ts +++ b/extensions/workflows/navigation.ts @@ -6,7 +6,7 @@ import { fitNavigationSides, renderNavigationMetrics, } from "../shared/below-editor-navigation.ts"; -import { spinnerFrame } from "../shared/spinner.ts"; +import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { aggregateUsage, @@ -63,7 +63,10 @@ export class WorkflowStripWidget { this.theme = theme; this.strip = strip; this.getEntry = getEntry; - this.timer = setInterval(() => this.tui.requestRender(), 500); + this.timer = setInterval( + () => this.tui.requestRender(), + SPINNER_INTERVAL_MS, + ); this.timer.unref?.(); } @@ -94,7 +97,9 @@ export class WorkflowStripWidget { const right = renderNavigationMetrics( this.theme, [ - `${settled}/${details.agents.length} agents`, + details.agents.length > 0 + ? `${settled}/${details.agents.length} agents` + : undefined, formatElapsed(details.startedAt, details.finishedAt), tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined, ], diff --git a/extensions/workflows/rendering.test.ts b/extensions/workflows/rendering.test.ts new file mode 100644 index 00000000..427c1ab8 --- /dev/null +++ b/extensions/workflows/rendering.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + initTheme, + type AgentToolResult, + type ExtensionAPI, + type MessageRenderer, + type Theme, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; +import workflows from "./index.ts"; +import type { WorkflowDetails } from "./model.ts"; + +initTheme("dark", false); + +const theme = new Proxy( + {}, + { + get: (_target, property) => + property === "fg" || property === "bg" + ? (_color: string, text: string) => text + : (text: string) => text, + }, +) as Theme; + +function runningWorkflow(): WorkflowDetails { + return { + runId: "wf_render", + name: "render-check", + status: "running", + background: false, + startedAt: 0, + phases: [], + agents: [], + }; +} + +function captureRenderers() { + const tools = new Map(); + const messages = new Map(); + const pi = { + registerTool(tool: ToolDefinition) { + tools.set(tool.name, tool); + }, + registerMessageRenderer(type: string, renderer: MessageRenderer) { + messages.set(type, renderer); + }, + registerCommand() {}, + on() {}, + getThinkingLevel: () => "off", + getActiveTools: () => [], + setActiveTools() {}, + sendMessage() {}, + } as unknown as ExtensionAPI; + workflows(pi); + const workflow = tools.get("workflow"); + const message = messages.get("workflow-result"); + assert.ok(workflow?.renderResult); + assert.ok(message); + return { workflow, message }; +} + +test("running workflow cards request and render each shared spinner frame", (t) => { + t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 }); + const { workflow } = captureRenderers(); + const renderResult = workflow.renderResult!; + + for (const expanded of [false, true]) { + const details = runningWorkflow(); + const result: AgentToolResult = { + content: [{ type: "text", text: "running" }], + details, + }; + let invalidations = 0; + const context = { + args: {}, + toolCallId: `call-${expanded}`, + invalidate: () => { + invalidations += 1; + }, + lastComponent: undefined, + state: {}, + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial: true, + expanded, + showImages: false, + isError: false, + } as Parameters[3]; + const component = renderResult( + result, + { expanded, isPartial: true }, + theme, + context, + ); + const first = component.render(100)[0]; + + t.mock.timers.tick(SPINNER_INTERVAL_MS); + + const next = component.render(100)[0]; + assert.equal(invalidations, 1, `expanded=${expanded}`); + assert.notEqual(first, next, `expanded=${expanded}`); + + details.status = "completed"; + t.mock.timers.tick(SPINNER_INTERVAL_MS); + assert.equal(invalidations, 2, `terminal repaint expanded=${expanded}`); + t.mock.timers.tick(SPINNER_INTERVAL_MS); + assert.equal(invalidations, 2, `timer stopped expanded=${expanded}`); + assert.match(component.render(100)[0] ?? "", /✓/); + } +}); + +test("running workflow result messages let the glyph carry the state", () => { + const { message } = captureRenderers(); + const component = message( + { + role: "custom", + customType: "workflow-result", + content: "still working", + display: true, + details: runningWorkflow(), + timestamp: Date.now(), + }, + { expanded: false, outputPad: 0 }, + theme, + ); + assert.ok(component); + const rendered = component.render(100).join("\n"); + assert.match(rendered, /workflow render-check/); + assert.doesNotMatch(rendered, /\brunning\b/); +});