From c8b6213f2d213bff07226d38da8e71b56c1744e0 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 01:34:54 +0800 Subject: [PATCH 1/8] feat(ui): spinner and check glyphs for workflow status chrome The workflow surfaces still used the old colored-square status language that the subagent UI moved away from. Bring them onto the same glyphs: - transcript card, message renderer, and /workflows dashboard now use the package spinner for running, check for done, cross for failed; - the run header drops the redundant status word while running (the spinner says it) and keeps it only for terminal states; - collapsed agent rows drop the repeated phase suffix already named in the header; - planned phases in the call card read as open circles; - the footer activity line drops the squares and lets the colored words carry state, since a frozen spinner would read as a bug. --- extensions/shared/activity-status.test.ts | 4 +-- extensions/shared/activity-status.ts | 11 +++---- extensions/subagents/docs/design-plan.md | 4 +-- extensions/workflows/dashboard.ts | 33 ++++++++++----------- extensions/workflows/index.ts | 36 +++++++++++------------ extensions/workflows/model.ts | 33 ++++++++++++++------- 6 files changed, 65 insertions(+), 56 deletions(-) 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.ts b/extensions/workflows/dashboard.ts index 0e2032ce..d7e47da6 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 */ @@ -32,6 +32,7 @@ import { screenTitleLine, hintLine as sharedHintLine, } from "../shared/screen-chrome.ts"; +import { 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 +54,11 @@ import { phaseGroups, resolveWorkflowRunTarget, resultJson, - SQUARE, sanitizeLine, shortenHome, - stateSquare, + stateGlyph, statusColor, + statusGlyph, statusWord, type Theme, type TranscriptEntry, @@ -1002,7 +1003,7 @@ export class WorkflowDashboard { ) + theme.fg(statusColor(d.status), statusWord(d.status)) + " "; - const left = ` ${marker} ${statusSquareFor(d, theme)} ${label} ${theme.fg("dim", d.runId)}`; + const left = ` ${marker} ${statusGlyph(d.status, theme, Date.now())} ${label} ${theme.fg("dim", d.runId)}`; return this.split(left, right, width - 2); }); lines.push(...this.panel("Runs", rows, width, panelHeight)); @@ -1089,7 +1090,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) @@ -1135,7 +1136,7 @@ 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} ${theme.fg("dim", stats)}`; const right = theme.fg( "dim", `${formatElapsed(agent.startedAt, agent.finishedAt)} `, @@ -1240,7 +1241,7 @@ export class WorkflowDashboard { const label = transcriptLabel(entry); const color = transcriptColor(entry); rows.push( - ` ${theme.fg(color, SQUARE)} ${theme.bold(theme.fg(color, label))}`, + ` ${theme.fg(color, "●")} ${theme.bold(theme.fg(color, label))}`, ); const contentWidth = Math.max(8, width - 4); const styled = theme.fg( @@ -1276,7 +1277,7 @@ export class WorkflowDashboard { ); lines.push( this.split( - ` ${stateSquare(agent.state, theme)} ${theme.bold(theme.fg("accent", agent.label))}`, + ` ${stateGlyph(agent.state, theme, Date.now())} ${theme.bold(theme.fg("accent", agent.label))}`, right, width, ), @@ -1340,17 +1341,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): string { + 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. */ diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 1688c7c5..8980b040 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -118,12 +118,12 @@ import { refreshWorkflowGraph, resolveWorkflowRunTarget, resultJson, - SQUARE, sanitizeLine, sanitizeWorkflowDisplayLine, sanitizeWorkflowDisplayText, - stateSquare, + stateGlyph, statusColor, + statusGlyph, statusWord, type WorkflowDetails, } from "./model.ts"; @@ -1725,7 +1725,7 @@ 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}`) : "" }`; } @@ -1746,17 +1746,24 @@ export default function workflows(pi: ExtensionAPI) { const { done, failed } = countStates(details); const settled = done + failed; const elapsed = formatElapsed(details.startedAt, details.finishedAt); + const now = Date.now(); + // The glyph already carries the run state, so the status word only + // stays for terminal states; a running run names its phase instead. let header = - `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + + `${statusGlyph(details.status, theme, now)} ${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)); + `${settled}/${details.agents.length} agents · ${elapsed}`, + ); + if (details.status !== "running") { + header += + theme.fg("dim", " · ") + + 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) { @@ -1771,17 +1778,10 @@ export default function workflows(pi: ExtensionAPI) { let text = header; for (const agent of details.agents) { const context = agentContext(agent); - text += `\n ${stateSquare(agent.state, theme)} ${theme.fg( + text += `\n ${stateGlyph(agent.state, theme, now)} ${theme.fg( "accent", sanitizeWorkflowDisplayLine(agent.label), - )}${ - agent.phase - ? theme.fg( - "dim", - ` (${sanitizeWorkflowDisplayLine(agent.phase)})`, - ) - : "" - }${theme.fg( + )}${theme.fg( "dim", `${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`, )}`; @@ -1831,7 +1831,7 @@ export default function workflows(pi: ExtensionAPI) { 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( + let line = `${stateGlyph(agent.state, theme, now)} ${theme.fg( "accent", sanitizeWorkflowDisplayLine(agent.label), )} ${theme.fg( @@ -2067,7 +2067,7 @@ export default function workflows(pi: ExtensionAPI) { const { done, failed } = countStates(details); const settled = done + failed; let header = - `${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + + `${statusGlyph(details.status, theme, Date.now())} ${theme.fg("toolTitle", theme.bold("workflow "))}` + `${theme.fg( "accent", sanitizeWorkflowDisplayLine(details.name ?? details.runId), 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 { From 760917c3a82c44f914662f2116233d976a263555 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 01:48:08 +0800 Subject: [PATCH 2/8] feat(ui): calm the workflow card at launch and under swarms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a fresh run no longer shows "0/0 agents · 0s"; metrics join in once agents exist or time has actually passed; - the header drops the duplicate (background) already on the call card; - the collapsed card caps agent rows at eight and summarizes the rest as "… N more" instead of flooding the chat for large swarms; - the expand hint only appears when expanding would reveal more. --- extensions/workflows/index.ts | 37 +++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 8980b040..dd840552 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -1747,25 +1747,30 @@ export default function workflows(pi: ExtensionAPI) { const settled = done + failed; const elapsed = formatElapsed(details.startedAt, details.finishedAt); const now = Date.now(); + // A just-launched run has no agents and a 0s clock; both are noise on a + // card that only updates on events, so the metrics join in once real. + const counts = + details.agents.length > 0 + ? `${settled}/${details.agents.length} agents` + : undefined; + const metrics = [counts, counts || elapsed !== "0s" ? elapsed : undefined] + .filter(Boolean) + .join(" · "); // The glyph already carries the run state, so the status word only // stays for terminal states; a running run names its phase instead. let header = `${statusGlyph(details.status, theme, now)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + - `${theme.fg( + theme.fg( "accent", sanitizeWorkflowDisplayLine(details.name ?? details.runId), - )} ` + - theme.fg( - "dim", - `${settled}/${details.agents.length} agents · ${elapsed}`, ); + if (metrics) header += theme.fg("dim", ` ${metrics}`); if (details.status !== "running") { header += theme.fg("dim", " · ") + 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", @@ -1776,7 +1781,10 @@ export default function workflows(pi: ExtensionAPI) { if (!expanded) { let text = header; - for (const agent of details.agents) { + // A swarm can run dozens of agents; the collapsed card shows the + // first few and summarizes the rest instead of flooding the chat. + const collapsedAgents = details.agents.slice(0, 8); + for (const agent of collapsedAgents) { const context = agentContext(agent); text += `\n ${stateGlyph(agent.state, theme, now)} ${theme.fg( "accent", @@ -1786,6 +1794,10 @@ export default function workflows(pi: ExtensionAPI) { `${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`, )}`; } + const hiddenAgents = details.agents.length - collapsedAgents.length; + if (hiddenAgents > 0) { + text += `\n ${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)) { @@ -1800,7 +1812,16 @@ export default function workflows(pi: ExtensionAPI) { "error", `Error: ${sanitizeWorkflowDisplayLine(details.error)}`, )}`; - text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`; + // 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 + ) { + text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`; + } return new Text(text, 0, 0); } From 9a22dca31ed1ae0dc43372cefeeab1a696d98851 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 01:54:40 +0800 Subject: [PATCH 3/8] feat(ui): one number per workflow agent row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row that reads "name · 4%/1.0M · 45s" carries two stats that mostly duplicate across rows. Collapsed workflow cards now show a single meaningful stat per agent: context occupancy while it runs (proof of life), elapsed once it settles (what it cost). The below-editor strip also hides its "0/0 agents" count before any agent registers. --- extensions/workflows/index.ts | 19 ++++++++++++++----- extensions/workflows/navigation.ts | 4 +++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index dd840552..70c9d988 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -44,6 +44,7 @@ import { Container, Markdown, Spacer, Text } 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, @@ -1783,16 +1784,24 @@ export default function workflows(pi: ExtensionAPI) { let text = header; // A swarm can run dozens of agents; the collapsed card shows the // first few and summarizes the rest instead of flooding the chat. + // Each row carries one number only: context occupancy says a running + // agent is alive, elapsed says what a settled one cost. const collapsedAgents = details.agents.slice(0, 8); for (const agent of collapsedAgents) { - const context = agentContext(agent); + const percent = contextPercent({ + tokens: agent.usage.contextTokens, + contextWindow: agent.contextWindow, + }); + const stat = + agent.state === "running" + ? percent === undefined + ? undefined + : `${percent}%` + : formatElapsed(agent.startedAt, agent.finishedAt); text += `\n ${stateGlyph(agent.state, theme, now)} ${theme.fg( "accent", sanitizeWorkflowDisplayLine(agent.label), - )}${theme.fg( - "dim", - `${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`, - )}`; + )}${theme.fg("dim", stat ? ` · ${stat}` : "")}`; } const hiddenAgents = details.agents.length - collapsedAgents.length; if (hiddenAgents > 0) { diff --git a/extensions/workflows/navigation.ts b/extensions/workflows/navigation.ts index 42922bbc..292f22f8 100644 --- a/extensions/workflows/navigation.ts +++ b/extensions/workflows/navigation.ts @@ -94,7 +94,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, ], From c60db5a6f841fc62af9a6ed83b183354043ca2e6 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 02:00:15 +0800 Subject: [PATCH 4/8] feat(ui): declutter the workflow detail dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the header drops the "running" word and shows the status glyph up front, matching the transcript card; terminal states keep the word; - a flat swarm no longer reports "N nodes · 0 edges" — the graph summary only appears when edges actually exist; - agent rows stop repeating the model when the phase is homogeneous, and show context occupancy only for running agents (a settled row's cost is already the elapsed on the right); - agent errors read as "429: user rate limit exceeded" instead of a raw JSON body dump. --- extensions/workflows/dashboard.ts | 53 +++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index d7e47da6..08c2a0ed 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -26,6 +26,7 @@ import { visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; +import { contextPercent } from "../shared/context-utilization.ts"; import { panelFrame, type ScreenHint, @@ -1034,22 +1035,31 @@ 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)), + ` ${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( @@ -1111,6 +1121,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, @@ -1123,9 +1137,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, @@ -1136,7 +1159,7 @@ 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} ${stateGlyph(agent.state, theme, Date.now())} ${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)} `, @@ -1145,7 +1168,7 @@ export class WorkflowDashboard { if (agent.error) { agentRows.push( truncateToWidth( - ` ${theme.fg("error", sanitizeLine(agent.error, 2_000))}`, + ` ${theme.fg("error", displayError(agent.error))}`, agentsInner, "…", ), @@ -1321,6 +1344,18 @@ 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): 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"; From 514ac4b70b59783cb9c0fc8353e73f1718e5c483 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 02:06:41 +0800 Subject: [PATCH 5/8] feat(ui): compact one-line tool calls in the transcript view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tool calls render as "→ name {args}" on one line with the JSON arguments collapsed inline, instead of a shouty "TOOL name" header over a multi-line pretty-printed block; - results answer with "← name" and keep their body dimmed below; - role labels drop the all-caps (user / assistant / thinking). --- extensions/workflows/dashboard.ts | 43 ++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 08c2a0ed..0c3d197d 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -1261,14 +1261,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, "●")} ${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)) { @@ -1357,12 +1373,25 @@ function displayError(error: string): string { } 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): string { + const flat = text.trim(); + if (!flat) return ""; + try { + return JSON.stringify(JSON.parse(flat)); + } catch { + return flat.replace(/\s+/g, " "); + } } function transcriptColor( From 3328dda0ebd8d32ce55b76e56f69fd1988c3651d Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 09:44:37 +0800 Subject: [PATCH 6/8] fix(ui): animate the workflow card spinner and right-align its metrics The collapsed card was a static Text baked at renderResult time, so its spinner frame only advanced when a workflow event happened to arrive, while the below-editor strip kept ticking on its own timer. The card now rebuilds its rows on every repaint, riding the host's render cadence, so the spinner and clocks stay live. While rebuilding, the card switches to a left/right layout that matches the strip and dashboard: identity and current phase on the left, agent counts and elapsed on the right, and each agent row's single stat right-aligned in its own column. --- extensions/workflows/index.ts | 245 ++++++++++++++++++++++------------ 1 file changed, 159 insertions(+), 86 deletions(-) diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 70c9d988..87d9a63c 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -40,7 +40,14 @@ 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, + visibleWidth, +} 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"; @@ -175,6 +182,144 @@ 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 }; +} + +/** Compose `left ... right` within `width`, truncating left when needed. */ +function splitRow(left: string, right: string, width: number): string { + if (!right) return truncateToWidth(left, width, "…"); + 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; +} + +/** + * 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, +): string[] { + const header = runHeader(details, theme, now); + const rows = [splitRow(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 + ? splitRow(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; +} + /** * Test-only injection seam for execute-level tests: production never sets it. * The underscore-prefixed setter name makes any accidental production use @@ -1744,96 +1889,24 @@ export default function workflows(pi: ExtensionAPI) { ); } - const { done, failed } = countStates(details); - const settled = done + failed; - const elapsed = formatElapsed(details.startedAt, details.finishedAt); - const now = Date.now(); - // A just-launched run has no agents and a 0s clock; both are noise on a - // card that only updates on events, so the metrics join in once real. - const counts = - details.agents.length > 0 - ? `${settled}/${details.agents.length} agents` - : undefined; - const metrics = [counts, counts || elapsed !== "0s" ? elapsed : undefined] - .filter(Boolean) - .join(" · "); - // The glyph already carries the run state, so the status word only - // stays for terminal states; a running run names its phase instead. - let header = - `${statusGlyph(details.status, theme, now)} ${theme.fg("toolTitle", theme.bold("workflow "))}` + - theme.fg( - "accent", - sanitizeWorkflowDisplayLine(details.name ?? details.runId), - ); - if (metrics) header += theme.fg("dim", ` ${metrics}`); - if (details.status !== "running") { - header += - theme.fg("dim", " · ") + - theme.fg(statusColor(details.status), statusWord(details.status)); - } - if (failed) header += theme.fg("error", ` · ${failed} failed`); - if (details.status === "running" && details.currentPhase) { - header += theme.fg( - "muted", - ` · ${sanitizeWorkflowDisplayLine(details.currentPhase)}`, - ); - } const totals = formatUsage(aggregateUsage(details.agents)); if (!expanded) { - let text = header; - // A swarm can run dozens of agents; the collapsed card shows the - // first few and summarizes the rest instead of flooding the chat. - // Each row carries one number only: context occupancy says a running - // agent is alive, elapsed says what a settled one cost. - 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); - text += `\n ${stateGlyph(agent.state, theme, now)} ${theme.fg( - "accent", - sanitizeWorkflowDisplayLine(agent.label), - )}${theme.fg("dim", stat ? ` · ${stat}` : "")}`; - } - const hiddenAgents = details.agents.length - collapsedAgents.length; - if (hiddenAgents > 0) { - text += `\n ${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)) { - 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)}`, - )}`; - // 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 - ) { - text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`; - } - return new Text(text, 0, 0); + // Rebuilt on every repaint: the spinner frame and the clocks advance + // with the host's render cadence instead of freezing between events. + return { + render: (width: number) => + buildCollapsedRows(details, theme, width, Date.now(), totals), + invalidate() {}, + }; } + const headerParts = runHeader(details, theme, Date.now()); + const now = Date.now(); + const header = headerParts.right + ? `${headerParts.left} ${headerParts.right}` + : headerParts.left; + const container = new Container(); container.addChild(new Text(header, 0, 0)); if (details.description) { From 9aaac1dc6333935b84dee879f3a46a4d4649d2e3 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 10:12:15 +0800 Subject: [PATCH 7/8] fix(ui): harden and animate workflow rendering --- extensions/workflows/dashboard.test.ts | 60 +++- extensions/workflows/dashboard.ts | 47 ++- extensions/workflows/index.ts | 364 +++++++++++++----------- extensions/workflows/navigation.test.ts | 24 ++ extensions/workflows/navigation.ts | 7 +- extensions/workflows/rendering.test.ts | 133 +++++++++ 6 files changed, 439 insertions(+), 196 deletions(-) create mode 100644 extensions/workflows/rendering.test.ts 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 0c3d197d..47a3652a 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -23,17 +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 { spinnerFrame } from "../shared/spinner.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"; @@ -672,7 +672,7 @@ export class WorkflowDashboard { this.refresh(); this.tui.requestRender(); } - }, 500); + }, SPINNER_INTERVAL_MS); } dispose() { @@ -908,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, @@ -1005,7 +994,7 @@ export class WorkflowDashboard { theme.fg(statusColor(d.status), statusWord(d.status)) + " "; const left = ` ${marker} ${statusGlyph(d.status, theme, Date.now())} ${label} ${theme.fg("dim", d.runId)}`; - return this.split(left, right, width - 2); + return fitNavigationSides(left, right, width - 2); }); lines.push(...this.panel("Runs", rows, width, panelHeight)); lines.push( @@ -1048,7 +1037,7 @@ export class WorkflowDashboard { theme.fg(statusColor(d.status), statusWord(d.status)) + " "); lines.push( - this.split( + fitNavigationSides( ` ${statusGlyph(d.status, theme, Date.now())} ${theme.bold(theme.fg("accent", d.name ?? d.runId))}`, right, width, @@ -1063,7 +1052,7 @@ export class WorkflowDashboard { 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, @@ -1109,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. @@ -1164,7 +1157,7 @@ export class WorkflowDashboard { "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( @@ -1315,14 +1308,14 @@ export class WorkflowDashboard { .join(" · ") + " ", ); lines.push( - this.split( + 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, @@ -1365,7 +1358,7 @@ export class WorkflowDashboard { * (`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): string { +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]}`; @@ -1384,8 +1377,8 @@ function transcriptLabel(entry: TranscriptEntry): string { * Tool arguments arrive pretty-printed over many lines; the transcript shows * them inline. Non-JSON text passes through flattened. */ -function compactInlineJson(text: string): string { - const flat = text.trim(); +function compactInlineJson(text: string) { + const flat = sanitizeTerminalText(text).trim(); if (!flat) return ""; try { return JSON.stringify(JSON.parse(flat)); @@ -1405,7 +1398,7 @@ function transcriptColor( return "muted"; } -function groupGlyph(group: PhaseGroup, theme: Theme): string { +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", spinnerFrame(Date.now())); @@ -1421,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 87d9a63c..a0287704 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -46,7 +46,6 @@ import { Spacer, Text, truncateToWidth, - visibleWidth, } from "@earendil-works/pi-tui"; import { type Static, Type } from "typebox"; import { formatActivityStatus } from "../shared/activity-status.ts"; @@ -56,7 +55,9 @@ 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, @@ -224,18 +225,6 @@ function runHeader( return { left, right }; } -/** Compose `left ... right` within `width`, truncating left when needed. */ -function splitRow(left: string, right: string, width: number): string { - if (!right) return truncateToWidth(left, width, "…"); - 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; -} - /** * Collapsed workflow card, rebuilt per repaint. Metrics right-align like the * below-editor strip, and each agent row carries one number only: context @@ -249,9 +238,9 @@ function buildCollapsedRows( width: number, now: number, totals: string, -): string[] { +) { const header = runHeader(details, theme, now); - const rows = [splitRow(header.left, header.right, width)]; + const rows = [fitNavigationSides(header.left, header.right, width)]; const collapsedAgents = details.agents.slice(0, 8); for (const agent of collapsedAgents) { const percent = contextPercent({ @@ -270,7 +259,7 @@ function buildCollapsedRows( )}`; rows.push( stat - ? splitRow(left, theme.fg("dim", stat), width) + ? fitNavigationSides(left, theme.fg("dim", stat), width) : truncateToWidth(left, width, "…"), ); } @@ -320,6 +309,164 @@ function buildCollapsedRows( 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; + state.spinnerTimer = setInterval(() => { + if (!isRunning() && state.spinnerTimer) { + clearInterval(state.spinnerTimer); + state.spinnerTimer = undefined; + } + invalidate(); + }, SPINNER_INTERVAL_MS); + state.spinnerTimer.unref?.(); +} + /** * Test-only injection seam for execute-level tests: production never sets it. * The underscore-prefixed setter name makes any accidental production use @@ -1878,7 +2025,7 @@ export default function workflows(pi: ExtensionAPI) { 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]; @@ -1888,146 +2035,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 totals = formatUsage(aggregateUsage(details.agents)); - - if (!expanded) { - // Rebuilt on every repaint: the spinner frame and the clocks advance - // with the host's render cadence instead of freezing between events. - return { - render: (width: number) => - buildCollapsedRows(details, theme, width, Date.now(), totals), - invalidate() {}, - }; - } - - const headerParts = runHeader(details, theme, Date.now()); - const now = Date.now(); - const header = headerParts.right - ? `${headerParts.left} ${headerParts.right}` - : headerParts.left; - - 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 = `${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, - ), + 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() {}, + }; }, }); @@ -2167,17 +2208,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 = - `${statusGlyph(details.status, theme, Date.now())} ${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/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 292f22f8..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?.(); } 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/); +}); From 306398f9145abb2ce40cbfa20ed5607d74f2f332 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 23 Aug 2026 10:15:13 +0800 Subject: [PATCH 8/8] test(ui): tolerate queued spinner callbacks --- extensions/workflows/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index a0287704..805201af 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -457,14 +457,16 @@ function syncWorkflowSpinner( return; } if (state.spinnerTimer) return; - state.spinnerTimer = setInterval(() => { - if (!isRunning() && state.spinnerTimer) { - clearInterval(state.spinnerTimer); + const spinnerTimer = setInterval(() => { + if (state.spinnerTimer !== spinnerTimer) return; + if (!isRunning()) { + clearInterval(spinnerTimer); state.spinnerTimer = undefined; } invalidate(); }, SPINNER_INTERVAL_MS); - state.spinnerTimer.unref?.(); + state.spinnerTimer = spinnerTimer; + spinnerTimer.unref?.(); } /**