From f1f3a4738c45c20f1bdf385dd5d8e0e5625edcf8 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 17:28:52 +0800 Subject: [PATCH] feat(ui): spin the running state and quiet the spawn marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The below-editor strips (subagents and workflows) now show the braille spinner for running work instead of a static ●, in step with the dashboard and takeover headers. The frames and cadence move to shared/spinner.ts so every surface animates identically. - The spawn result card's bright success ● drops to dim: a spawn is a beginning, not a success, and the strip's spinner carries the running state from there. --- extensions/shared/spinner.ts | 28 +++++++++++++++++++++ extensions/subagents/index.ts | 4 ++- extensions/subagents/navigation.ts | 13 +++++++--- extensions/subagents/src/ui/transcript.ts | 30 ++++++----------------- extensions/workflows/navigation.ts | 12 ++++++--- 5 files changed, 56 insertions(+), 31 deletions(-) create mode 100644 extensions/shared/spinner.ts diff --git a/extensions/shared/spinner.ts b/extensions/shared/spinner.ts new file mode 100644 index 00000000..cdebc1c7 --- /dev/null +++ b/extensions/shared/spinner.ts @@ -0,0 +1,28 @@ +/** + * One braille spinner for every running-state indicator in the package: + * transcripts, takeover and dashboard headers, and the below-editor strips all + * advance on the same cadence so concurrent views animate in step. + */ + +export const SPINNER_FRAMES = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", +] as const; + +/** Frame cadence, shared with the dashboard and takeover headers. */ +export const SPINNER_INTERVAL_MS = 120; + +export function spinnerFrame(now: number) { + const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length; + return SPINNER_FRAMES[ + (frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length + ]; +} diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index e3d0104d..2245be50 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -810,8 +810,10 @@ export default function (pi: ExtensionAPI) { const meta = [details.harness, details.model] .filter(Boolean) .join(" \u00b7 "); + // A spawn is a beginning, not a success: keep the marker quiet and let + // the strip's spinner carry the running state from here on. return new Text( - `${theme.fg("success", "\u25cf")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`, + `${theme.fg("dim", "\u25cf")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`, 0, 0, ); diff --git a/extensions/subagents/navigation.ts b/extensions/subagents/navigation.ts index 0b74bad2..30050f92 100644 --- a/extensions/subagents/navigation.ts +++ b/extensions/subagents/navigation.ts @@ -9,6 +9,7 @@ import { unreadActivityCounts, type ActivityCounts, } from "../shared/activity-status.ts"; +import { spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts"; import { contextPercent } from "./src/format.ts"; @@ -59,9 +60,13 @@ function statusColor(status: SubagentSnapshot["status"]) { return "error" as const; } -/** One status glyph per run state; doubles as the focus marker when selected. */ -function statusGlyph(snapshot: SubagentSnapshot, theme: Theme) { - if (snapshot.status === "running") return theme.fg("warning", "●"); +/** + * One status indicator per run state; doubles as the focus marker when + * selected. Running spins, in step with the dashboard and takeover headers. + */ +function statusGlyph(snapshot: SubagentSnapshot, theme: Theme, now: number) { + if (snapshot.status === "running") + return theme.fg("warning", spinnerFrame(now)); if (snapshot.status === "done") return theme.fg("success", "✓"); return theme.fg("error", "x"); } @@ -100,7 +105,7 @@ export class SubagentStripWidget { const { snapshot, counts } = entry; const glyph = this.strip.focused ? this.theme.fg("accent", "❯") - : statusGlyph(snapshot, this.theme); + : statusGlyph(snapshot, this.theme, Date.now()); const titleText = normalizeSubagentTitle(snapshot.title, snapshot.id); const title = this.strip.focused ? this.theme.bold(this.theme.fg("accent", titleText)) diff --git a/extensions/subagents/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index c56dab97..7b73bb5a 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -18,28 +18,14 @@ import type { SubagentSnapshot, TranscriptItem } from "../domain.ts"; const MAX_CACHED_WIDTHS_PER_ITEM = 2; -export const SPINNER_FRAMES = [ - "⠋", - "⠙", - "⠹", - "⠸", - "⠼", - "⠴", - "⠦", - "⠧", - "⠇", - "⠏", -] as const; - -/** Frame cadence, shared with the dashboard and takeover headers. */ -export const SPINNER_INTERVAL_MS = 120; - -export function spinnerFrame(now: number) { - const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length; - return SPINNER_FRAMES[ - (frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length - ]; -} +// The spinner lives in shared/ so strips outside this extension animate in +// step; the re-export keeps this module's historical import surface intact. +import { spinnerFrame } from "../../../shared/spinner.ts"; +export { + SPINNER_FRAMES, + SPINNER_INTERVAL_MS, + spinnerFrame, +} from "../../../shared/spinner.ts"; /** * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded diff --git a/extensions/workflows/navigation.ts b/extensions/workflows/navigation.ts index 1daca2d3..313cd82b 100644 --- a/extensions/workflows/navigation.ts +++ b/extensions/workflows/navigation.ts @@ -6,6 +6,7 @@ import { fitNavigationSides, renderNavigationMetrics, } from "../shared/below-editor-navigation.ts"; +import { spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { aggregateUsage, @@ -34,10 +35,13 @@ function cleanLine(value: string) { return sanitizeTerminalText(value).replace(/\s+/g, " ").trim(); } -/** One status glyph per run state; doubles as the focus marker when selected. */ -function statusGlyph(status: WorkflowStatus, theme: Theme) { +/** + * One status indicator per run state; doubles as the focus marker when + * selected. Running spins, in step with the dashboard and takeover headers. + */ +function statusGlyph(status: WorkflowStatus, theme: Theme, now: number) { if (status === "completed") return theme.fg("success", "✓"); - if (status === "running") return theme.fg("warning", "●"); + if (status === "running") return theme.fg("warning", spinnerFrame(now)); return theme.fg("error", "x"); } @@ -79,7 +83,7 @@ export class WorkflowStripWidget { const tokenCount = usage.input + usage.output; const glyph = this.strip.focused ? this.theme.fg("accent", "❯") - : statusGlyph(details.status, this.theme); + : statusGlyph(details.status, this.theme, Date.now()); const displayName = cleanLine(details.name ?? entry.runId) || entry.runId; const name = this.strip.focused ? this.theme.bold(this.theme.fg("accent", displayName))