pi-subagents 0.24.3 → 0.24.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/render.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  WIDGET_KEY,
17
17
  } from "../shared/types.ts";
18
18
  import { formatTokens, formatUsage, formatDuration, formatModelThinking, formatToolCall, shortenPath } from "../shared/formatters.ts";
19
- import { getDisplayItems, getLastActivity, getSingleResultOutput } from "../shared/utils.ts";
19
+ import { getDisplayItems, getSingleResultOutput } from "../shared/utils.ts";
20
20
  import { flatToLogicalStepIndex } from "../runs/background/parallel-groups.ts";
21
21
  import { aggregateStepStatus, formatActivityLabel, formatAgentRunningLabel, formatParallelOutcome } from "../shared/status-format.ts";
22
22
 
@@ -84,64 +84,49 @@ function truncLine(text: string, maxWidth: number): string {
84
84
  return result + activeStyles.join("") + "…";
85
85
  }
86
86
 
87
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
88
- const WIDGET_ANIMATION_MS = 80;
87
+ const RUNNING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
88
+ const STATIC_RUNNING_GLYPH = "●";
89
89
 
90
- let widgetTimer: ReturnType<typeof setInterval> | undefined;
91
- let latestWidgetCtx: ExtensionContext | undefined;
92
- let latestWidgetJobs: AsyncJobState[] = [];
90
+ type ProgressSeedSource = Partial<Pick<AgentProgress, "index" | "toolCount" | "tokens" | "durationMs" | "lastActivityAt" | "currentToolStartedAt" | "turnCount">>;
93
91
 
94
- const resultAnimationTimers = new Map<ReturnType<typeof setInterval>, ResultAnimationContext["state"]>();
95
- const outputActivityCache = new Map<string, { checkedAt: number; text: string }>();
96
- const STALE_EXTENSION_CONTEXT_MESSAGE = "This extension ctx is stale after session replacement or reload";
97
-
98
- interface ResultAnimationContext {
99
- state: { subagentResultAnimationTimer?: ReturnType<typeof setInterval> };
100
- invalidate: () => void;
92
+ function runningSeed(...values: Array<number | undefined>): number | undefined {
93
+ let seed: number | undefined;
94
+ for (const value of values) {
95
+ if (value === undefined || !Number.isFinite(value)) continue;
96
+ seed = (seed ?? 0) + Math.trunc(value);
97
+ }
98
+ return seed;
101
99
  }
102
100
 
103
- function spinnerFrame(): string {
104
- return SPINNER[Math.floor(Date.now() / WIDGET_ANIMATION_MS) % SPINNER.length]!;
101
+ function runningGlyph(seed?: number): string {
102
+ if (seed === undefined) return STATIC_RUNNING_GLYPH;
103
+ return RUNNING_FRAMES[Math.abs(seed) % RUNNING_FRAMES.length]!;
105
104
  }
106
105
 
107
- function isStaleExtensionContextError(error: unknown): boolean {
108
- if (!(error instanceof Error)) return false;
109
- return error.message.includes(STALE_EXTENSION_CONTEXT_MESSAGE);
106
+ function progressRunningSeed(progress: ProgressSeedSource | undefined): number | undefined {
107
+ if (!progress) return undefined;
108
+ return runningSeed(
109
+ progress.index,
110
+ progress.toolCount,
111
+ progress.tokens,
112
+ progress.durationMs,
113
+ progress.lastActivityAt,
114
+ progress.currentToolStartedAt,
115
+ progress.turnCount,
116
+ );
110
117
  }
111
118
 
112
- function resultIsRunning(result: AgentToolResult<Details>): boolean {
113
- return result.details?.progress?.some((entry) => entry.status === "running")
114
- || result.details?.results.some((entry) => entry.progress?.status === "running")
115
- || false;
119
+ interface LegacyResultAnimationContext {
120
+ state: { subagentResultAnimationTimer?: ReturnType<typeof setInterval> };
116
121
  }
117
122
 
118
- function stopResultAnimation(context: ResultAnimationContext): void {
123
+ export function clearLegacyResultAnimationTimer(context: LegacyResultAnimationContext): void {
119
124
  const timer = context.state.subagentResultAnimationTimer;
120
125
  if (!timer) return;
121
126
  clearInterval(timer);
122
- resultAnimationTimers.delete(timer);
123
127
  context.state.subagentResultAnimationTimer = undefined;
124
128
  }
125
129
 
126
- export function syncResultAnimation(result: AgentToolResult<Details>, context: ResultAnimationContext): void {
127
- if (!resultIsRunning(result)) {
128
- stopResultAnimation(context);
129
- return;
130
- }
131
- if (context.state.subagentResultAnimationTimer) return;
132
- const timer = setInterval(() => {
133
- try {
134
- context.invalidate();
135
- } catch (error) {
136
- if (!isStaleExtensionContextError(error)) throw error;
137
- stopResultAnimation(context);
138
- }
139
- }, WIDGET_ANIMATION_MS);
140
- timer.unref?.();
141
- context.state.subagentResultAnimationTimer = timer;
142
- resultAnimationTimers.set(timer, context.state);
143
- }
144
-
145
130
  function extractOutputTarget(task: string): string | undefined {
146
131
  const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i);
147
132
  if (writeToMatch?.[1]?.trim()) return writeToMatch[1].trim();
@@ -170,7 +155,17 @@ function getToolCallLines(
170
155
  }
171
156
 
172
157
 
173
- function formatCurrentToolLine(progress: Pick<AgentProgress, "currentTool" | "currentToolArgs" | "currentToolStartedAt">, availableWidth: number, expanded: boolean): string | undefined {
158
+ function snapshotNowForProgress(progress: Pick<AgentProgress, "currentToolStartedAt" | "durationMs" | "lastActivityAt">): number | undefined {
159
+ if (progress.currentToolStartedAt !== undefined && progress.durationMs !== undefined) return progress.currentToolStartedAt + progress.durationMs;
160
+ return progress.lastActivityAt;
161
+ }
162
+
163
+ function formatCurrentToolLine(
164
+ progress: Pick<AgentProgress, "currentTool" | "currentToolArgs" | "currentToolStartedAt">,
165
+ availableWidth: number,
166
+ expanded: boolean,
167
+ snapshotNow?: number,
168
+ ): string | undefined {
174
169
  if (!progress.currentTool) return undefined;
175
170
  const maxToolArgsLen = Math.max(50, availableWidth - 20);
176
171
  const toolArgsPreview = progress.currentToolArgs
@@ -178,16 +173,20 @@ function formatCurrentToolLine(progress: Pick<AgentProgress, "currentTool" | "cu
178
173
  ? progress.currentToolArgs
179
174
  : `${progress.currentToolArgs.slice(0, maxToolArgsLen)}...`)
180
175
  : "";
181
- const durationSuffix = progress.currentToolStartedAt !== undefined
182
- ? ` | ${formatDuration(Math.max(0, Date.now() - progress.currentToolStartedAt))}`
176
+ const durationSuffix = progress.currentToolStartedAt !== undefined && snapshotNow !== undefined
177
+ ? ` | ${formatDuration(Math.max(0, snapshotNow - progress.currentToolStartedAt))}`
183
178
  : "";
184
179
  return toolArgsPreview
185
180
  ? `${progress.currentTool}: ${toolArgsPreview}${durationSuffix}`
186
181
  : `${progress.currentTool}${durationSuffix}`;
187
182
  }
188
183
 
189
- function buildLiveStatusLine(progress: Pick<AgentProgress, "activityState" | "lastActivityAt">): string | undefined {
190
- return formatActivityLabel(progress.lastActivityAt, progress.activityState);
184
+ function buildLiveStatusLine(progress: Pick<AgentProgress, "activityState" | "lastActivityAt">, snapshotNow?: number): string | undefined {
185
+ if (progress.lastActivityAt !== undefined && snapshotNow !== undefined) return formatActivityLabel(progress.lastActivityAt, progress.activityState, snapshotNow);
186
+ if (progress.activityState === "needs_attention") return "needs attention";
187
+ if (progress.activityState === "active_long_running") return "active but long-running";
188
+ if (progress.lastActivityAt !== undefined) return "active";
189
+ return undefined;
191
190
  }
192
191
 
193
192
  function themeBold(theme: Theme, text: string): string {
@@ -227,8 +226,8 @@ function resultStatusLine(result: Details["results"][number], output: string): s
227
226
  return "Done";
228
227
  }
229
228
 
230
- function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running"): string {
231
- if (running) return theme.fg("accent", spinnerFrame());
229
+ function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running", seed = progressRunningSeed(result.progress ?? result.progressSummary)): string {
230
+ if (running) return theme.fg("accent", runningGlyph(seed));
232
231
  if (result.detached) return theme.fg("warning", "■");
233
232
  if (result.interrupted) return theme.fg("warning", "■");
234
233
  if (result.exitCode !== 0) return theme.fg("error", "✗");
@@ -237,11 +236,35 @@ function resultGlyph(result: Details["results"][number], output: string, theme:
237
236
  }
238
237
 
239
238
  function compactCurrentActivity(progress: AgentProgress): string {
240
- return formatCurrentToolLine(progress, getTermWidth() - 4, false) ?? buildLiveStatusLine(progress) ?? "thinking…";
239
+ const snapshotNow = snapshotNowForProgress(progress);
240
+ return formatCurrentToolLine(progress, getTermWidth() - 4, false, snapshotNow) ?? buildLiveStatusLine(progress, snapshotNow) ?? "thinking…";
241
241
  }
242
242
 
243
- function hasAnimatedWidgetJobs(jobs: AsyncJobState[]): boolean {
244
- return jobs.some((job) => job.status === "running");
243
+ export function widgetRenderKey(job: AsyncJobState): string {
244
+ return JSON.stringify({
245
+ asyncDir: job.asyncDir,
246
+ status: job.status,
247
+ activityState: job.activityState,
248
+ lastActivityAt: job.lastActivityAt,
249
+ currentTool: job.currentTool,
250
+ currentToolStartedAt: job.currentToolStartedAt,
251
+ currentPath: job.currentPath,
252
+ turnCount: job.turnCount,
253
+ toolCount: job.toolCount,
254
+ mode: job.mode,
255
+ agents: job.agents,
256
+ currentStep: job.currentStep,
257
+ chainStepCount: job.chainStepCount,
258
+ parallelGroups: job.parallelGroups,
259
+ steps: job.steps,
260
+ stepsTotal: job.stepsTotal,
261
+ runningSteps: job.runningSteps,
262
+ completedSteps: job.completedSteps,
263
+ activeParallelGroup: job.activeParallelGroup,
264
+ startedAt: job.startedAt,
265
+ updatedAt: job.updatedAt,
266
+ totalTokens: job.totalTokens,
267
+ });
245
268
  }
246
269
 
247
270
  function formatWidgetAgents(agents: string[]): string {
@@ -259,25 +282,14 @@ function widgetJobName(job: AsyncJobState): string {
259
282
  return job.mode ?? "subagent";
260
283
  }
261
284
 
262
- function getCachedLastActivity(outputFile: string | undefined): string {
263
- if (!outputFile) return "";
264
- const now = Date.now();
265
- const cached = outputActivityCache.get(outputFile);
266
- if (cached && now - cached.checkedAt < 1000) return cached.text;
267
- const text = getLastActivity(outputFile);
268
- outputActivityCache.set(outputFile, { checkedAt: now, text });
269
- return text;
270
- }
271
-
272
285
  function widgetActivity(job: AsyncJobState): string {
273
286
  const facts: string[] = [];
274
- if (job.currentTool && job.currentToolStartedAt !== undefined) facts.push(`${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`);
287
+ if (job.currentTool && job.currentToolStartedAt !== undefined && job.updatedAt !== undefined) facts.push(`${job.currentTool} ${formatDuration(Math.max(0, job.updatedAt - job.currentToolStartedAt))}`);
275
288
  else if (job.currentTool) facts.push(job.currentTool);
276
289
  if (job.currentPath) facts.push(shortenPath(job.currentPath));
277
290
  if (job.turnCount !== undefined) facts.push(`${job.turnCount} turns`);
278
291
  if (job.toolCount !== undefined) facts.push(`${job.toolCount} tools`);
279
- const activity = formatActivityLabel(job.lastActivityAt, job.activityState)
280
- ?? (job.status === "running" ? getCachedLastActivity(job.outputFile) : "");
292
+ const activity = buildLiveStatusLine(job, job.updatedAt);
281
293
  if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`;
282
294
  if (activity) return activity;
283
295
  if (facts.length) return facts.join(" · ");
@@ -288,16 +300,55 @@ function widgetActivity(job: AsyncJobState): string {
288
300
  return "Done";
289
301
  }
290
302
 
303
+ function widgetStepRunningSeed(step: NonNullable<AsyncJobState["steps"]>[number], fallbackIndex?: number): number | undefined {
304
+ return runningSeed(
305
+ fallbackIndex,
306
+ step.index,
307
+ step.toolCount,
308
+ step.turnCount,
309
+ step.tokens?.total,
310
+ step.lastActivityAt,
311
+ step.currentToolStartedAt,
312
+ step.durationMs,
313
+ );
314
+ }
315
+
316
+ function widgetStepsRunningSeed(steps: Array<NonNullable<AsyncJobState["steps"]>[number]> | undefined): number | undefined {
317
+ let seed: number | undefined;
318
+ for (const [index, step] of (steps ?? []).entries()) seed = runningSeed(seed, widgetStepRunningSeed(step, index));
319
+ return seed;
320
+ }
321
+
322
+ function widgetJobRunningSeed(job: AsyncJobState): number | undefined {
323
+ return runningSeed(
324
+ job.updatedAt,
325
+ job.lastActivityAt,
326
+ job.toolCount,
327
+ job.turnCount,
328
+ job.totalTokens?.total,
329
+ job.currentStep,
330
+ job.runningSteps,
331
+ job.completedSteps,
332
+ widgetStepsRunningSeed(job.steps),
333
+ );
334
+ }
335
+
336
+ function widgetJobsRunningSeed(jobs: AsyncJobState[]): number | undefined {
337
+ let seed: number | undefined;
338
+ for (const job of jobs) seed = runningSeed(seed, widgetJobRunningSeed(job));
339
+ return seed;
340
+ }
341
+
291
342
  function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
292
- if (job.status === "running") return theme.fg("accent", spinnerFrame());
343
+ if (job.status === "running") return theme.fg("accent", runningGlyph(widgetJobRunningSeed(job)));
293
344
  if (job.status === "queued") return theme.fg("muted", "◦");
294
345
  if (job.status === "complete") return theme.fg("success", "✓");
295
346
  if (job.status === "paused") return theme.fg("warning", "■");
296
347
  return theme.fg("error", "✗");
297
348
  }
298
349
 
299
- function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme): string {
300
- if (status === "running") return theme.fg("accent", spinnerFrame());
350
+ function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number): string {
351
+ if (status === "running") return theme.fg("accent", runningGlyph(seed));
301
352
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
302
353
  if (status === "failed") return theme.fg("error", "✗");
303
354
  if (status === "paused") return theme.fg("warning", "■");
@@ -312,15 +363,15 @@ function widgetStepStatus(status: AsyncJobStep["status"], theme: Theme): string
312
363
  return theme.fg("dim", status);
313
364
  }
314
365
 
315
- function widgetStepActivity(step: NonNullable<AsyncJobState["steps"]>[number]): string {
366
+ function widgetStepActivity(step: NonNullable<AsyncJobState["steps"]>[number], snapshotNow?: number): string {
316
367
  const facts: string[] = [];
317
- if (step.currentTool && step.currentToolStartedAt !== undefined) facts.push(`${step.currentTool} ${formatDuration(Math.max(0, Date.now() - step.currentToolStartedAt))}`);
368
+ if (step.currentTool && step.currentToolStartedAt !== undefined && snapshotNow !== undefined) facts.push(`${step.currentTool} ${formatDuration(Math.max(0, snapshotNow - step.currentToolStartedAt))}`);
318
369
  else if (step.currentTool) facts.push(step.currentTool);
319
370
  if (step.currentPath) facts.push(shortenPath(step.currentPath));
320
371
  if (step.turnCount !== undefined) facts.push(`${step.turnCount} turns`);
321
372
  if (step.toolCount !== undefined) facts.push(`${step.toolCount} tools`);
322
373
  if (step.tokens?.total) facts.push(formatTokenStat(step.tokens.total));
323
- const activity = formatActivityLabel(step.lastActivityAt, step.activityState);
374
+ const activity = buildLiveStatusLine(step, snapshotNow);
324
375
  if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`;
325
376
  if (activity) return activity;
326
377
  return facts.join(" · ");
@@ -335,7 +386,7 @@ function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false,
335
386
  const steps = job.steps.slice(span.start, span.start + span.count);
336
387
  if (span.isParallel) {
337
388
  const status = aggregateStepStatus(steps);
338
- lines.push(` ${widgetStepGlyph(status, theme)} Step ${span.stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", formatParallelOutcome(steps, span.count))}`);
389
+ lines.push(` ${widgetStepGlyph(status, theme, widgetStepsRunningSeed(steps))} Step ${span.stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", formatParallelOutcome(steps, span.count))}`);
339
390
  continue;
340
391
  }
341
392
  const step = steps[0];
@@ -355,10 +406,10 @@ function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme): string[]
355
406
  const total = job.stepsTotal ?? job.steps.length;
356
407
  return job.steps.map((step, index) => {
357
408
  const marker = index === job.steps!.length - 1 ? "└" : "├";
358
- const activity = widgetStepActivity(step);
409
+ const activity = widgetStepActivity(step, job.updatedAt);
359
410
  const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
360
411
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
361
- return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${modelDisplay}${activity ? ` · ${activity}` : ""}`)}`;
412
+ return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${modelDisplay}${activity ? ` · ${activity}` : ""}`)}`;
362
413
  });
363
414
  }
364
415
 
@@ -563,8 +614,7 @@ function widgetStats(job: AsyncJobState, theme: Theme): string {
563
614
  }
564
615
  if (job.toolCount !== undefined) parts.push(formatToolUseStat(job.toolCount));
565
616
  if (job.totalTokens?.total) parts.push(formatTokenStat(job.totalTokens.total));
566
- const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? (job.updatedAt ?? Date.now()) : Date.now();
567
- if (job.startedAt) parts.push(formatDuration(Math.max(0, endTime - job.startedAt)));
617
+ if (job.startedAt !== undefined && job.updatedAt !== undefined) parts.push(formatDuration(Math.max(0, job.updatedAt - job.startedAt)));
568
618
  return statJoin(theme, parts);
569
619
  }
570
620
 
@@ -582,10 +632,10 @@ function modelThinkingBadge(theme: Theme, model?: string, thinking?: string): st
582
632
  return label ? theme.fg("dim", ` (${label})`) : "";
583
633
  }
584
634
 
585
- function widgetStepActivityLine(step: NonNullable<AsyncJobState["steps"]>[number], width: number, expanded: boolean): string {
586
- const toolLine = formatCurrentToolLine(step, width, expanded);
635
+ function widgetStepActivityLine(step: NonNullable<AsyncJobState["steps"]>[number], width: number, expanded: boolean, snapshotNow?: number): string {
636
+ const toolLine = formatCurrentToolLine(step, width, expanded, snapshotNow);
587
637
  if (toolLine) return toolLine;
588
- const activity = formatActivityLabel(step.lastActivityAt, step.activityState);
638
+ const activity = buildLiveStatusLine(step, snapshotNow);
589
639
  if (activity) return activity;
590
640
  if (step.status === "running") return "thinking…";
591
641
  return "";
@@ -609,15 +659,15 @@ function foregroundStyleWidgetStepLines(
609
659
  const status = widgetStepStatus(step.status, theme);
610
660
  const stats = widgetStepStats(theme, step);
611
661
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
612
- const lines = [` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
613
- const activity = widgetStepActivityLine(step, width, expanded);
662
+ const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1))} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
663
+ const activity = widgetStepActivityLine(step, width, expanded, job.updatedAt);
614
664
  if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`);
615
665
  if (step.status === "running") {
616
666
  if (!expanded) lines.push(` ${theme.fg("accent", "Press Ctrl+O for live detail")}`);
617
667
  const output = widgetOutputPath(job, step);
618
668
  if (output) lines.push(` ${theme.fg("dim", `output: ${shortenPath(output)}`)}`);
619
669
  if (expanded) {
620
- const liveStatus = buildLiveStatusLine(step);
670
+ const liveStatus = buildLiveStatusLine(step, job.updatedAt);
621
671
  if (liveStatus && liveStatus !== activity) lines.push(` ${theme.fg("accent", liveStatus)}`);
622
672
  for (const tool of step.recentTools?.slice(-3) ?? []) {
623
673
  const maxArgsLen = Math.max(40, width - 30);
@@ -665,11 +715,11 @@ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: numbe
665
715
  const lines = fullLines.slice(0, 2);
666
716
  for (const [index, step] of job.steps.entries()) {
667
717
  const status = widgetStepStatus(step.status, theme);
668
- const activity = widgetStepActivityLine(step, width, false);
718
+ const activity = widgetStepActivityLine(step, width, false, job.updatedAt);
669
719
  const stepStats = widgetStepStats(theme, step);
670
720
  const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : "";
671
721
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
672
- lines.push(` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
722
+ lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
673
723
  }
674
724
  if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", " Press Ctrl+O for live detail"));
675
725
  return lines.map((line) => truncLine(line, width));
@@ -712,7 +762,8 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
712
762
 
713
763
  const lines: string[] = [];
714
764
  const hasActive = running.length > 0 || queued.length > 0;
715
- lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", hasActive ? "●" : "○")} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "· background")}`, width));
765
+ const headerGlyph = running.length > 0 ? runningGlyph(widgetJobsRunningSeed(running)) : hasActive ? "" : "";
766
+ lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", headerGlyph)} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "· background")}`, width));
716
767
 
717
768
  const items: string[][] = [];
718
769
  let hiddenRunning = 0;
@@ -772,66 +823,16 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
772
823
  return lines;
773
824
  }
774
825
 
775
- function refreshAnimatedWidget(): void {
776
- try {
777
- if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return;
778
- latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(latestWidgetJobs, latestWidgetCtx.ui.getToolsExpanded?.() ?? false));
779
- latestWidgetCtx.ui.requestRender?.();
780
- } catch (error) {
781
- if (!isStaleExtensionContextError(error)) throw error;
782
- stopWidgetAnimation();
783
- }
784
- }
785
-
786
- function ensureWidgetAnimation(): void {
787
- if (widgetTimer) return;
788
- widgetTimer = setInterval(() => {
789
- if (!hasAnimatedWidgetJobs(latestWidgetJobs)) {
790
- stopWidgetAnimation();
791
- return;
792
- }
793
- refreshAnimatedWidget();
794
- }, WIDGET_ANIMATION_MS);
795
- widgetTimer.unref?.();
796
- }
797
-
798
- export function stopWidgetAnimation(): void {
799
- if (widgetTimer) {
800
- clearInterval(widgetTimer);
801
- widgetTimer = undefined;
802
- }
803
- latestWidgetCtx = undefined;
804
- latestWidgetJobs = [];
805
- outputActivityCache.clear();
806
- }
807
-
808
- export function stopResultAnimations(): void {
809
- for (const [timer, state] of resultAnimationTimers) {
810
- clearInterval(timer);
811
- state.subagentResultAnimationTimer = undefined;
812
- }
813
- resultAnimationTimers.clear();
814
- }
815
-
816
826
  /**
817
827
  * Render the async jobs widget
818
828
  */
819
829
  export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
820
830
  if (jobs.length === 0) {
821
- stopWidgetAnimation();
822
831
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
823
832
  return;
824
833
  }
825
- if (!ctx.hasUI) {
826
- stopWidgetAnimation();
827
- return;
828
- }
829
- latestWidgetCtx = ctx;
830
- latestWidgetJobs = [...jobs];
831
-
834
+ if (!ctx.hasUI) return;
832
835
  ctx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(jobs, ctx.ui.getToolsExpanded?.() ?? false));
833
- if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation();
834
- else stopWidgetAnimation();
835
836
  }
836
837
 
837
838
  function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme): Component {
@@ -849,9 +850,10 @@ function renderSingleCompact(d: Details, r: Details["results"][number], theme: T
849
850
  c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning)} ${theme.fg("toolTitle", theme.bold(r.agent))}${modelDisplay}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
850
851
 
851
852
  if (isRunning && r.progress) {
853
+ const progressSnapshotNow = snapshotNowForProgress(r.progress);
852
854
  const activity = compactCurrentActivity(r.progress);
853
855
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
854
- const liveStatus = buildLiveStatusLine(r.progress);
856
+ const liveStatus = buildLiveStatusLine(r.progress, progressSnapshotNow);
855
857
  if (liveStatus && liveStatus !== activity) c.addChild(new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0));
856
858
  c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
857
859
  if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
@@ -892,7 +894,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
892
894
  const itemTitle = multiLabel.itemTitle;
893
895
  const stats = statJoin(theme, [multiLabel.headerLabel, formatProgressStats(theme, totalSummary)]);
894
896
  const glyph = hasRunning
895
- ? theme.fg("accent", spinnerFrame())
897
+ ? theme.fg("accent", runningGlyph(runningSeed(progressRunningSeed(totalSummary), d.currentStepIndex)))
896
898
  : failed
897
899
  ? theme.fg("error", "✗")
898
900
  : paused
@@ -922,7 +924,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
922
924
  const rPending = rProg && "status" in rProg && rProg.status === "pending";
923
925
  const stepNumber = r.progress?.index !== undefined ? r.progress.index + 1 : progressFromArray?.index !== undefined ? progressFromArray.index + 1 : i + 1;
924
926
  const stepStats = formatProgressStats(theme, rProg);
925
- const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning);
927
+ const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning, progressRunningSeed(rProg));
926
928
  const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
927
929
  const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
928
930
  const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
@@ -997,11 +999,12 @@ export function renderSubagentResult(
997
999
  c.addChild(new Spacer(1));
998
1000
 
999
1001
  if (isRunning && r.progress) {
1000
- const toolLine = formatCurrentToolLine(r.progress, w, expanded);
1002
+ const progressSnapshotNow = snapshotNowForProgress(r.progress);
1003
+ const toolLine = formatCurrentToolLine(r.progress, w, expanded, progressSnapshotNow);
1001
1004
  if (toolLine) {
1002
1005
  c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0));
1003
1006
  }
1004
- const liveStatusLine = buildLiveStatusLine(r.progress);
1007
+ const liveStatusLine = buildLiveStatusLine(r.progress, progressSnapshotNow);
1005
1008
  if (liveStatusLine) {
1006
1009
  c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0));
1007
1010
  }
@@ -1208,11 +1211,12 @@ export function renderSubagentResult(
1208
1211
  if (rProg.skills?.length) {
1209
1212
  c.addChild(new Text(fit(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`)), 0, 0));
1210
1213
  }
1211
- const toolLine = formatCurrentToolLine(rProg, w, expanded);
1214
+ const progressSnapshotNow = snapshotNowForProgress(rProg);
1215
+ const toolLine = formatCurrentToolLine(rProg, w, expanded, progressSnapshotNow);
1212
1216
  if (toolLine) {
1213
1217
  c.addChild(new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0));
1214
1218
  }
1215
- const liveStatusLine = buildLiveStatusLine(rProg);
1219
+ const liveStatusLine = buildLiveStatusLine(rProg, progressSnapshotNow);
1216
1220
  if (liveStatusLine) {
1217
1221
  c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0));
1218
1222
  }