@xynogen/pix-subagent 0.1.0

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/types.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * types.ts — Type definitions for the pix-subagent system.
3
+ *
4
+ * Ported from tintinweb/pi-subagents (MIT) with trimmed deferred fields:
5
+ * memory, isolation, worktree, joinMode, scheduling.
6
+ */
7
+
8
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
9
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
10
+ import type { LifetimeUsage } from "./usage.ts";
11
+
12
+ export type { LifetimeUsage, ThinkingLevel };
13
+
14
+ /** Agent type: any string name (built-in defaults or user-defined). */
15
+ export type SubagentType = string;
16
+
17
+ /** Names of the three embedded default agents. */
18
+ export const DEFAULT_AGENT_NAMES = [
19
+ "general-purpose",
20
+ "Explore",
21
+ "Plan",
22
+ ] as const;
23
+
24
+ /** Unified agent configuration — used for both default and user-defined agents. */
25
+ export interface AgentConfig {
26
+ name: string;
27
+ displayName?: string;
28
+ description: string;
29
+ builtinToolNames?: string[];
30
+ /** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"]. */
31
+ extSelectors?: string[];
32
+ /** Tool denylist — removed even if builtinToolNames or extensions include them. */
33
+ disallowedTools?: string[];
34
+ /** true = inherit all, string[] = only listed, false = none */
35
+ extensions: true | string[] | false;
36
+ /** Extension-name denylist applied after the extensions include set. Exclude wins. */
37
+ excludeExtensions?: string[];
38
+ /** true = inherit all, string[] = only listed, false = none */
39
+ skills: true | string[] | false;
40
+ model?: string;
41
+ thinking?: ThinkingLevel;
42
+ maxTurns?: number;
43
+ systemPrompt: string;
44
+ promptMode: "replace" | "append";
45
+ /** Default for spawn: fork parent conversation. undefined = caller decides. */
46
+ inheritContext?: boolean;
47
+ /** Default for spawn: run in background. undefined = caller decides. */
48
+ runInBackground?: boolean;
49
+ /** Default for spawn: no extension tools. undefined = caller decides. */
50
+ isolated?: boolean;
51
+ /** true = this is an embedded default agent (informational) */
52
+ isDefault?: boolean;
53
+ /** false = agent is hidden from the registry */
54
+ enabled?: boolean;
55
+ /** Where this agent was loaded from */
56
+ source?: "default" | "project" | "global";
57
+ }
58
+
59
+ export interface AgentRecord {
60
+ id: string;
61
+ type: SubagentType;
62
+ description: string;
63
+ status:
64
+ | "queued"
65
+ | "running"
66
+ | "completed"
67
+ | "steered"
68
+ | "aborted"
69
+ | "stopped"
70
+ | "error";
71
+ result?: string;
72
+ error?: string;
73
+ toolUses: number;
74
+ startedAt: number;
75
+ completedAt?: number;
76
+ session?: AgentSession;
77
+ abortController?: AbortController;
78
+ promise?: Promise<string>;
79
+ /** Set when result was already consumed via agent_result — suppresses completion notification. */
80
+ resultConsumed?: boolean;
81
+ /** Steering messages queued before the session was ready. */
82
+ pendingSteers?: string[];
83
+ /** The tool_use_id from the original agent tool call. */
84
+ toolCallId?: string;
85
+ /**
86
+ * Lifetime usage breakdown, accumulated via message_end events.
87
+ * Survives compaction. Total = input + output + cacheWrite.
88
+ */
89
+ lifetimeUsage: LifetimeUsage;
90
+ /** Number of times this agent's session has compacted. */
91
+ compactionCount: number;
92
+ /** Resolved spawn params, captured for UI display. */
93
+ invocation?: AgentInvocation;
94
+ }
95
+
96
+ export interface AgentInvocation {
97
+ /** Short model label — ALWAYS set (the pix twist: shown in widget even when same as parent). */
98
+ modelName?: string;
99
+ thinking?: ThinkingLevel;
100
+ maxTurns?: number;
101
+ isolated?: boolean;
102
+ inheritContext?: boolean;
103
+ runInBackground?: boolean;
104
+ }
105
+
106
+ /** Details attached to custom notification messages for visual rendering. */
107
+ export interface NotificationDetails {
108
+ id: string;
109
+ description: string;
110
+ status: string;
111
+ /** Short model label — shown in notification stats (the pix twist). */
112
+ modelName?: string;
113
+ toolUses: number;
114
+ turnCount: number;
115
+ maxTurns?: number;
116
+ totalTokens: number;
117
+ durationMs: number;
118
+ error?: string;
119
+ resultPreview: string;
120
+ }
121
+
122
+ export interface EnvInfo {
123
+ isGitRepo: boolean;
124
+ branch: string;
125
+ platform: string;
126
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ui/notification.ts — subagent-notification custom message renderer.
3
+ *
4
+ * Renders background agent completion notifications as compact themed boxes:
5
+ * ✓ Explore [haiku] scout auth flow ↻5 · 3 tool uses · 12.4k · 8.3s
6
+ * ⎿ Found 3 references in src/middleware/…
7
+ *
8
+ * Pix twist: model name always in the stats line (even when same as parent).
9
+ * Ported from tintinweb/pi-subagents (MIT), individual-nudge path only
10
+ * (group-join deferred to v2).
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { Text } from "@earendil-works/pi-tui";
15
+ import { formatMs, formatTokens, formatTurns } from "../tools.ts";
16
+ import type { NotificationDetails } from "../types.ts";
17
+
18
+ /**
19
+ * Register the subagent-notification message renderer on the pi instance.
20
+ * Called once from index.ts during extension setup.
21
+ */
22
+ export function registerNotificationRenderer(pi: ExtensionAPI): void {
23
+ pi.registerMessageRenderer<NotificationDetails>(
24
+ "subagent-notification",
25
+ (message, { expanded }, theme) => {
26
+ const d = message.details;
27
+ if (!d) return undefined;
28
+
29
+ const isError =
30
+ d.status === "error" ||
31
+ d.status === "stopped" ||
32
+ d.status === "aborted";
33
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
34
+ const statusText = isError
35
+ ? d.status
36
+ : d.status === "steered"
37
+ ? "completed (steered)"
38
+ : "completed";
39
+
40
+ // Line 1: icon + description + status
41
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
42
+
43
+ // Line 2: stats — model always first (the pix twist)
44
+ const parts: string[] = [];
45
+ if (d.modelName) parts.push(theme.fg("muted", `[${d.modelName}]`));
46
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
47
+ if (d.toolUses > 0)
48
+ parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
49
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
50
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
51
+ if (parts.length) {
52
+ line +=
53
+ "\n " +
54
+ parts
55
+ .map((p) => theme.fg("dim", p))
56
+ .join(` ${theme.fg("dim", "·")} `);
57
+ }
58
+
59
+ // Line 3: result preview
60
+ if (expanded) {
61
+ const lines = d.resultPreview.split("\n").slice(0, 30);
62
+ for (const l of lines) line += `\n${theme.fg("dim", ` ${l}`)}`;
63
+ } else {
64
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
65
+ line += `\n ${theme.fg("dim", `⎿ ${preview}`)}`;
66
+ }
67
+
68
+ if (d.error && isError) {
69
+ line += `\n ${theme.fg("error", `⎿ ${d.error.slice(0, 100)}`)}`;
70
+ }
71
+
72
+ return new Text(line, 0, 0);
73
+ },
74
+ );
75
+ }
@@ -0,0 +1,490 @@
1
+ /**
2
+ * ui/widget.ts — Persistent above-editor widget showing running/completed agents.
3
+ *
4
+ * Pix twist vs tintinweb: model name is ALWAYS shown inline in the header line
5
+ * (e.g. "Explore [haiku]") even when it matches the parent model. Types and
6
+ * formatting helpers are re-exported from tools.ts to avoid circular deps.
7
+ *
8
+ * Ported from tintinweb/pi-subagents (MIT), adapted for pix-mono.
9
+ */
10
+
11
+ import { truncateToWidth } from "@earendil-works/pi-tui";
12
+ import type { AgentManager } from "../agent-manager.ts";
13
+ import { getConfig } from "../agent-types.ts";
14
+ import type { AgentActivity, AgentDetails, Theme } from "../tools.ts";
15
+ import { formatMs, formatTokens, formatTurns, SPINNER } from "../tools.ts";
16
+ import type { AgentInvocation, SubagentType } from "../types.ts";
17
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.ts";
18
+
19
+ export type { AgentActivity, AgentDetails, Theme };
20
+ export { formatMs, formatTokens, formatTurns, SPINNER };
21
+
22
+ // ── constants ─────────────────────────────────────────────────────────────────
23
+
24
+ const MAX_WIDGET_LINES = 12;
25
+
26
+ export const ERROR_STATUSES = new Set([
27
+ "error",
28
+ "aborted",
29
+ "steered",
30
+ "stopped",
31
+ ]);
32
+
33
+ const TOOL_DISPLAY: Record<string, string> = {
34
+ read: "reading",
35
+ bash: "running command",
36
+ edit: "editing",
37
+ write: "writing",
38
+ grep: "searching",
39
+ find: "finding files",
40
+ ls: "listing",
41
+ };
42
+
43
+ // ── UICtx type ────────────────────────────────────────────────────────────────
44
+
45
+ export type UICtx = {
46
+ setStatus(key: string, text: string | undefined): void;
47
+ setWidget(
48
+ key: string,
49
+ content:
50
+ | undefined
51
+ | ((
52
+ tui: unknown,
53
+ theme: Theme,
54
+ ) => { render(): string[]; invalidate(): void }),
55
+ options?: { placement?: "aboveEditor" | "belowEditor" },
56
+ ): void;
57
+ };
58
+
59
+ // ── helpers ───────────────────────────────────────────────────────────────────
60
+
61
+ export function formatSessionTokens(
62
+ tokens: number,
63
+ percent: number | null,
64
+ theme: Theme,
65
+ compactions = 0,
66
+ ): string {
67
+ const tokenStr = formatTokens(tokens);
68
+ const annot: string[] = [];
69
+ if (percent !== null) {
70
+ const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
71
+ annot.push(theme.fg(color, `${Math.round(percent)}%`));
72
+ }
73
+ if (compactions > 0) annot.push(theme.fg("dim", `⇊${compactions}`));
74
+ if (annot.length === 0) return tokenStr;
75
+ return `${tokenStr} (${annot.join(" · ")})`;
76
+ }
77
+
78
+ export function getDisplayName(type: SubagentType): string {
79
+ return getConfig(type).displayName;
80
+ }
81
+
82
+ export function getPromptModeLabel(type: SubagentType): string | undefined {
83
+ return getConfig(type).promptMode === "append" ? "twin" : undefined;
84
+ }
85
+
86
+ export function buildInvocationTags(invocation: AgentInvocation | undefined): {
87
+ modelName?: string;
88
+ tags: string[];
89
+ } {
90
+ const tags: string[] = [];
91
+ if (!invocation) return { tags };
92
+ if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
93
+ if (invocation.isolated) tags.push("isolated");
94
+ if (invocation.inheritContext) tags.push("inherit ctx");
95
+ if (invocation.runInBackground) tags.push("bg");
96
+ if (invocation.maxTurns != null) tags.push(`max: ${invocation.maxTurns}`);
97
+ return { modelName: invocation.modelName, tags };
98
+ }
99
+
100
+ function truncateLine(text: string, len = 60): string {
101
+ const line =
102
+ text
103
+ .split("\n")
104
+ .find((l) => l.trim())
105
+ ?.trim() ?? "";
106
+ if (line.length <= len) return line;
107
+ return `${line.slice(0, len)}…`;
108
+ }
109
+
110
+ export function describeActivity(
111
+ activeTools: Map<string, string>,
112
+ responseText?: string,
113
+ ): string {
114
+ if (activeTools.size > 0) {
115
+ const groups = new Map<string, number>();
116
+ for (const toolName of activeTools.values()) {
117
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
118
+ groups.set(action, (groups.get(action) ?? 0) + 1);
119
+ }
120
+ const parts: string[] = [];
121
+ for (const [action, count] of groups) {
122
+ parts.push(count > 1 ? `${action} ${count}×` : action);
123
+ }
124
+ return `${parts.join(", ")}…`;
125
+ }
126
+ if (responseText?.trim()) return truncateLine(responseText);
127
+ return "thinking…";
128
+ }
129
+
130
+ // ── AgentWidget ───────────────────────────────────────────────────────────────
131
+
132
+ export class AgentWidget {
133
+ private uiCtx: UICtx | undefined;
134
+ private widgetFrame = 0;
135
+ private widgetInterval: ReturnType<typeof setInterval> | undefined;
136
+ private finishedTurnAge = new Map<string, number>();
137
+ private static readonly ERROR_LINGER_TURNS = 2;
138
+ private widgetRegistered = false;
139
+ private tui: unknown = undefined;
140
+ private lastStatusText: string | undefined;
141
+
142
+ constructor(
143
+ private manager: AgentManager,
144
+ private agentActivity: Map<string, AgentActivity>,
145
+ ) {}
146
+
147
+ setUICtx(ctx: UICtx) {
148
+ if (ctx !== this.uiCtx) {
149
+ this.uiCtx = ctx;
150
+ this.widgetRegistered = false;
151
+ this.tui = undefined;
152
+ this.lastStatusText = undefined;
153
+ }
154
+ }
155
+
156
+ onTurnStart() {
157
+ for (const [id, age] of this.finishedTurnAge) {
158
+ this.finishedTurnAge.set(id, age + 1);
159
+ }
160
+ this.update();
161
+ }
162
+
163
+ ensureTimer() {
164
+ if (!this.widgetInterval) {
165
+ this.widgetInterval = setInterval(() => this.update(), 80);
166
+ }
167
+ }
168
+
169
+ private shouldShowFinished(agentId: string, status: string): boolean {
170
+ const age = this.finishedTurnAge.get(agentId) ?? 0;
171
+ const maxAge = ERROR_STATUSES.has(status)
172
+ ? AgentWidget.ERROR_LINGER_TURNS
173
+ : 1;
174
+ return age < maxAge;
175
+ }
176
+
177
+ markFinished(agentId: string) {
178
+ if (!this.finishedTurnAge.has(agentId)) {
179
+ this.finishedTurnAge.set(agentId, 0);
180
+ }
181
+ }
182
+
183
+ private renderFinishedLine(
184
+ a: {
185
+ id: string;
186
+ type: SubagentType;
187
+ status: string;
188
+ description: string;
189
+ toolUses: number;
190
+ startedAt: number;
191
+ completedAt?: number;
192
+ error?: string;
193
+ invocation?: AgentInvocation;
194
+ },
195
+ theme: Theme,
196
+ ): string {
197
+ const name = getDisplayName(a.type);
198
+ const modeLabel = getPromptModeLabel(a.type);
199
+ const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
200
+
201
+ // model label (the pix twist — always shown)
202
+ const modelLabel = a.invocation?.modelName
203
+ ? ` ${theme.fg("muted", `[${a.invocation.modelName}]`)}`
204
+ : "";
205
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
206
+
207
+ let icon: string;
208
+ let statusText: string;
209
+ if (a.status === "completed") {
210
+ icon = theme.fg("success", "✓");
211
+ statusText = "";
212
+ } else if (a.status === "steered") {
213
+ icon = theme.fg("warning", "✓");
214
+ statusText = theme.fg("warning", " (turn limit)");
215
+ } else if (a.status === "stopped") {
216
+ icon = theme.fg("dim", "■");
217
+ statusText = theme.fg("dim", " stopped");
218
+ } else if (a.status === "error") {
219
+ icon = theme.fg("error", "✗");
220
+ const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
221
+ statusText = theme.fg("error", ` error${errMsg}`);
222
+ } else {
223
+ icon = theme.fg("error", "✗");
224
+ statusText = theme.fg("warning", " aborted");
225
+ }
226
+
227
+ const parts: string[] = [];
228
+ const activity = this.agentActivity.get(a.id);
229
+ if (activity)
230
+ parts.push(formatTurns(activity.turnCount, activity.maxTurns));
231
+ if (a.toolUses > 0)
232
+ parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
233
+ parts.push(duration);
234
+
235
+ return `${icon} ${theme.fg("dim", name)}${modelLabel}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
236
+ }
237
+
238
+ private renderWidget(
239
+ tui: { terminal: { columns: number }; requestRender?: () => void },
240
+ theme: Theme,
241
+ ): string[] {
242
+ const allAgents = this.manager.listAgents();
243
+ const running = allAgents.filter((a) => a.status === "running");
244
+ const queued = allAgents.filter((a) => a.status === "queued");
245
+ const finished = allAgents.filter(
246
+ (a) =>
247
+ a.status !== "running" &&
248
+ a.status !== "queued" &&
249
+ a.completedAt &&
250
+ this.shouldShowFinished(a.id, a.status),
251
+ );
252
+
253
+ if (running.length === 0 && queued.length === 0 && finished.length === 0)
254
+ return [];
255
+
256
+ const w = tui.terminal.columns;
257
+ const truncate = (line: string) => truncateToWidth(line, w);
258
+ const hasActive = running.length > 0 || queued.length > 0;
259
+ const headingColor = hasActive ? "accent" : "dim";
260
+ const headingIcon = hasActive ? "●" : "○";
261
+ const frame = SPINNER[this.widgetFrame % SPINNER.length];
262
+
263
+ const finishedLines: string[] = [];
264
+ for (const a of finished) {
265
+ finishedLines.push(
266
+ truncate(
267
+ `${theme.fg("dim", "├─")} ${this.renderFinishedLine(a, theme)}`,
268
+ ),
269
+ );
270
+ }
271
+
272
+ const runningLines: string[][] = [];
273
+ for (const a of running) {
274
+ const name = getDisplayName(a.type);
275
+ const modeLabel = getPromptModeLabel(a.type);
276
+ // model label inline (the pix twist)
277
+ const modelLabel = a.invocation?.modelName
278
+ ? ` ${theme.fg("muted", `[${a.invocation.modelName}]`)}`
279
+ : "";
280
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
281
+ const elapsed = formatMs(Date.now() - a.startedAt);
282
+
283
+ const bg = this.agentActivity.get(a.id);
284
+ const toolUses = bg?.toolUses ?? a.toolUses;
285
+ const tokens = getLifetimeTotal(bg?.lifetimeUsage);
286
+ const contextPercent = bg?.session
287
+ ? getSessionContextPercent(
288
+ bg.session as Parameters<typeof getSessionContextPercent>[0],
289
+ )
290
+ : null;
291
+ const tokenText =
292
+ tokens > 0
293
+ ? formatSessionTokens(
294
+ tokens,
295
+ contextPercent,
296
+ theme,
297
+ a.compactionCount,
298
+ )
299
+ : "";
300
+
301
+ const parts: string[] = [];
302
+ if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
303
+ if (toolUses > 0)
304
+ parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
305
+ if (tokenText) parts.push(tokenText);
306
+ parts.push(elapsed);
307
+ const statsText = parts.join(" · ");
308
+
309
+ const activity = bg
310
+ ? describeActivity(bg.activeTools, bg.responseText)
311
+ : "thinking…";
312
+
313
+ runningLines.push([
314
+ truncate(
315
+ theme.fg("dim", "├─") +
316
+ ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modelLabel}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
317
+ ),
318
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
319
+ ]);
320
+ }
321
+
322
+ const queuedLine =
323
+ queued.length > 0
324
+ ? truncate(
325
+ theme.fg("dim", "├─") +
326
+ ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`,
327
+ )
328
+ : undefined;
329
+
330
+ const maxBody = MAX_WIDGET_LINES - 1;
331
+ const totalBody =
332
+ finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
333
+
334
+ const lines: string[] = [
335
+ truncate(
336
+ theme.fg(headingColor, headingIcon) +
337
+ " " +
338
+ theme.fg(headingColor, "Agents"),
339
+ ),
340
+ ];
341
+
342
+ if (totalBody <= maxBody) {
343
+ lines.push(...finishedLines);
344
+ for (const pair of runningLines) lines.push(...pair);
345
+ if (queuedLine) lines.push(queuedLine);
346
+
347
+ // Fix last connector ├─ → └─
348
+ if (lines.length > 1) {
349
+ const last = lines.length - 1;
350
+ lines[last] = lines[last].replace("├─", "└─");
351
+ if (runningLines.length > 0 && !queuedLine && last >= 2) {
352
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
353
+ lines[last] = lines[last].replace("│ ", " ");
354
+ }
355
+ }
356
+ } else {
357
+ let budget = maxBody - 1;
358
+ let hiddenRunning = 0;
359
+ let hiddenFinished = 0;
360
+
361
+ for (const pair of runningLines) {
362
+ if (budget >= 2) {
363
+ lines.push(...pair);
364
+ budget -= 2;
365
+ } else {
366
+ hiddenRunning++;
367
+ }
368
+ }
369
+ if (queuedLine && budget >= 1) {
370
+ lines.push(queuedLine);
371
+ budget--;
372
+ }
373
+ for (const fl of finishedLines) {
374
+ if (budget >= 1) {
375
+ lines.push(fl);
376
+ budget--;
377
+ } else {
378
+ hiddenFinished++;
379
+ }
380
+ }
381
+
382
+ const overflowParts: string[] = [];
383
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
384
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
385
+ lines.push(
386
+ truncate(
387
+ theme.fg("dim", "└─") +
388
+ ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowParts.join(", ")})`)}`,
389
+ ),
390
+ );
391
+ }
392
+
393
+ return lines;
394
+ }
395
+
396
+ update() {
397
+ if (!this.uiCtx) return;
398
+ const allAgents = this.manager.listAgents();
399
+
400
+ let runningCount = 0;
401
+ let queuedCount = 0;
402
+ let hasFinished = false;
403
+ for (const a of allAgents) {
404
+ if (a.status === "running") runningCount++;
405
+ else if (a.status === "queued") queuedCount++;
406
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status))
407
+ hasFinished = true;
408
+ }
409
+ const hasActive = runningCount > 0 || queuedCount > 0;
410
+
411
+ if (!hasActive && !hasFinished) {
412
+ if (this.widgetRegistered) {
413
+ this.uiCtx.setWidget("agents", undefined);
414
+ this.widgetRegistered = false;
415
+ this.tui = undefined;
416
+ }
417
+ if (this.lastStatusText !== undefined) {
418
+ this.uiCtx.setStatus("subagents", undefined);
419
+ this.lastStatusText = undefined;
420
+ }
421
+ if (this.widgetInterval) {
422
+ clearInterval(this.widgetInterval);
423
+ this.widgetInterval = undefined;
424
+ }
425
+ for (const [id] of this.finishedTurnAge) {
426
+ if (!allAgents.some((a) => a.id === id))
427
+ this.finishedTurnAge.delete(id);
428
+ }
429
+ return;
430
+ }
431
+
432
+ let newStatusText: string | undefined;
433
+ if (hasActive) {
434
+ const parts: string[] = [];
435
+ if (runningCount > 0) parts.push(`${runningCount} running`);
436
+ if (queuedCount > 0) parts.push(`${queuedCount} queued`);
437
+ const total = runningCount + queuedCount;
438
+ newStatusText = `${parts.join(", ")} agent${total === 1 ? "" : "s"}`;
439
+ }
440
+ if (newStatusText !== this.lastStatusText) {
441
+ this.uiCtx.setStatus("subagents", newStatusText);
442
+ this.lastStatusText = newStatusText;
443
+ }
444
+
445
+ this.widgetFrame++;
446
+
447
+ if (!this.widgetRegistered) {
448
+ this.uiCtx.setWidget(
449
+ "agents",
450
+ (tui, theme) => {
451
+ this.tui = tui;
452
+ return {
453
+ render: () =>
454
+ this.renderWidget(
455
+ tui as {
456
+ terminal: { columns: number };
457
+ requestRender?: () => void;
458
+ },
459
+ theme,
460
+ ),
461
+ invalidate: () => {
462
+ this.widgetRegistered = false;
463
+ this.tui = undefined;
464
+ },
465
+ };
466
+ },
467
+ { placement: "aboveEditor" },
468
+ );
469
+ this.widgetRegistered = true;
470
+ } else {
471
+ (
472
+ this.tui as { requestRender?: () => void } | undefined
473
+ )?.requestRender?.();
474
+ }
475
+ }
476
+
477
+ dispose() {
478
+ if (this.widgetInterval) {
479
+ clearInterval(this.widgetInterval);
480
+ this.widgetInterval = undefined;
481
+ }
482
+ if (this.uiCtx) {
483
+ this.uiCtx.setWidget("agents", undefined);
484
+ this.uiCtx.setStatus("subagents", undefined);
485
+ }
486
+ this.widgetRegistered = false;
487
+ this.tui = undefined;
488
+ this.lastStatusText = undefined;
489
+ }
490
+ }