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

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