From dd51e1143bdc1eec38930578434fce5467ce8a85 Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Fri, 9 Oct 2026 19:01:11 +0800 Subject: [PATCH] fix(subagent): stop background lanes from flickering and flooding the transcript Each background lane had its own widget, republished on every streamed delta with the child's live text. The host re-inserts a widget on every set, so lanes swapped places, and wrapping text changed the block height. Progress notifications were displayed in the transcript every 15s per lane as raw pseudo-XML. - Render all background lanes in one shared list widget, reusing the blocking call's SubagentListWidget: fixed spawn order, task titles instead of live text, republished only on status/token changes, and cleared once the whole batch settles. A 1s tick keeps elapsed moving while the parent is idle. - Keep background_progress notifications for the model only (display: false). - Render the remaining agent-notification events as a status line with a collapsible preview; notifications without structured details keep the host's default rendering. - Drop the token readout from settled rows in the list widget. --- .../src/features/step-subagent.ts | 66 ++++- .../src/features/subagent/lane-events.ts | 27 +- .../src/features/subagent/rendering.ts | 77 ++++-- .../test/step-capabilities-extension.test.ts | 1 + .../test/step-subagent-events.test.ts | 1 + .../test/subagent-background-tui.test.ts | 241 ++++++++++++++++++ .../test/subagent-child-env.test.ts | 1 + .../test/subagent-list-widget.test.ts | 19 +- 8 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 packages/coding-agent/test/subagent-background-tui.test.ts diff --git a/packages/coding-agent/src/features/step-subagent.ts b/packages/coding-agent/src/features/step-subagent.ts index a634f948..f647e2a7 100644 --- a/packages/coding-agent/src/features/step-subagent.ts +++ b/packages/coding-agent/src/features/step-subagent.ts @@ -28,7 +28,7 @@ import { laneMatches, } from "./subagent/lane-lifecycle.ts"; import { - laneWidgetLines, + renderAgentNotification, renderSubagentResult, SubagentListWidget, subagentListSignature, @@ -51,7 +51,7 @@ export { routeSubagentRpcLine } from "./subagent/rpc-adapter.ts"; // moved to ./subagent/execute.ts and is imported above; it was never public. // TUI render helpers (statusIcon/renderRecordSummary/renderExpandedRecord/ -// renderSubagentResult/laneWidgetLines) moved to ./subagent/rendering.ts and +// renderSubagentResult/renderAgentNotification) moved to ./subagent/rendering.ts and // are imported above; none were public. // Pure utilities (usage math, sanitizeLabel/normalizeChildTools, the git @@ -518,13 +518,65 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption if (process.env[CHILD_MARKER] === "1") return; const lanes = new Map(); - const laneWidgetKey = (id: string): string => `step-agent:${id}`; + // Every background lane shares one list widget, built the same way as the + // blocking call's list: a row per task with a fixed title and metrics. A + // widget per lane, republished on every streamed delta with the child's + // live text, reordered the lanes on each publish (the host re-inserts the + // key) and changed height as lines wrapped, so the block flickered. + // + // Lanes stay listed until the whole batch settles, so the header reads + // "2/4 complete" rather than shrinking as lanes finish; then it clears and + // the transcript's agent-notification entries take over. + // + // Elapsed is read from the clock at render time. A blocking call gets its + // redraws from the working indicator, but background lanes usually run + // while the parent sits idle, so a 1s tick keeps the column moving; the + // differential renderer only rewrites the cells that changed. + const lanesWidgetKey = "step-agent-lanes"; + const shownLanes = new Set(); + let lanesWidget: SubagentListWidget | undefined; + let lanesPublished: string | undefined; + let lanesTicker: ReturnType | undefined; + const stopLanesTicker = (): void => { + if (lanesTicker) clearInterval(lanesTicker); + lanesTicker = undefined; + }; const updateLaneWidget = (lane: BackgroundAgentLane): void => { if (!lane.ctx.hasUI) return; + shownLanes.add(lane.id); + const batch = [...shownLanes] + .map((id) => lanes.get(id)) + .filter((entry): entry is BackgroundAgentLane => entry !== undefined); try { - lane.ctx.ui.setWidget(laneWidgetKey(lane.id), laneWidgetLines(lane), { - placement: "aboveEditor", - }); + if (batch.every((entry) => entry.status !== "running")) { + shownLanes.clear(); + stopLanesTicker(); + lanesWidget = undefined; + lanesPublished = undefined; + lane.ctx.ui.setWidget(lanesWidgetKey, undefined); + return; + } + const details: StepSubagentDetails = { + ...lane.details, + mode: "parallel", + results: batch.flatMap((entry) => entry.details.results), + }; + lanesWidget?.setDetails(details); + const signature = subagentListSignature(details); + if (signature === lanesPublished) return; + lanesPublished = signature; + lane.ctx.ui.setWidget( + lanesWidgetKey, + (tui, theme) => { + lanesWidget ??= new SubagentListWidget(details, theme, "background agents"); + if (!lanesTicker) { + lanesTicker = setInterval(() => tui.requestRender(), 1_000); + lanesTicker.unref?.(); + } + return lanesWidget; + }, + { placement: "aboveEditor" }, + ); } catch { // A host may tear down its UI while a detached child is finishing. } @@ -538,6 +590,8 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption updateLaneWidget, }); + pi.registerMessageRenderer("agent-notification", renderAgentNotification); + pi.registerTool({ name: "subagent", label: "Subagent", diff --git a/packages/coding-agent/src/features/subagent/lane-events.ts b/packages/coding-agent/src/features/subagent/lane-events.ts index 3367b621..825439b3 100644 --- a/packages/coding-agent/src/features/subagent/lane-events.ts +++ b/packages/coding-agent/src/features/subagent/lane-events.ts @@ -18,6 +18,19 @@ export type BackgroundLaneEvent = | "background_progress" | "background_restarted"; +/** `details` on an `` message; read by its transcript renderer. */ +export interface AgentNotificationDetails { + agentId: string; + event: BackgroundLaneEvent; + status: BackgroundAgentLane["status"]; + /** Alias when the lane has one, otherwise its id. */ + label?: string; + /** Agent names running in the lane, e.g. ["general"]. */ + agents?: string[]; + /** Unescaped body below the headline (output, failure reasons, progress). */ + detail?: string; +} + /** Minimum interval between background_progress notifications per lane. */ const PROGRESS_NOTIFY_INTERVAL_MS = 15_000; @@ -55,6 +68,14 @@ export function notifyLaneEvent( ? `Background agent ${label} restarted its child process.` : `Background agent ${label} ${lane.status}.`; const body = detail?.trim() ? `${headline}\n${detail.trim()}` : headline; + const details: AgentNotificationDetails = { + agentId: lane.id, + event, + status: lane.status, + label, + agents: [...new Set(lane.details.results.map((record) => record.agent))], + detail: detail?.trim() || undefined, + }; pi.sendMessage( { customType: "agent-notification", @@ -66,8 +87,10 @@ export function notifyLaneEvent( content: `` + `${escapeXmlAttr(body)}`, - display: true, - details: { agentId: lane.id, event, status: lane.status }, + // Progress is for the parent model; the user already watches the lanes + // widget, and a transcript entry every 15s per lane buries the session. + display: event !== "background_progress", + details, }, { deliverAs: "steer" }, ); diff --git a/packages/coding-agent/src/features/subagent/rendering.ts b/packages/coding-agent/src/features/subagent/rendering.ts index 1f3d492b..1c9deef8 100644 --- a/packages/coding-agent/src/features/subagent/rendering.ts +++ b/packages/coding-agent/src/features/subagent/rendering.ts @@ -7,7 +7,8 @@ import type { AgentToolResult } from "@step-harness/agent-core"; import type { Component } from "@step-harness/pi-tui"; import { Container, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from "@step-harness/pi-tui"; -import type { ToolRenderResultOptions } from "../../core/extensions/types.ts"; +import type { MessageRenderOptions, ToolRenderResultOptions } from "../../core/extensions/types.ts"; +import type { CustomMessage } from "../../core/messages.ts"; import type { Theme } from "../../theme/theme.ts"; import { getMarkdownTheme } from "../../theme/theme.ts"; import { @@ -17,8 +18,7 @@ import { type StepSubagentResultRecord, type StepSubagentUsage, } from "../step-subagent.ts"; -import { truncateText } from "./lane-events.ts"; -import type { BackgroundAgentLane } from "./lane-lifecycle.ts"; +import { type AgentNotificationDetails, truncateText } from "./lane-events.ts"; const COLLAPSED_OUTPUT_LINES = 8; /** Rows the live widget will show before collapsing the rest into a counter. */ @@ -181,10 +181,12 @@ export function subagentListSignature(details: StepSubagentDetails): string { export class SubagentListWidget implements Component { private details: StepSubagentDetails; private readonly theme: Theme; + private readonly title: string; - constructor(details: StepSubagentDetails, theme: Theme) { + constructor(details: StepSubagentDetails, theme: Theme, title = "subagent") { this.details = details; this.theme = theme; + this.title = title; } setDetails(details: StepSubagentDetails): void { @@ -205,13 +207,16 @@ export class SubagentListWidget implements Component { const summary = [`${completed}/${records.length} complete`]; if (running > 0) summary.push(`${running} running`); if (failed > 0) summary.push(`${failed} failed`); - const header = ` ${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", summary.join(", "))}`; + const header = ` ${theme.fg("toolTitle", theme.bold(this.title))} ${theme.fg("accent", summary.join(", "))}`; const lines = [visibleWidth(header) > width ? truncateToWidth(header, width, "\u2026") : header]; // Right column is sized across all shown rows so the metrics line up. const shown = records.slice(0, WIDGET_MAX_ROWS); const metrics = shown.map((record) => { - const tokens = record.usage.output; + // Tokens are a liveness readout for a lane still producing output; once + // it settles the row keeps only its final elapsed, so finished rows read + // as done at a glance instead of as one more counter. + const tokens = record.status === "running" ? record.usage.output : 0; // Same shape as the working indicator's "· ↓ 1.2k tokens", so the two // token readouts on screen read as one unit. return `${formatElapsed(record)}${tokens > 0 ? ` \u00b7 \u2193 ${formatTokenCount(tokens)} tokens` : ""}`; @@ -275,24 +280,44 @@ export function renderSubagentResult( return new Text(text, 0, 0); } -export function laneWidgetLines(lane: BackgroundAgentLane): string[] { - const records = lane.details.results; - const lines = [ - `agent ${lane.id} ${lane.status}`, - ...records.map((record) => { - const live = record.activeTool - ? ` | ${record.activeTool}` - : record.activeText - ? ` | ${record.activeText.split(/\r?\n/u).at(-1)?.slice(0, 100) ?? ""}` - : ""; - return `${statusIcon(record.status, themeForWidget)} ${record.agent}${live}`; - }), - ]; - return lines; -} +const NOTIFICATION_PREVIEW_LINES = 3; + +const NOTIFICATION_STYLE: Record< + AgentNotificationDetails["event"], + { icon: string; color: "success" | "error" | "warning" | "dim"; verb: string } +> = { + background_done: { icon: "\u2713", color: "success", verb: "finished" }, + background_failed: { icon: "x", color: "error", verb: "failed" }, + background_interrupted: { icon: "x", color: "warning", verb: "interrupted" }, + background_needs_input: { icon: "?", color: "warning", verb: "needs input" }, + background_progress: { icon: "~", color: "dim", verb: "progress" }, + background_restarted: { icon: "~", color: "warning", verb: "restarted its child process" }, +}; -// Widgets receive the same color callback shape as the native renderer. Keep -// this tiny fallback local so background lanes can also be shown in test hosts. -const themeForWidget = { - fg: (_color: string, text: string): string => text, -} as unknown as Theme; +/** + * Transcript entry for an `` message: one status line plus + * a short preview of the detail, instead of the raw pseudo-XML the parent model + * reads. Returns undefined for messages without structured details (sessions + * recorded before they were added), which keeps the host's default rendering. + */ +export function renderAgentNotification( + message: CustomMessage, + options: MessageRenderOptions, + theme: Theme, +): Component | undefined { + const details = message.details; + const style = details?.event ? NOTIFICATION_STYLE[details.event] : undefined; + if (!details || !style) return undefined; + const label = details.label ?? details.agentId; + const agents = details.agents?.length ? ` ${theme.fg("muted", `(${details.agents.join(", ")})`)}` : ""; + let text = `${theme.fg(style.color, style.icon)} ${theme.fg("toolTitle", theme.bold("background agent"))} ${theme.fg("accent", label)}${agents} ${theme.fg(style.color, style.verb)}`; + const lines = (details.detail ?? "").split(/\r?\n/u).filter((line) => line.trim().length > 0); + const shown = options.expanded ? lines : lines.slice(0, NOTIFICATION_PREVIEW_LINES); + for (const line of shown) { + text += `\n ${theme.fg("dim", options.expanded ? line : truncateText(line, 200).split("\n")[0])}`; + } + if (shown.length < lines.length) { + text += `\n ${theme.fg("dim", `... ${lines.length - shown.length} more lines (Ctrl+O to expand)`)}`; + } + return new Text(text, options.outputPad, 0); +} diff --git a/packages/coding-agent/test/step-capabilities-extension.test.ts b/packages/coding-agent/test/step-capabilities-extension.test.ts index bfe96795..a25d3e00 100644 --- a/packages/coding-agent/test/step-capabilities-extension.test.ts +++ b/packages/coding-agent/test/step-capabilities-extension.test.ts @@ -76,6 +76,7 @@ function createApi(): { registerCommand(name: string, command: { handler: (args: string, ctx: ExtensionContext) => unknown }) { commands.set(name, command); }, + registerMessageRenderer: () => {}, registerFlag: () => {}, registerShortcut: () => {}, on(event: string, handler: (event: never, ctx: ExtensionContext) => unknown) { diff --git a/packages/coding-agent/test/step-subagent-events.test.ts b/packages/coding-agent/test/step-subagent-events.test.ts index d1ffa52d..a1d54487 100644 --- a/packages/coding-agent/test/step-subagent-events.test.ts +++ b/packages/coding-agent/test/step-subagent-events.test.ts @@ -57,6 +57,7 @@ function createApi(): { api: ExtensionAPI; tools: Map; s tools.set(tool.name, tool); }, registerCommand: () => {}, + registerMessageRenderer: () => {}, registerFlag: () => {}, registerShortcut: () => {}, on: () => {}, diff --git a/packages/coding-agent/test/subagent-background-tui.test.ts b/packages/coding-agent/test/subagent-background-tui.test.ts new file mode 100644 index 00000000..5683c9f5 --- /dev/null +++ b/packages/coding-agent/test/subagent-background-tui.test.ts @@ -0,0 +1,241 @@ +import { visibleWidth } from "@step-harness/pi-tui"; +import { expect, test } from "vitest"; +import type { ExtensionAPI, ExtensionContext } from "../src/core/extensions/types.ts"; +import type { CustomMessage } from "../src/core/messages.ts"; +import { + type BackgroundAgentLane, + createStepSubagentExtension, + type StepSubagentRunInput, + type StepSubagentRunResult, +} from "../src/features/step-subagent.ts"; +import { type AgentNotificationDetails, notifyLaneEvent } from "../src/features/subagent/lane-events.ts"; +import { renderAgentNotification } from "../src/features/subagent/rendering.ts"; +import type { Theme } from "../src/theme/theme.ts"; + +// Identity theme: assertions are about content and layout, not colors. +const plainTheme = { fg: (_c: string, text: string) => text, bold: (text: string) => text } as unknown as Theme; + +function runResult(text: string, output = 2, exitCode = 0): StepSubagentRunResult { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "step", + model: "step-3.7-flash", + usage: { + input: 1, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1 + output, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ], + stderr: "", + exitCode, + usage: { input: 1, output, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 1 + output, turns: 1 }, + startedAt: Date.now(), + updatedAt: Date.now(), + } as unknown as StepSubagentRunResult; +} + +const fakeTui = { requestRender: () => {} }; + +interface WidgetCall { + key: string; + component?: { render(width: number): string[] }; +} + +function harness(): { + api: ExtensionAPI; + tools: Map Promise }>; + ctx: ExtensionContext; + calls: WidgetCall[]; +} { + const tools = new Map Promise }>(); + const api = { + registerTool: (tool: { name: string }) => tools.set(tool.name, tool as never), + registerMessageRenderer: () => {}, + registerCommand: () => {}, + registerFlag: () => {}, + registerShortcut: () => {}, + on: () => {}, + getActiveTools: () => [], + setActiveTools: () => {}, + getFlag: () => false, + appendEntry: () => {}, + sendMessage: () => {}, + sendUserMessage: () => {}, + } as unknown as ExtensionAPI; + const calls: WidgetCall[] = []; + const ctx = { + mode: "tui", + hasUI: true, + cwd: "/tmp", + model: undefined, + thinkingLevel: "high", + isIdle: () => true, + isProjectTrusted: () => true, + ui: { + confirm: async () => true, + notify: () => {}, + setWidget: (key: string, content: unknown) => { + calls.push({ + key, + component: + typeof content === "function" + ? (content as (tui: unknown, theme: Theme) => WidgetCall["component"])(fakeTui, plainTheme) + : undefined, + }); + }, + }, + sessionManager: { getEntries: () => [] }, + } as unknown as ExtensionContext; + return { api, tools, ctx, calls }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("Timed out waiting for condition"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +test("background lanes share one stable list widget that clears when the batch settles", async () => { + const { api, tools, ctx, calls } = harness(); + const gates = new Map void>(); + const inputs = new Map(); + createStepSubagentExtension({ + includeBuiltinAgents: true, + agentDir: "/tmp/step-agent-test", + runner: (input) => { + inputs.set(input.task, input); + return new Promise((resolve) => gates.set(input.task, () => resolve(runResult(`${input.task} done`, 9)))); + }, + })(api); + const subagent = tools.get("subagent")!; + for (const task of ["alpha task", "beta task"]) { + await subagent.execute( + `call-${task}` as never, + { agent: "general", task, run_in_background: true } as never, + undefined as never, + undefined as never, + ctx as never, + ); + } + await waitFor(() => inputs.size === 2); + + // A burst of streamed deltas only moves the live text; none of it reaches + // the widget, so it is not republished. + const before = calls.length; + for (const delta of ["Now let me", "Now let me look at the section", "- INVERT_IF: `if cond:`"]) { + inputs.get("beta task")!.onUpdate?.({ ...runResult("", 0), exitCode: -1, activeText: delta, messages: [] }); + } + expect(calls.length).toBe(before); + + expect(new Set(calls.map((call) => call.key))).toEqual(new Set(["step-agent-lanes"])); + const widget = calls.at(-1)!.component!; + const rows = widget.render(100); + expect(rows[0]).toContain("background agents"); + expect(rows[0]).toContain("2 running"); + // Rows keep spawn order regardless of which lane updated last, never show + // the child's streamed text, and fit the width on one line each. + expect(rows.findIndex((row) => row.includes("alpha task"))).toBeLessThan( + rows.findIndex((row) => row.includes("beta task")), + ); + expect(rows.join("\n")).not.toContain("INVERT_IF"); + for (const row of rows) expect(visibleWidth(row)).toBeLessThanOrEqual(100); + + gates.get("alpha task")!(); + await waitFor(() => (calls.at(-1)?.component?.render(100)[0] ?? "").includes("1/2 complete")); + expect( + calls + .at(-1)! + .component!.render(100) + .some((row) => row.includes("alpha task")), + ).toBe(true); + + gates.get("beta task")!(); + await waitFor(() => calls.at(-1)?.component === undefined); + expect(calls.at(-1)!.key).toBe("step-agent-lanes"); +}); + +function fakeLane(subscribe: BackgroundAgentLane["subscribe"]): BackgroundAgentLane { + return { + id: "b727337a", + subscribe, + status: "running", + details: { results: [{ agent: "general" }] }, + } as unknown as BackgroundAgentLane; +} + +test("progress notifications reach the model but stay out of the transcript", () => { + const sent: Array<{ display?: boolean; details?: AgentNotificationDetails; content: string }> = []; + const pi = { sendMessage: (message: (typeof sent)[number]) => sent.push(message) } as unknown as ExtensionAPI; + const lane = fakeLane("progress"); + + notifyLaneEvent(pi, lane, "background_progress", "step 1/1; tool write_file; turns 107"); + lane.status = "completed"; + notifyLaneEvent(pi, lane, "background_done", "all done"); + + expect(sent[0].display).toBe(false); + expect(sent[0].content).toContain("turns 107"); + expect(sent[1].display).toBe(true); + expect(sent[1].details).toMatchObject({ + agentId: "b727337a", + event: "background_done", + label: "b727337a", + agents: ["general"], + detail: "all done", + }); +}); + +function notification(details?: AgentNotificationDetails): CustomMessage { + return { + role: "custom", + customType: "agent-notification", + content: 'raw', + display: true, + details, + timestamp: Date.now(), + } as CustomMessage; +} + +test("agent-notification renders as a status line with a collapsible preview", () => { + const detail = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n"); + const message = notification({ + agentId: "b727337a", + event: "background_failed", + status: "failed", + label: "audit", + agents: ["general"], + detail, + }); + + const collapsed = renderAgentNotification(message, { expanded: false, outputPad: 0 }, plainTheme)! + .render(120) + .join("\n"); + expect(collapsed).toContain("background agent audit (general) failed"); + expect(collapsed).toContain("line 3"); + expect(collapsed).not.toContain("line 4"); + expect(collapsed).toContain("2 more lines"); + expect(collapsed).not.toContain(" { + expect(renderAgentNotification(notification(undefined), { expanded: false, outputPad: 0 }, plainTheme)).toBe( + undefined, + ); +}); diff --git a/packages/coding-agent/test/subagent-child-env.test.ts b/packages/coding-agent/test/subagent-child-env.test.ts index 98662634..9d3584e7 100644 --- a/packages/coding-agent/test/subagent-child-env.test.ts +++ b/packages/coding-agent/test/subagent-child-env.test.ts @@ -62,6 +62,7 @@ function registeredTools(factory: (pi: ExtensionAPI) => void): string[] { factory({ registerTool: (tool: ToolDefinition) => tools.push(tool.name), registerCommand: () => {}, + registerMessageRenderer: () => {}, registerFlag: () => {}, registerShortcut: () => {}, on: () => {}, diff --git a/packages/coding-agent/test/subagent-list-widget.test.ts b/packages/coding-agent/test/subagent-list-widget.test.ts index 1b313531..fe50363c 100644 --- a/packages/coding-agent/test/subagent-list-widget.test.ts +++ b/packages/coding-agent/test/subagent-list-widget.test.ts @@ -141,7 +141,6 @@ test("wide glyphs are measured in display columns, so the metric column survives const widget = new SubagentListWidget( details([ record({ - status: "completed", startedAt: Date.now() - 259_000, usage: { input: 0, output: 5100, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 2 }, task: "已获取全部所需数据,多个来源交叉验证一致。以下是查询结果,品种为黄金现货。", @@ -160,6 +159,23 @@ test("wide glyphs are measured in display columns, so the metric column survives } }); +test("a settled row keeps its final elapsed but drops the token readout", () => { + const now = Date.now(); + const usage = { input: 0, output: 4100, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 3 }; + const widget = new SubagentListWidget( + details([ + record({ status: "completed", startedAt: now - 65_000, updatedAt: now, usage }), + record({ status: "failed", startedAt: now - 30_000, updatedAt: now, usage }), + record({ startedAt: now - 199_000, usage }), + ]), + plainTheme, + ); + const rows = widget.render(120).slice(1); + expect(rows[0]).toMatch(/ 65s$/u); + expect(rows[1]).toMatch(/ 30s$/u); + expect(rows[2]).toMatch(/199s · ↓ 4\.1k tokens$/u); +}); + test("the metric column is a fixed width, so rows align regardless of value length", () => { const now = Date.now(); const widget = new SubagentListWidget( @@ -243,6 +259,7 @@ function harness(hasUI: boolean): { const api = { registerTool: (tool: { name: string }) => tools.set(tool.name, tool as never), registerCommand: () => {}, + registerMessageRenderer: () => {}, registerFlag: () => {}, registerShortcut: () => {}, on: () => {},