pi-subagents 0.24.2 → 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
@@ -15,8 +15,8 @@ import {
15
15
  MAX_WIDGET_JOBS,
16
16
  WIDGET_KEY,
17
17
  } from "../shared/types.ts";
18
- import { formatTokens, formatUsage, formatDuration, formatToolCall, shortenPath } from "../shared/formatters.ts";
19
- import { getDisplayItems, getLastActivity, getSingleResultOutput } from "../shared/utils.ts";
18
+ import { formatTokens, formatUsage, formatDuration, formatModelThinking, formatToolCall, shortenPath } from "../shared/formatters.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,9 +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
- return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${activity ? ` · ${activity}` : ""}`)}`;
411
+ const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
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}` : ""}`)}`;
361
413
  });
362
414
  }
363
415
 
@@ -562,8 +614,7 @@ function widgetStats(job: AsyncJobState, theme: Theme): string {
562
614
  }
563
615
  if (job.toolCount !== undefined) parts.push(formatToolUseStat(job.toolCount));
564
616
  if (job.totalTokens?.total) parts.push(formatTokenStat(job.totalTokens.total));
565
- const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? (job.updatedAt ?? Date.now()) : Date.now();
566
- 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)));
567
618
  return statJoin(theme, parts);
568
619
  }
569
620
 
@@ -576,10 +627,15 @@ function widgetStepStats(theme: Theme, step: NonNullable<AsyncJobState["steps"]>
576
627
  ]);
577
628
  }
578
629
 
579
- function widgetStepActivityLine(step: NonNullable<AsyncJobState["steps"]>[number], width: number, expanded: boolean): string {
580
- const toolLine = formatCurrentToolLine(step, width, expanded);
630
+ function modelThinkingBadge(theme: Theme, model?: string, thinking?: string): string {
631
+ const label = formatModelThinking(model, thinking);
632
+ return label ? theme.fg("dim", ` (${label})`) : "";
633
+ }
634
+
635
+ function widgetStepActivityLine(step: NonNullable<AsyncJobState["steps"]>[number], width: number, expanded: boolean, snapshotNow?: number): string {
636
+ const toolLine = formatCurrentToolLine(step, width, expanded, snapshotNow);
581
637
  if (toolLine) return toolLine;
582
- const activity = formatActivityLabel(step.lastActivityAt, step.activityState);
638
+ const activity = buildLiveStatusLine(step, snapshotNow);
583
639
  if (activity) return activity;
584
640
  if (step.status === "running") return "thinking…";
585
641
  return "";
@@ -602,15 +658,16 @@ function foregroundStyleWidgetStepLines(
602
658
  ): string[] {
603
659
  const status = widgetStepStatus(step.status, theme);
604
660
  const stats = widgetStepStats(theme, step);
605
- const lines = [` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
606
- const activity = widgetStepActivityLine(step, width, expanded);
661
+ const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
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);
607
664
  if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`);
608
665
  if (step.status === "running") {
609
666
  if (!expanded) lines.push(` ${theme.fg("accent", "Press Ctrl+O for live detail")}`);
610
667
  const output = widgetOutputPath(job, step);
611
668
  if (output) lines.push(` ${theme.fg("dim", `output: ${shortenPath(output)}`)}`);
612
669
  if (expanded) {
613
- const liveStatus = buildLiveStatusLine(step);
670
+ const liveStatus = buildLiveStatusLine(step, job.updatedAt);
614
671
  if (liveStatus && liveStatus !== activity) lines.push(` ${theme.fg("accent", liveStatus)}`);
615
672
  for (const tool of step.recentTools?.slice(-3) ?? []) {
616
673
  const maxArgsLen = Math.max(40, width - 30);
@@ -658,10 +715,11 @@ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: numbe
658
715
  const lines = fullLines.slice(0, 2);
659
716
  for (const [index, step] of job.steps.entries()) {
660
717
  const status = widgetStepStatus(step.status, theme);
661
- const activity = widgetStepActivityLine(step, width, false);
718
+ const activity = widgetStepActivityLine(step, width, false, job.updatedAt);
662
719
  const stepStats = widgetStepStats(theme, step);
663
720
  const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : "";
664
- lines.push(` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
721
+ const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
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}` : ""}`);
665
723
  }
666
724
  if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", " Press Ctrl+O for live detail"));
667
725
  return lines.map((line) => truncLine(line, width));
@@ -704,7 +762,8 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
704
762
 
705
763
  const lines: string[] = [];
706
764
  const hasActive = running.length > 0 || queued.length > 0;
707
- 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));
708
767
 
709
768
  const items: string[][] = [];
710
769
  let hiddenRunning = 0;
@@ -764,66 +823,16 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
764
823
  return lines;
765
824
  }
766
825
 
767
- function refreshAnimatedWidget(): void {
768
- try {
769
- if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return;
770
- latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(latestWidgetJobs, latestWidgetCtx.ui.getToolsExpanded?.() ?? false));
771
- latestWidgetCtx.ui.requestRender?.();
772
- } catch (error) {
773
- if (!isStaleExtensionContextError(error)) throw error;
774
- stopWidgetAnimation();
775
- }
776
- }
777
-
778
- function ensureWidgetAnimation(): void {
779
- if (widgetTimer) return;
780
- widgetTimer = setInterval(() => {
781
- if (!hasAnimatedWidgetJobs(latestWidgetJobs)) {
782
- stopWidgetAnimation();
783
- return;
784
- }
785
- refreshAnimatedWidget();
786
- }, WIDGET_ANIMATION_MS);
787
- widgetTimer.unref?.();
788
- }
789
-
790
- export function stopWidgetAnimation(): void {
791
- if (widgetTimer) {
792
- clearInterval(widgetTimer);
793
- widgetTimer = undefined;
794
- }
795
- latestWidgetCtx = undefined;
796
- latestWidgetJobs = [];
797
- outputActivityCache.clear();
798
- }
799
-
800
- export function stopResultAnimations(): void {
801
- for (const [timer, state] of resultAnimationTimers) {
802
- clearInterval(timer);
803
- state.subagentResultAnimationTimer = undefined;
804
- }
805
- resultAnimationTimers.clear();
806
- }
807
-
808
826
  /**
809
827
  * Render the async jobs widget
810
828
  */
811
829
  export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
812
830
  if (jobs.length === 0) {
813
- stopWidgetAnimation();
814
831
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
815
832
  return;
816
833
  }
817
- if (!ctx.hasUI) {
818
- stopWidgetAnimation();
819
- return;
820
- }
821
- latestWidgetCtx = ctx;
822
- latestWidgetJobs = [...jobs];
823
-
834
+ if (!ctx.hasUI) return;
824
835
  ctx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(jobs, ctx.ui.getToolsExpanded?.() ?? false));
825
- if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation();
826
- else stopWidgetAnimation();
827
836
  }
828
837
 
829
838
  function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme): Component {
@@ -837,12 +846,14 @@ function renderSingleCompact(d: Details, r: Details["results"][number], theme: T
837
846
  ]);
838
847
  const c = new Container();
839
848
  const width = getTermWidth() - 4;
840
- c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning)} ${theme.fg("toolTitle", theme.bold(r.agent))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
849
+ const modelDisplay = modelThinkingBadge(theme, r.model);
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));
841
851
 
842
852
  if (isRunning && r.progress) {
853
+ const progressSnapshotNow = snapshotNowForProgress(r.progress);
843
854
  const activity = compactCurrentActivity(r.progress);
844
855
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
845
- const liveStatus = buildLiveStatusLine(r.progress);
856
+ const liveStatus = buildLiveStatusLine(r.progress, progressSnapshotNow);
846
857
  if (liveStatus && liveStatus !== activity) c.addChild(new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0));
847
858
  c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
848
859
  if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
@@ -883,7 +894,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
883
894
  const itemTitle = multiLabel.itemTitle;
884
895
  const stats = statJoin(theme, [multiLabel.headerLabel, formatProgressStats(theme, totalSummary)]);
885
896
  const glyph = hasRunning
886
- ? theme.fg("accent", spinnerFrame())
897
+ ? theme.fg("accent", runningGlyph(runningSeed(progressRunningSeed(totalSummary), d.currentStepIndex)))
887
898
  : failed
888
899
  ? theme.fg("error", "✗")
889
900
  : paused
@@ -913,7 +924,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
913
924
  const rPending = rProg && "status" in rProg && rProg.status === "pending";
914
925
  const stepNumber = r.progress?.index !== undefined ? r.progress.index + 1 : progressFromArray?.index !== undefined ? progressFromArray.index + 1 : i + 1;
915
926
  const stepStats = formatProgressStats(theme, rProg);
916
- 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));
917
928
  const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
918
929
  const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
919
930
  const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
@@ -988,11 +999,12 @@ export function renderSubagentResult(
988
999
  c.addChild(new Spacer(1));
989
1000
 
990
1001
  if (isRunning && r.progress) {
991
- const toolLine = formatCurrentToolLine(r.progress, w, expanded);
1002
+ const progressSnapshotNow = snapshotNowForProgress(r.progress);
1003
+ const toolLine = formatCurrentToolLine(r.progress, w, expanded, progressSnapshotNow);
992
1004
  if (toolLine) {
993
1005
  c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0));
994
1006
  }
995
- const liveStatusLine = buildLiveStatusLine(r.progress);
1007
+ const liveStatusLine = buildLiveStatusLine(r.progress, progressSnapshotNow);
996
1008
  if (liveStatusLine) {
997
1009
  c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0));
998
1010
  }
@@ -1166,7 +1178,7 @@ export function renderSubagentResult(
1166
1178
  ? theme.fg("warning", "warning")
1167
1179
  : theme.fg("success", "done");
1168
1180
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
1169
- const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
1181
+ const modelDisplay = modelThinkingBadge(theme, r.model);
1170
1182
  const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
1171
1183
  const stepHeader = rRunning
1172
1184
  ? `${statusIcon} ${stepLabel}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
@@ -1199,11 +1211,12 @@ export function renderSubagentResult(
1199
1211
  if (rProg.skills?.length) {
1200
1212
  c.addChild(new Text(fit(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`)), 0, 0));
1201
1213
  }
1202
- const toolLine = formatCurrentToolLine(rProg, w, expanded);
1214
+ const progressSnapshotNow = snapshotNowForProgress(rProg);
1215
+ const toolLine = formatCurrentToolLine(rProg, w, expanded, progressSnapshotNow);
1203
1216
  if (toolLine) {
1204
1217
  c.addChild(new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0));
1205
1218
  }
1206
- const liveStatusLine = buildLiveStatusLine(rProg);
1219
+ const liveStatusLine = buildLiveStatusLine(rProg, progressSnapshotNow);
1207
1220
  if (liveStatusLine) {
1208
1221
  c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0));
1209
1222
  }