@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.6

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1244 -2
  3. package/package.json +75 -4
  4. package/src/abortable.ts +43 -0
  5. package/src/agent-color.ts +161 -0
  6. package/src/agent-file-toggle.ts +297 -0
  7. package/src/agent-manager.ts +1874 -0
  8. package/src/agent-runner.ts +1552 -0
  9. package/src/agent-source-badge.ts +75 -0
  10. package/src/agent-types.ts +346 -0
  11. package/src/child-context.ts +15 -0
  12. package/src/clock.ts +25 -0
  13. package/src/completion-outbox.ts +125 -0
  14. package/src/context.ts +58 -0
  15. package/src/cross-extension-rpc.ts +221 -0
  16. package/src/custom-agents.ts +436 -0
  17. package/src/default-agents.ts +126 -0
  18. package/src/detached-results.ts +134 -0
  19. package/src/enabled-models.ts +180 -0
  20. package/src/enterprise-local.ts +66 -0
  21. package/src/env.ts +33 -0
  22. package/src/group-join.ts +148 -0
  23. package/src/index.ts +4423 -0
  24. package/src/invocation-config.ts +189 -0
  25. package/src/memory.ts +179 -0
  26. package/src/mention-clone.ts +195 -0
  27. package/src/mention.ts +141 -0
  28. package/src/model-call.ts +83 -0
  29. package/src/model-resolver.ts +266 -0
  30. package/src/model-scope.ts +70 -0
  31. package/src/nested-tools.ts +448 -0
  32. package/src/output-file.ts +164 -0
  33. package/src/package-resources.ts +624 -0
  34. package/src/prompts.ts +156 -0
  35. package/src/quota-admission.ts +99 -0
  36. package/src/schedule-store.ts +153 -0
  37. package/src/schedule.ts +446 -0
  38. package/src/settings.ts +728 -0
  39. package/src/skill-loader.ts +137 -0
  40. package/src/startup-error.ts +29 -0
  41. package/src/status-note.ts +90 -0
  42. package/src/structured-output.ts +130 -0
  43. package/src/types.ts +404 -0
  44. package/src/ui/agent-mention.ts +216 -0
  45. package/src/ui/agent-row.ts +235 -0
  46. package/src/ui/agent-widget.ts +692 -0
  47. package/src/ui/conversation-viewer.ts +832 -0
  48. package/src/ui/fleet-list.ts +675 -0
  49. package/src/ui/notification-view.ts +120 -0
  50. package/src/ui/schedule-menu.ts +105 -0
  51. package/src/ui/select-item.ts +45 -0
  52. package/src/ui/viewer-keys.ts +39 -0
  53. package/src/ui/workflow-card.ts +462 -0
  54. package/src/ui/workflow-dialog.ts +1115 -0
  55. package/src/ui/workflow-menu.ts +193 -0
  56. package/src/usage.ts +167 -0
  57. package/src/workflow/collisions.ts +123 -0
  58. package/src/workflow/entry.ts +47 -0
  59. package/src/workflow/host.ts +401 -0
  60. package/src/workflow/journal.ts +164 -0
  61. package/src/workflow/json-schema.ts +129 -0
  62. package/src/workflow/meta.ts +326 -0
  63. package/src/workflow/progress.ts +549 -0
  64. package/src/workflow/runtime.ts +1216 -0
  65. package/src/workflow/saved.ts +259 -0
  66. package/src/workflow/task.ts +302 -0
  67. package/src/workflow/tool-description.ts +200 -0
  68. package/src/workflow/worker-source.ts +781 -0
  69. package/src/worktree.ts +230 -0
  70. package/src/xml.ts +13 -0
@@ -0,0 +1,692 @@
1
+ /**
2
+ * agent-widget.ts — Persistent widget showing running/completed agents above the editor.
3
+ *
4
+ * Displays a tree of agents with animated spinners, live stats, and activity descriptions.
5
+ * Uses the callback form of setWidget for themed rendering.
6
+ */
7
+
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { renderAgentName } from "../agent-color.js";
10
+ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
11
+ import { getConfig } from "../agent-types.js";
12
+ import { type Clock, realClock, type TimerHandle } from "../clock.js";
13
+ import { describeModelCall, type ModelCallState } from "../model-call.js";
14
+ import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
15
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
16
+
17
+ // ---- Constants ----
18
+
19
+ /** Maximum number of rendered lines before overflow collapse kicks in. */
20
+ const MAX_WIDGET_LINES = 12;
21
+
22
+ /** Braille spinner frames for animated running indicator. */
23
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
24
+
25
+ /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
26
+ export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
27
+
28
+ /** Tool name → human-readable action for activity descriptions. */
29
+ const TOOL_DISPLAY: Record<string, string> = {
30
+ read: "reading",
31
+ bash: "running command",
32
+ edit: "editing",
33
+ write: "writing",
34
+ grep: "searching",
35
+ find: "finding files",
36
+ ls: "listing",
37
+ };
38
+
39
+ // ---- Types ----
40
+
41
+ export type Theme = {
42
+ fg(color: string, text: string): string;
43
+ bold(text: string): string;
44
+ };
45
+
46
+ export type UICtx = {
47
+ setStatus(key: string, text: string | undefined): void;
48
+ setWidget(
49
+ key: string,
50
+ content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void; dispose?(): void }),
51
+ options?: { placement?: "aboveEditor" | "belowEditor" },
52
+ ): void;
53
+ };
54
+
55
+ /** Per-agent live activity state. */
56
+ export interface AgentActivity {
57
+ activeTools: Map<string, string>;
58
+ toolUses: number;
59
+ responseText: string;
60
+ session?: SessionLike;
61
+ /** Current turn count. */
62
+ turnCount: number;
63
+ /** Effective max turns for this agent (undefined = unlimited). */
64
+ maxTurns?: number;
65
+ /** The model call in flight, if any; see `describeModelCall`. */
66
+ modelCall?: ModelCallState;
67
+ }
68
+
69
+ /** Metadata attached to Agent tool results for custom rendering. */
70
+ export interface AgentDetails {
71
+ displayName: string;
72
+ description: string;
73
+ subagentType: string;
74
+ toolUses: number;
75
+ tokens: string;
76
+ durationMs: number;
77
+ status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
78
+ /** Human-readable description of what the agent is currently doing. */
79
+ activity?: string;
80
+ /** Current spinner frame index (for animated running indicator). */
81
+ spinnerFrame?: number;
82
+ /** Show the ctrl+b hint on this running foreground call's row — one row per batch. */
83
+ backgroundHint?: boolean;
84
+ /** Short label for the model the run used, e.g. "haiku 4.5". */
85
+ modelName?: string;
86
+ /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
87
+ tags?: string[];
88
+ /** Current turn count. */
89
+ turnCount?: number;
90
+ /** Effective max turns (undefined = unlimited). */
91
+ maxTurns?: number;
92
+ /** Estimated cost in USD; 0 when the model has no pricing data. */
93
+ cost?: number;
94
+ agentId?: string;
95
+ error?: string;
96
+ /** The `.output` transcript, when one was written. */
97
+ outputFile?: string;
98
+ }
99
+
100
+ // ---- Formatting helpers ----
101
+
102
+ /** Apply foreground styling while restoring it after nested foreground/full ANSI resets. */
103
+ export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string {
104
+ const styledEmpty = theme.fg(color, "");
105
+ const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, "");
106
+ return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
107
+ }
108
+
109
+ /** Format a token count compactly: "33.8k token", "1.2M token". */
110
+ export function formatTokens(count: number): string {
111
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
112
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
113
+ return `${count} token`;
114
+ }
115
+
116
+ /**
117
+ * Format a cost as `~$0.0042`, or "" when there is nothing to show.
118
+ *
119
+ * The tilde is load-bearing: this is pi's own estimate from the model's listed
120
+ * rates, not a billed figure, and the surfaces that print it sit next to token
121
+ * counts that ARE exact.
122
+ *
123
+ * Nothing is printed for zero, which is also what a model with no pricing data
124
+ * reports: `$0.00` beside a local model's tokens would claim its cost was
125
+ * measured and found to be nothing, rather than never measured at all. For the
126
+ * same reason a real cost too small for four decimals reads `<$0.0001` — it was
127
+ * measured, and rounding it to `~$0.0000` would say the opposite.
128
+ */
129
+ export function formatCost(cost: number): string {
130
+ if (!(cost > 0)) return ""; // also catches NaN
131
+ if (cost < 0.0001) return "<$0.0001";
132
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
133
+ // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
134
+ // Most single runs land between a tenth of a cent and a dime, where rounding
135
+ // to cents would collapse a 4x difference in spend into the same figure.
136
+ const rounded = Number(cost.toFixed(4));
137
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
138
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
139
+ }
140
+
141
+ /**
142
+ * Token count with optional context-fill % and compaction-count annotations.
143
+ * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
144
+ * Compaction count rendered as `⇊N` in dim.
145
+ *
146
+ * "12.3k token" — no annotations
147
+ * "12.3k token (45%)" — percent only
148
+ * "12.3k token (⇊2)" — compactions only (e.g. right after compact)
149
+ * "12.3k token (45% · ⇊2)" — both
150
+ */
151
+ export function formatSessionTokens(
152
+ tokens: number,
153
+ percent: number | null,
154
+ theme: Theme,
155
+ compactions = 0,
156
+ ): string {
157
+ const tokenStr = formatTokens(tokens);
158
+ const annot: string[] = [];
159
+ if (percent !== null) {
160
+ const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
161
+ annot.push(theme.fg(color, `${Math.round(percent)}%`));
162
+ }
163
+ if (compactions > 0) {
164
+ annot.push(theme.fg("dim", `⇊${compactions}`));
165
+ }
166
+ if (annot.length === 0) return tokenStr;
167
+ return `${tokenStr} (${annot.join(" · ")})`;
168
+ }
169
+
170
+ /** Format turn count with optional max limit: "↻5≤30" or "↻5". */
171
+ export function formatTurns(turnCount: number, maxTurns?: number | null): string {
172
+ return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
173
+ }
174
+
175
+ /** Format milliseconds as human-readable duration. */
176
+ export function formatMs(ms: number): string {
177
+ return `${(ms / 1000).toFixed(1)}s`;
178
+ }
179
+
180
+ /** Format duration from start/completed timestamps. */
181
+ export function formatDuration(startedAt: number, completedAt?: number, now = Date.now()): string {
182
+ if (completedAt) return formatMs(completedAt - startedAt);
183
+ return `${formatMs(now - startedAt)} (running)`;
184
+ }
185
+
186
+ /** Get display name for any agent type (built-in or custom). */
187
+ export function getDisplayName(type: SubagentType): string {
188
+ return getConfig(type).displayName;
189
+ }
190
+
191
+ /** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
192
+ export function getPromptModeLabel(type: SubagentType): string | undefined {
193
+ const config = getConfig(type);
194
+ return config.promptMode === "append" ? "twin" : undefined;
195
+ }
196
+
197
+ /**
198
+ * Mode label is not included — callers add it where they want it.
199
+ *
200
+ * Both model forms come back so each surface can pick by width; the
201
+ * "(asked X)" annotation is applied here rather than by callers, so a value the
202
+ * spawn did not honor cannot be rendered as though it had been (#182).
203
+ */
204
+ export function buildInvocationTags(
205
+ invocation: AgentInvocation | undefined,
206
+ ): { modelName?: string; modelId?: string; tags: string[] } {
207
+ const tags: string[] = [];
208
+ if (!invocation) return { tags };
209
+ const asked = (value: string | undefined, requested: string | undefined): string | undefined =>
210
+ value && requested && requested !== value ? `${value} (asked ${requested})` : value;
211
+ const thinking = asked(invocation.thinking, invocation.requestedThinking);
212
+ if (thinking) tags.push(`thinking: ${thinking}`);
213
+ if (invocation.isolated) tags.push("isolated");
214
+ if (invocation.isolation === "worktree") tags.push("worktree");
215
+ if (invocation.inheritContext) tags.push("inherit context");
216
+ if (invocation.runInBackground) tags.push("background");
217
+ if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
218
+ return { modelName: invocation.modelName, modelId: invocation.modelId, tags };
219
+ }
220
+
221
+ /** Truncate text to a single line, max `len` chars. */
222
+ function truncateLine(text: string, len = 60): string {
223
+ const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
224
+ if (line.length <= len) return line;
225
+ return line.slice(0, len) + "…";
226
+ }
227
+
228
+ /**
229
+ * Build a human-readable activity string: running tools first, then the model
230
+ * call in flight (`thinking · 2m41s`, `writing · 3.2k tok`, `stalled? · 2m41s`),
231
+ * then the latest response text.
232
+ */
233
+ export function describeActivity(
234
+ activeTools: Map<string, string>,
235
+ responseText?: string,
236
+ modelCall?: ModelCallState,
237
+ now: number = Date.now(),
238
+ ): string {
239
+ if (activeTools.size > 0) {
240
+ const groups = new Map<string, number>();
241
+ for (const toolName of activeTools.values()) {
242
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
243
+ groups.set(action, (groups.get(action) ?? 0) + 1);
244
+ }
245
+
246
+ const parts: string[] = [];
247
+ for (const [action, count] of groups) {
248
+ if (count > 1) {
249
+ parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
250
+ } else {
251
+ parts.push(action);
252
+ }
253
+ }
254
+ return parts.join(", ") + "…";
255
+ }
256
+
257
+ const call = describeModelCall(modelCall, now);
258
+ if (call) return call;
259
+
260
+ // No tools active — show truncated response text if available
261
+ if (responseText && responseText.trim().length > 0) {
262
+ return truncateLine(responseText);
263
+ }
264
+
265
+ return "thinking…";
266
+ }
267
+
268
+ // ---- Widget manager ----
269
+
270
+ export class AgentWidget {
271
+ private uiCtx: UICtx | undefined;
272
+ private widgetFrame = 0;
273
+ private widgetInterval: TimerHandle | undefined;
274
+ /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
275
+ private finishedTurnAge = new Map<string, number>();
276
+ /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
277
+ private static readonly ERROR_LINGER_TURNS = 2;
278
+
279
+ /** Whether the widget callback is currently registered with the TUI. */
280
+ private widgetRegistered = false;
281
+ /** Cached TUI reference from widget factory callback, used for requestRender(). */
282
+ private tui: any | undefined;
283
+ /** The registered widget component, so a late `dispose()` of a replaced one is ignored. */
284
+ private component: object | undefined;
285
+ /** Last status bar text, used to avoid redundant setStatus calls. */
286
+ private lastStatusText: string | undefined;
287
+
288
+ constructor(
289
+ private manager: AgentManager,
290
+ private agentActivity: Map<string, AgentActivity>,
291
+ /**
292
+ * Read live at render time. Selects which agents the widget shows — see
293
+ * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
294
+ * extension supplies one defaulting to `"background"`.
295
+ */
296
+ private mode: () => WidgetMode = () => "all",
297
+ /**
298
+ * Read live at render time, like `mode`. Whether running agents show an
299
+ * estimated cost beside their token count. Defaults to off — the extension
300
+ * supplies the user's `showCost` setting.
301
+ */
302
+ private showCost: () => boolean = () => false,
303
+ /**
304
+ * Read live at render time, like `mode`. Whether running agents name the
305
+ * model driving them and the thinking level it is running at. Defaults to
306
+ * off — the extension supplies the user's `showModel` setting — because the
307
+ * row is already dense and the same pair is on the tool result and in the
308
+ * conversation viewer unconditionally.
309
+ */
310
+ private showModel: () => boolean = () => false,
311
+ private readonly clock: Clock = realClock,
312
+ ) {}
313
+
314
+ /**
315
+ * Agents eligible for the widget, per the current `WidgetMode`:
316
+ * - `off`: none (the widget's existing empty-state path hides it entirely).
317
+ * - `background`: drop only agents *known* to be foreground
318
+ * (`isBackground === false`); keep everything else — background, queued,
319
+ * scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground`
320
+ * record flag rather than the UI-only `invocation` snapshot (which only the
321
+ * Agent-tool path sets), and excluding rather than allow-listing, means
322
+ * only proven-foreground runs drop out — nothing else silently vanishes.
323
+ * - `all`: every agent.
324
+ */
325
+ private widgetAgents() {
326
+ const all = this.manager.listAgents().filter(isTopLevelAgent);
327
+ switch (this.mode()) {
328
+ case "off": return [];
329
+ case "background": return all.filter(a => a.isBackground !== false);
330
+ default: return all;
331
+ }
332
+ }
333
+
334
+ /** Set the UI context (grabbed from first tool execution). */
335
+ setUICtx(ctx: UICtx) {
336
+ if (ctx !== this.uiCtx) {
337
+ // UICtx changed — the widget registered on the old context is gone.
338
+ // Force re-registration on next update().
339
+ this.uiCtx = ctx;
340
+ this.widgetRegistered = false;
341
+ this.tui = undefined;
342
+ this.lastStatusText = undefined;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Called on each new turn (tool_execution_start).
348
+ * Ages finished agents and clears those that have lingered long enough.
349
+ */
350
+ onTurnStart() {
351
+ // Age all finished agents
352
+ for (const [id, age] of this.finishedTurnAge) {
353
+ this.finishedTurnAge.set(id, age + 1);
354
+ }
355
+ // Trigger a widget refresh (will filter out expired agents)
356
+ this.update();
357
+ }
358
+
359
+ /** Ensure the widget update timer is running. */
360
+ ensureTimer() {
361
+ if (!this.widgetInterval) {
362
+ this.widgetInterval = this.clock.setInterval(() => this.update(), 80);
363
+ }
364
+ }
365
+
366
+ /** Check if a finished agent should still be shown in the widget. */
367
+ private shouldShowFinished(agentId: string, status: string): boolean {
368
+ const age = this.finishedTurnAge.get(agentId) ?? 0;
369
+ const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
370
+ return age < maxAge;
371
+ }
372
+
373
+ /** Record an agent as finished (call when agent completes). */
374
+ markFinished(agentId: string) {
375
+ if (!this.finishedTurnAge.has(agentId)) {
376
+ this.finishedTurnAge.set(agentId, 0);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Drop an agent's finished-age (call when a settled agent starts running
382
+ * again, i.e. a background resume). markFinished only seeds an age it has not
383
+ * seen before, so a resumed agent would otherwise keep the age from its
384
+ * previous run — already past the linger limit, hiding the new run's
385
+ * completion line entirely.
386
+ */
387
+ markRunning(agentId: string) {
388
+ this.finishedTurnAge.delete(agentId);
389
+ }
390
+
391
+ /** Render a finished agent line. */
392
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
393
+ const modeLabel = getPromptModeLabel(a.type);
394
+ const duration = formatMs((a.completedAt ?? this.clock.now()) - a.startedAt);
395
+
396
+ let icon: string;
397
+ let statusText: string;
398
+ if (a.status === "completed") {
399
+ icon = theme.fg("success", "✓");
400
+ statusText = "";
401
+ } else if (a.status === "steered") {
402
+ icon = theme.fg("warning", "✓");
403
+ statusText = theme.fg("warning", " (turn limit)");
404
+ } else if (a.status === "stopped") {
405
+ icon = theme.fg("dim", "■");
406
+ statusText = theme.fg("dim", " stopped");
407
+ } else if (a.status === "error") {
408
+ icon = theme.fg("error", "✗");
409
+ const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
410
+ statusText = theme.fg("error", ` error${errMsg}`);
411
+ } else {
412
+ // aborted
413
+ icon = theme.fg("error", "✗");
414
+ statusText = theme.fg("warning", " aborted");
415
+ }
416
+
417
+ const parts: string[] = [];
418
+ const activity = this.agentActivity.get(a.id);
419
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
420
+ if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
421
+ // From the record, not the activity tracker: that entry is deleted the
422
+ // moment an agent finishes, and "what did it cost" is a question asked
423
+ // about finished agents.
424
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
425
+ if (costText) parts.push(costText);
426
+ parts.push(duration);
427
+
428
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
429
+ return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
430
+ }
431
+
432
+ /**
433
+ * Render the widget content. Called from the registered widget's render() callback,
434
+ * reading live state each time instead of capturing it in a closure.
435
+ */
436
+ private renderWidget(tui: any, theme: Theme): string[] {
437
+ const allAgents = this.widgetAgents();
438
+ const running = allAgents.filter(a => a.status === "running");
439
+ const queued = allAgents.filter(a => a.status === "queued");
440
+ const finished = allAgents.filter(a =>
441
+ a.status !== "running" && a.status !== "queued" && a.completedAt
442
+ && this.shouldShowFinished(a.id, a.status),
443
+ );
444
+
445
+ const hasActive = running.length > 0 || queued.length > 0;
446
+ const hasFinished = finished.length > 0;
447
+
448
+ // Nothing to show — return empty (widget will be unregistered by update())
449
+ if (!hasActive && !hasFinished) return [];
450
+
451
+ const w = tui.terminal.columns;
452
+ const truncate = (line: string) => truncateToWidth(line, w);
453
+ const headingColor = hasActive ? "accent" : "dim";
454
+ const headingIcon = hasActive ? "●" : "○";
455
+ const frame = SPINNER[this.widgetFrame % SPINNER.length];
456
+
457
+ // Build sections separately for overflow-aware assembly.
458
+ // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
459
+
460
+ const finishedLines: string[] = [];
461
+ for (const a of finished) {
462
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
463
+ }
464
+
465
+ const runningLines: string[][] = []; // each entry is [header, activity]
466
+ for (const a of running) {
467
+ const modeLabel = getPromptModeLabel(a.type);
468
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
469
+ const elapsed = formatMs(this.clock.now() - a.startedAt);
470
+
471
+ const bg = this.agentActivity.get(a.id);
472
+ const toolUses = bg?.toolUses ?? a.toolUses;
473
+ // Spend comes from the record, never from the activity tracker: the record
474
+ // is the one that survives the agent finishing, and the one nested-tools
475
+ // folds a hidden child's spend into. Reading the tracker while an agent
476
+ // runs and the record once it stops made the figure jump at completion.
477
+ const tokens = getLifetimeTotal(a.lifetimeUsage);
478
+ const contextPercent = getSessionContextPercent(bg?.session);
479
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
480
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
481
+
482
+ const parts: string[] = [];
483
+ if (this.showModel()) {
484
+ // Leading, and paired: a thinking level means nothing without the model
485
+ // it applies to. The tag is taken from buildInvocationTags rather than
486
+ // rebuilt so the "(asked X)" annotation survives.
487
+ const { modelName, tags } = buildInvocationTags(a.invocation);
488
+ if (modelName) parts.push(modelName);
489
+ const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
490
+ if (thinkingTag) parts.push(thinkingTag);
491
+ }
492
+ if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
493
+ if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
494
+ if (tokenText) parts.push(tokenText);
495
+ if (costText) parts.push(costText);
496
+ parts.push(elapsed);
497
+ const statsText = parts.join(" · ");
498
+
499
+ const activity = bg ? describeActivity(bg.activeTools, bg.responseText, bg.modelCall, this.clock.now()) : "thinking…";
500
+
501
+ runningLines.push([
502
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
503
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
504
+ ]);
505
+ }
506
+
507
+ const queuedLine = queued.length > 0
508
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
509
+ : undefined;
510
+
511
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
512
+ const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
513
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
514
+
515
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
516
+
517
+ if (totalBody <= maxBody) {
518
+ // Everything fits — add all lines and fix up connectors for the last item.
519
+ lines.push(...finishedLines);
520
+ for (const pair of runningLines) lines.push(...pair);
521
+ if (queuedLine) lines.push(queuedLine);
522
+
523
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
524
+ if (lines.length > 1) {
525
+ const last = lines.length - 1;
526
+ lines[last] = lines[last].replace("├─", "└─");
527
+ // If last item is a running agent activity line, fix indent of that line
528
+ // and fix the header line above it.
529
+ if (runningLines.length > 0 && !queuedLine) {
530
+ // The last two lines are the last running agent's header + activity.
531
+ if (last >= 2) {
532
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
533
+ lines[last] = lines[last].replace("│ ", " ");
534
+ }
535
+ }
536
+ }
537
+ } else {
538
+ // Overflow — prioritize: running > queued > finished.
539
+ // Reserve 1 line for overflow indicator.
540
+ let budget = maxBody - 1;
541
+ let hiddenRunning = 0;
542
+ let hiddenFinished = 0;
543
+
544
+ // Reserve the queued line's row up front. It is a single summary of N
545
+ // waiting agents, so it cannot be folded into the "+N more" count (which
546
+ // is denominated in agents) without either under-reporting it as 1 or
547
+ // inflating the total with agents that were never getting their own rows.
548
+ // Reserving costs at most one running agent — which IS counted below —
549
+ // and makes the drop unreachable. It matters most exactly when it used to
550
+ // vanish: the pool is saturated and the queue is what the user needs to see.
551
+ const queuedReserve = queuedLine ? 1 : 0;
552
+ budget -= queuedReserve;
553
+
554
+ // 1. Running agents (2 lines each)
555
+ for (const pair of runningLines) {
556
+ if (budget >= 2) {
557
+ lines.push(...pair);
558
+ budget -= 2;
559
+ } else {
560
+ hiddenRunning++;
561
+ }
562
+ }
563
+
564
+ // 2. Queued line (always fits — its row was reserved above)
565
+ if (queuedLine) {
566
+ budget += queuedReserve;
567
+ lines.push(queuedLine);
568
+ budget--;
569
+ }
570
+
571
+ // 3. Finished agents
572
+ for (const fl of finishedLines) {
573
+ if (budget >= 1) {
574
+ lines.push(fl);
575
+ budget--;
576
+ } else {
577
+ hiddenFinished++;
578
+ }
579
+ }
580
+
581
+ // Overflow summary
582
+ const overflowParts: string[] = [];
583
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
584
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
585
+ const overflowText = overflowParts.join(", ");
586
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
587
+ );
588
+ }
589
+
590
+ return lines;
591
+ }
592
+
593
+ /** Force an immediate widget update. */
594
+ update() {
595
+ if (!this.uiCtx) return;
596
+ const allAgents = this.widgetAgents();
597
+
598
+ // Lightweight existence checks — full categorization happens in renderWidget()
599
+ let runningCount = 0;
600
+ let queuedCount = 0;
601
+ let hasFinished = false;
602
+ for (const a of allAgents) {
603
+ if (a.status === "running") { runningCount++; }
604
+ else if (a.status === "queued") { queuedCount++; }
605
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
606
+ }
607
+ const hasActive = runningCount > 0 || queuedCount > 0;
608
+
609
+ // Nothing to show — clear widget
610
+ if (!hasActive && !hasFinished) {
611
+ if (this.widgetRegistered) {
612
+ this.uiCtx.setWidget("agents", undefined);
613
+ this.widgetRegistered = false;
614
+ this.tui = undefined;
615
+ }
616
+ if (this.lastStatusText !== undefined) {
617
+ this.uiCtx.setStatus("subagents", undefined);
618
+ this.lastStatusText = undefined;
619
+ }
620
+ if (this.widgetInterval) { this.widgetInterval.cancel(); this.widgetInterval = undefined; }
621
+ // Clean up stale entries
622
+ for (const [id] of this.finishedTurnAge) {
623
+ if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
624
+ }
625
+ return;
626
+ }
627
+
628
+ // Status bar — only call setStatus when the text actually changes
629
+ let newStatusText: string | undefined;
630
+ if (hasActive) {
631
+ const statusParts: string[] = [];
632
+ if (runningCount > 0) statusParts.push(`${runningCount} running`);
633
+ if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
634
+ const total = runningCount + queuedCount;
635
+ newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
636
+ }
637
+ if (newStatusText !== this.lastStatusText) {
638
+ this.uiCtx.setStatus("subagents", newStatusText);
639
+ this.lastStatusText = newStatusText;
640
+ }
641
+
642
+ this.widgetFrame++;
643
+
644
+ // Register widget callback once; subsequent updates use requestRender()
645
+ // which re-invokes render() without replacing the component (avoids layout thrashing).
646
+ if (!this.widgetRegistered) {
647
+ this.uiCtx.setWidget("agents", (tui, theme) => {
648
+ this.tui = tui;
649
+ const component = {
650
+ render: () => this.renderWidget(tui, theme),
651
+ invalidate: () => {
652
+ // Theme changed — force re-registration so factory captures fresh theme.
653
+ this.widgetRegistered = false;
654
+ this.tui = undefined;
655
+ },
656
+ // Core disposes every extension widget on /reload and when plan mode
657
+ // is entered or left. Without this the flag stayed true, update() only
658
+ // ever asked a dead component to re-render, and the widget vanished.
659
+ // Only the live component may reset it: a replaced one is disposed
660
+ // after its successor registered.
661
+ dispose: () => {
662
+ if (this.component !== component) return;
663
+ this.component = undefined;
664
+ this.widgetRegistered = false;
665
+ this.tui = undefined;
666
+ },
667
+ };
668
+ this.component = component;
669
+ return component;
670
+ }, { placement: "aboveEditor" });
671
+ this.widgetRegistered = true;
672
+ } else {
673
+ // Widget already registered — just request a re-render of existing components.
674
+ this.tui?.requestRender();
675
+ }
676
+ }
677
+
678
+ dispose() {
679
+ if (this.widgetInterval) {
680
+ this.widgetInterval.cancel();
681
+ this.widgetInterval = undefined;
682
+ }
683
+ if (this.uiCtx) {
684
+ this.uiCtx.setWidget("agents", undefined);
685
+ this.uiCtx.setStatus("subagents", undefined);
686
+ }
687
+ this.widgetRegistered = false;
688
+ this.component = undefined;
689
+ this.tui = undefined;
690
+ this.lastStatusText = undefined;
691
+ }
692
+ }