@ferris1225/pi-subagents 4.2.7 → 4.2.8

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/status.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Persistent footer status line for sub-agent progress.
3
+ *
4
+ * The above-editor widget is the detailed surface, but it only pays off when
5
+ * the user is looking at it: a parent turn that dispatches children and then
6
+ * keeps streaming leaves no trace that anything is still running. The footer
7
+ * is always visible, so one compact roll-up there answers "is anything still
8
+ * working?" without opening the widget or querying runs.
9
+ *
10
+ * It stays deliberately count-only — no elapsed time, no per-run detail — so
11
+ * it carries no time-varying field and needs no refresh timer: every monitor
12
+ * transition already pushes an update. Queued runs keep their wait word
13
+ * (`queued` for a process slot vs `repo lane` for write serialization) because
14
+ * a capacity wait and a serialization wait call for different reactions.
15
+ */
16
+
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { isRunActiveStatus, monitor, statusLabel, type RunView } from "./monitor.ts";
19
+ import { waitWord } from "./widget.ts";
20
+
21
+ export const SUBAGENTS_STATUS_ID = "pi-subagents";
22
+
23
+ const SEPARATOR = " · ";
24
+
25
+ /** Display order of the count segments: live work first, settled last. */
26
+ const SEGMENT_ORDER = ["running", "interrupting", "starting", "queued", "repo lane", "done", "stopped"];
27
+
28
+ type StatusContext = Pick<ExtensionContext, "hasUI" | "ui">;
29
+
30
+ /** One-line roll-up of the runs worth reporting — active ones plus the runs
31
+ * that settled during this turn — or undefined when there is nothing to say
32
+ * and the status entry should disappear instead of showing zeros. */
33
+ export function formatRunStatusLine(runs: readonly RunView[]): string | undefined {
34
+ const counts = new Map<string, number>();
35
+ for (const run of runs) {
36
+ const word = run.status === "queued"
37
+ ? waitWord(run)
38
+ : isRunActiveStatus(run.status) || run.status === "done" || run.status === "failed"
39
+ ? statusLabel(run.status)
40
+ : undefined;
41
+ if (word) counts.set(word, (counts.get(word) ?? 0) + 1);
42
+ }
43
+ const segments = SEGMENT_ORDER.filter((word) => counts.has(word)).map((word) => `${counts.get(word)} ${word}`);
44
+ return segments.length > 0 ? `subagents ${segments.join(SEPARATOR)}` : undefined;
45
+ }
46
+
47
+ /** Subscription of the currently installed status line; module-level because
48
+ * the monitor it follows is itself a singleton. */
49
+ let unsubscribe: (() => void) | undefined;
50
+
51
+ /** Install the footer status line. Not installed without a UI host (print and
52
+ * json modes), where setStatus has nowhere to render. */
53
+ export function installActiveRunsStatus(ctx: StatusContext): void {
54
+ if (!ctx.hasUI) return;
55
+ // A second install must not orphan the first subscription.
56
+ clearActiveRunsStatus(ctx);
57
+ const render = (): void => ctx.ui.setStatus(SUBAGENTS_STATUS_ID, formatRunStatusLine(monitor.getRuns()));
58
+ unsubscribe = monitor.subscribe(render);
59
+ render();
60
+ }
61
+
62
+ export function clearActiveRunsStatus(ctx: StatusContext): void {
63
+ unsubscribe?.();
64
+ unsubscribe = undefined;
65
+ if (ctx.hasUI) ctx.ui.setStatus(SUBAGENTS_STATUS_ID, undefined);
66
+ }
package/src/widget.ts CHANGED
@@ -1,266 +1,268 @@
1
- /**
2
- * Compact, glanceable active-run widget for interactive Pi sessions.
3
- *
4
- * Layout contract:
5
- * - Aligned identity columns: `icon #id agent` pad to the widest displayed
6
- * id and agent so every label starts at the same column; a resumed thread
7
- * carries a dim `↻` inside the agent column.
8
- * - A live run owns two lines. Line 1 is what it is: identity, task label,
9
- * then the telemetry flow (`provider/model`, token flow in the pi-footer
10
- * vocabulary `↑in ↓out R/W cache`, cost, wait state, seconds-precision
11
- * elapsed). Line 2 is what it is doing right now: the live activity, dim,
12
- * indented under the label column behind a `↳` marker.
13
- * - Telemetry drops leftmost-first under width pressure (badge, wait, usage,
14
- * model); the elapsed survives every width.
15
- * - Queued rows say what they actually wait for ("queued" for a process slot,
16
- * "repo lane" for shared-writer serialization, "starting" while the child
17
- * process launches) instead of one catch-all "queued".
18
- */
19
-
20
- import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
21
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
- import {
23
- formatElapsed,
24
- formatTaskSummary,
25
- formatUsageTokens,
26
- isRunActiveStatus,
27
- monitor,
28
- shrinkRunLabel,
29
- statusIcon,
30
- type RunView,
31
- } from "./monitor.ts";
32
- import type { UsageStats } from "./rpc-run.ts";
33
-
34
- export const SUBAGENTS_WIDGET_ID = "pi-subagents";
35
-
36
- /** The TUI caps string-array widgets at this many lines; a factory widget owns
37
- * the same bound itself, or a wide parallel dispatch floods the editor area. */
38
- const MAX_WIDGET_LINES = 10;
39
-
40
- const SEPARATOR = " · ";
41
- /** Column gap between the identity block and the run's label. */
42
- const IDENTITY_GAP = " ";
43
- /** Marker introducing the live-activity second line of a running row. */
44
- const ACTIVITY_MARKER = "↳ ";
45
- /** Columns kept for left content before right-tail parts are dropped. */
46
- const LEFT_MIN_CONTENT = 8;
47
- /** Minimum useful width for a live-activity fragment. */
48
- const ACTIVITY_MIN_WIDTH = 6;
49
-
50
- /** Shared column widths so every visible row lines up. */
51
- interface ColumnLayout {
52
- /** Display width of the widest `#id` among rendered roots. */
53
- idWidth: number;
54
- /** Display width of the widest agent name (plus resume marker) among them. */
55
- agentWidth: number;
56
- }
57
-
58
- /** Join left content and telemetry inline — `left · telemetry` — so a
59
- * multi-line chain reads as one flowing sentence instead of leaving blank
60
- * padding across the width; the whole line truncates as a last resort. */
61
- function composeLine(left: string, tail: string, theme: Theme, width: number): string {
62
- if (!tail) return truncateToWidth(left, width, "…");
63
- const line = `${left}${theme.fg("dim", SEPARATOR)}${theme.fg("dim", tail)}`;
64
- return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
65
- }
66
-
67
- /** Short truthful wait word for a queued row, shown in the telemetry column. */
68
- function waitWord(run: Pick<RunView, "waitReason">): string {
69
- switch (run.waitReason) {
70
- case "repository-lane":
71
- return "repo lane";
72
- case "starting":
73
- return "starting";
74
- default:
75
- return "queued";
76
- }
77
- }
78
-
79
- /** Worktree-group badge shown on the row that owns the isolated worktree: the
80
- * short group identity plus its integration state, so a run visibly moves
81
- * through applying applied (or retained) and a continuation worktree (new
82
- * identity) is distinguishable from the original one. */
83
- function worktreeBadge(run: RunView): string {
84
- const id = run.worktreeId ?? "?";
85
- switch (run.integrationStatus) {
86
- case "finalizing":
87
- return `wt:${id} applying`;
88
- case "integrated":
89
- return `wt:${id} applied`;
90
- case "no_changes":
91
- return `wt:${id} clean`;
92
- case "retained":
93
- return `wt:${id} retained`;
94
- default:
95
- return `wt:${id}`;
96
- }
97
- }
98
-
99
- /** Drop lower-priority tail parts (leftmost first) until the tail fits. */
100
- function composeTail(parts: Array<string | undefined>, budget: number): string {
101
- const present = parts.filter((part): part is string => Boolean(part));
102
- while (present.length > 0 && visibleWidth(present.join(SEPARATOR)) > budget) present.shift();
103
- return present.join(SEPARATOR);
104
- }
105
-
106
- /** Marker text that shares the agent column so alignment survives resumes. */
107
- function agentColumnText(run: RunView): string {
108
- return run.continuationKind ? `${run.agent} ↻` : run.agent;
109
- }
110
-
111
- /** `icon #id agent` in fixed columns — never truncated. The id is
112
- * right-aligned and the agent column is padded so every label starts at the
113
- * same x; a resumed thread carries a dim `↻` inside the agent column. */
114
- function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): string {
115
- const icon = statusIcon(run.status, theme);
116
- const id = `#${run.id}`.padStart(layout.idWidth);
117
- const name = theme.fg("accent", theme.bold(run.agent));
118
- const resumed = run.continuationKind ? ` ${theme.fg("dim", "↻")}` : "";
119
- const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth(agentColumnText(run))));
120
- return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
121
- }
122
-
123
- /** One footer-style usage part: token flow plus accrued cost, dropped as a
124
- * unit before the model under width pressure. */
125
- function usagePart(usage: UsageStats | undefined): string | undefined {
126
- return [formatUsageTokens(usage), usage?.cost ? `$${usage.cost.toFixed(4)}` : undefined].filter(Boolean).join(" ") || undefined;
127
- }
128
-
129
- /** Telemetry tail parts of a run row: badge and wait word first (dropped first
130
- * under pressure), then the usage part, the model, and the always-surviving
131
- * elapsed. */
132
- function telemetryTailParts(run: RunView, now: number): Array<string | undefined> {
133
- // Queued rows omit the model (the route is re-resolved at actual start).
134
- // The full provider/model ref is kept "which provider served this run" is
135
- // exactly what a multi-provider session needs to see.
136
- const modelPart = run.status === "queued" || !run.model ? undefined : run.model;
137
- const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
138
- const wait = run.status === "queued" ? waitWord(run) : undefined;
139
- // Drop order under pressure: badge, wait word, usage, model; elapsed
140
- // survives every width the identity leaves room for.
141
- return [badge, wait, usagePart(run.usage), modelPart, formatElapsed(run, now) || undefined];
142
- }
143
-
144
- /** Two lines for a live run. Line 1 is what the run is: identity, task label,
145
- * then the telemetry flow (worktree badge, token flow, cost, provider/model,
146
- * wait state, elapsed). Line 2 is what it is doing right now: the live
147
- * activity, dim, indented under the label column behind a `↳` marker. The
148
- * label takes the full content budget on line 1; the identity and the elapsed
149
- * survive every width. */
150
- function primaryLine(
151
- run: RunView,
152
- theme: Theme,
153
- width: number,
154
- now: number,
155
- layout: ColumnLayout,
156
- ): string[] {
157
- const identity = identitySegment(run, theme, layout);
158
- const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
159
- const tail = composeTail(telemetryTailParts(run, now), tailBudget);
160
- const label = run.label ?? formatTaskSummary(run.task, 48);
161
- const contentBudget = width
162
- - visibleWidth(identity)
163
- - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
164
- - visibleWidth(IDENTITY_GAP);
165
- // The label is already fragment-extracted (runLabel); narrowing it keeps
166
- // its tail so a second squeeze never trades away the recognisable
167
- // filename, and no second head…tail ellipsis stacks on top of it.
168
- const content = label && contentBudget > 0 ? shrinkRunLabel(label, contentBudget) : "";
169
- const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
170
- const lines = [composeLine(left, tail, theme, width)];
171
-
172
- const activity = run.status === "running" || run.status === "interrupting"
173
- ? run.activity?.trim()
174
- : undefined;
175
- if (activity) {
176
- const indent = visibleWidth(identity) + visibleWidth(IDENTITY_GAP);
177
- const activityBudget = width - indent - visibleWidth(ACTIVITY_MARKER);
178
- if (activityBudget >= ACTIVITY_MIN_WIDTH) {
179
- lines.push(
180
- `${" ".repeat(indent)}${theme.fg("dim", `${ACTIVITY_MARKER}${formatTaskSummary(activity, activityBudget)}`)}`,
181
- );
182
- }
183
- }
184
- return lines;
185
- }
186
-
187
- /** Render active runs as compact per-run line groups: one two-line group per
188
- * run. All rows share one column layout. */
189
- export function formatActiveRunLines(
190
- runs: readonly RunView[],
191
- theme: Theme,
192
- width: number,
193
- now: number = Date.now(),
194
- ): string[] {
195
- const active = runs.filter((run) => isRunActiveStatus(run.status));
196
- const layout: ColumnLayout = {
197
- idWidth: Math.max(...active.map((run) => visibleWidth(`#${run.id}`)), 0),
198
- agentWidth: Math.max(...active.map((run) => visibleWidth(agentColumnText(run))), 0),
199
- };
200
- const lines: string[] = [];
201
- let shown = 0;
202
- for (const run of active) {
203
- // Reserve one line so a cut is always announced by the overflow marker.
204
- const remaining = MAX_WIDGET_LINES - 1 - lines.length;
205
- if (remaining <= 0) break;
206
- const group = primaryLine(run, theme, width, now, layout);
207
- lines.push(...group.slice(0, remaining));
208
- shown++;
209
- }
210
- const hidden = active.length - shown;
211
- if (hidden > 0) {
212
- lines.push(theme.fg("dim", `… +${hidden} more`));
213
- }
214
- return lines;
215
- }
216
-
217
- function hasActiveRun(): boolean {
218
- return monitor.getRuns().some((run) => isRunActiveStatus(run.status) && run.activeSince !== undefined);
219
- }
220
-
221
- /** Install the widget for one TUI session. Its timer exists only while at
222
- * least one active run is executing, and is disposed with the widget. */
223
- export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
224
- if (ctx.mode !== "tui") return;
225
- ctx.ui.setWidget(
226
- SUBAGENTS_WIDGET_ID,
227
- (tui, theme) => {
228
- let timer: ReturnType<typeof setInterval> | undefined;
229
- let disposed = false;
230
-
231
- const syncTimer = (): void => {
232
- if (disposed) return;
233
- if (hasActiveRun()) {
234
- if (timer) return;
235
- timer = setInterval(() => tui.requestRender(), 1_000);
236
- timer.unref?.();
237
- return;
238
- }
239
- if (timer) clearInterval(timer);
240
- timer = undefined;
241
- };
242
-
243
- const unsubscribe = monitor.subscribe(() => {
244
- syncTimer();
245
- tui.requestRender();
246
- });
247
- syncTimer();
248
-
249
- return {
250
- render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width, Date.now()),
251
- invalidate() {},
252
- dispose() {
253
- disposed = true;
254
- unsubscribe();
255
- if (timer) clearInterval(timer);
256
- timer = undefined;
257
- },
258
- };
259
- },
260
- { placement: "aboveEditor" },
261
- );
262
- }
263
-
264
- export function clearActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
265
- if (ctx.mode === "tui") ctx.ui.setWidget(SUBAGENTS_WIDGET_ID, undefined);
266
- }
1
+ /**
2
+ * Compact, glanceable active-run widget for interactive Pi sessions.
3
+ *
4
+ * Layout contract:
5
+ * - Aligned identity columns: `icon #id agent` pad to the widest displayed
6
+ * id and agent so every label starts at the same column; a resumed thread
7
+ * carries a dim `↻` inside the agent column.
8
+ * - A live run owns two lines. Line 1 is what it is: identity, task label,
9
+ * then the telemetry flow (`provider/model`, token flow in the pi-footer
10
+ * vocabulary `↑in ↓out R/W cache`, cost, wait state, seconds-precision
11
+ * elapsed). Line 2 is what it is doing right now: the live activity, dim,
12
+ * indented under the label column behind a `↳` marker.
13
+ * - Telemetry drops leftmost-first under width pressure (badge, wait, usage,
14
+ * model); the elapsed survives every width.
15
+ * - Queued rows say what they actually wait for ("queued" for a process slot,
16
+ * "repo lane" for shared-writer serialization, "starting" while the child
17
+ * process launches) instead of one catch-all "queued".
18
+ */
19
+
20
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
21
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
+ import {
23
+ formatElapsed,
24
+ formatTaskSummary,
25
+ formatUsageTokens,
26
+ isRunActiveStatus,
27
+ monitor,
28
+ shrinkRunLabel,
29
+ statusIcon,
30
+ type RunView,
31
+ } from "./monitor.ts";
32
+ import type { UsageStats } from "./rpc-run.ts";
33
+
34
+ export const SUBAGENTS_WIDGET_ID = "pi-subagents";
35
+
36
+ /** The TUI caps string-array widgets at this many lines; a factory widget owns
37
+ * the same bound itself, or a wide parallel dispatch floods the editor area. */
38
+ const MAX_WIDGET_LINES = 10;
39
+
40
+ const SEPARATOR = " · ";
41
+ /** Column gap between the identity block and the run's label. */
42
+ const IDENTITY_GAP = " ";
43
+ /** Marker introducing the live-activity second line of a running row. */
44
+ const ACTIVITY_MARKER = "↳ ";
45
+ /** Columns kept for left content before right-tail parts are dropped. */
46
+ const LEFT_MIN_CONTENT = 8;
47
+ /** Minimum useful width for a live-activity fragment. */
48
+ const ACTIVITY_MIN_WIDTH = 6;
49
+
50
+ /** Shared column widths so every visible row lines up. */
51
+ interface ColumnLayout {
52
+ /** Display width of the widest `#id` among rendered roots. */
53
+ idWidth: number;
54
+ /** Display width of the widest agent name (plus resume marker) among them. */
55
+ agentWidth: number;
56
+ }
57
+
58
+ /** Join left content and telemetry inline — `left · telemetry` — so a
59
+ * multi-line chain reads as one flowing sentence instead of leaving blank
60
+ * padding across the width; the whole line truncates as a last resort. */
61
+ function composeLine(left: string, tail: string, theme: Theme, width: number): string {
62
+ if (!tail) return truncateToWidth(left, width, "…");
63
+ const line = `${left}${theme.fg("dim", SEPARATOR)}${theme.fg("dim", tail)}`;
64
+ return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
65
+ }
66
+
67
+ /** Short truthful wait word for a queued row shown in the widget telemetry
68
+ * column and aggregated into the footer status line, so both surfaces name a
69
+ * wait the same way. */
70
+ export function waitWord(run: Pick<RunView, "waitReason">): string {
71
+ switch (run.waitReason) {
72
+ case "repository-lane":
73
+ return "repo lane";
74
+ case "starting":
75
+ return "starting";
76
+ default:
77
+ return "queued";
78
+ }
79
+ }
80
+
81
+ /** Worktree-group badge shown on the row that owns the isolated worktree: the
82
+ * short group identity plus its integration state, so a run visibly moves
83
+ * through applying → applied (or retained) and a continuation worktree (new
84
+ * identity) is distinguishable from the original one. */
85
+ function worktreeBadge(run: RunView): string {
86
+ const id = run.worktreeId ?? "?";
87
+ switch (run.integrationStatus) {
88
+ case "finalizing":
89
+ return `worktree:${id} applying`;
90
+ case "integrated":
91
+ return `worktree:${id} applied`;
92
+ case "no_changes":
93
+ return `worktree:${id} clean`;
94
+ case "retained":
95
+ return `worktree:${id} retained`;
96
+ default:
97
+ return `worktree:${id}`;
98
+ }
99
+ }
100
+
101
+ /** Drop lower-priority tail parts (leftmost first) until the tail fits. */
102
+ function composeTail(parts: Array<string | undefined>, budget: number): string {
103
+ const present = parts.filter((part): part is string => Boolean(part));
104
+ while (present.length > 0 && visibleWidth(present.join(SEPARATOR)) > budget) present.shift();
105
+ return present.join(SEPARATOR);
106
+ }
107
+
108
+ /** Marker text that shares the agent column so alignment survives resumes. */
109
+ function agentColumnText(run: RunView): string {
110
+ return run.continuationKind ? `${run.agent} ↻` : run.agent;
111
+ }
112
+
113
+ /** `icon #id agent` in fixed columns never truncated. The id is
114
+ * right-aligned and the agent column is padded so every label starts at the
115
+ * same x; a resumed thread carries a dim `↻` inside the agent column. */
116
+ function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): string {
117
+ const icon = statusIcon(run.status, theme);
118
+ const id = `#${run.id}`.padStart(layout.idWidth);
119
+ const name = theme.fg("accent", theme.bold(run.agent));
120
+ const resumed = run.continuationKind ? ` ${theme.fg("dim", "↻")}` : "";
121
+ const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth(agentColumnText(run))));
122
+ return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
123
+ }
124
+
125
+ /** One footer-style usage part: token flow plus accrued cost, dropped as a
126
+ * unit before the model under width pressure. */
127
+ function usagePart(usage: UsageStats | undefined): string | undefined {
128
+ return [formatUsageTokens(usage), usage?.cost ? `$${usage.cost.toFixed(4)}` : undefined].filter(Boolean).join(" ") || undefined;
129
+ }
130
+
131
+ /** Telemetry tail parts of a run row: badge and wait word first (dropped first
132
+ * under pressure), then the usage part, the model, and the always-surviving
133
+ * elapsed. */
134
+ function telemetryTailParts(run: RunView, now: number): Array<string | undefined> {
135
+ // Queued rows omit the model (the route is re-resolved at actual start).
136
+ // The full provider/model ref is kept "which provider served this run" is
137
+ // exactly what a multi-provider session needs to see.
138
+ const modelPart = run.status === "queued" || !run.model ? undefined : run.model;
139
+ const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
140
+ const wait = run.status === "queued" ? waitWord(run) : undefined;
141
+ // Drop order under pressure: badge, wait word, usage, model; elapsed
142
+ // survives every width the identity leaves room for.
143
+ return [badge, wait, usagePart(run.usage), modelPart, formatElapsed(run, now) || undefined];
144
+ }
145
+
146
+ /** Two lines for a live run. Line 1 is what the run is: identity, task label,
147
+ * then the telemetry flow (worktree badge, token flow, cost, provider/model,
148
+ * wait state, elapsed). Line 2 is what it is doing right now: the live
149
+ * activity, dim, indented under the label column behind a `↳` marker. The
150
+ * label takes the full content budget on line 1; the identity and the elapsed
151
+ * survive every width. */
152
+ function primaryLine(
153
+ run: RunView,
154
+ theme: Theme,
155
+ width: number,
156
+ now: number,
157
+ layout: ColumnLayout,
158
+ ): string[] {
159
+ const identity = identitySegment(run, theme, layout);
160
+ const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
161
+ const tail = composeTail(telemetryTailParts(run, now), tailBudget);
162
+ const label = run.label ?? formatTaskSummary(run.task, 48);
163
+ const contentBudget = width
164
+ - visibleWidth(identity)
165
+ - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
166
+ - visibleWidth(IDENTITY_GAP);
167
+ // The label is already fragment-extracted (runLabel); narrowing it keeps
168
+ // its tail so a second squeeze never trades away the recognisable
169
+ // filename, and no second head…tail ellipsis stacks on top of it.
170
+ const content = label && contentBudget > 0 ? shrinkRunLabel(label, contentBudget) : "";
171
+ const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
172
+ const lines = [composeLine(left, tail, theme, width)];
173
+
174
+ const activity = run.status === "running" || run.status === "interrupting"
175
+ ? run.activity?.trim()
176
+ : undefined;
177
+ if (activity) {
178
+ const indent = visibleWidth(identity) + visibleWidth(IDENTITY_GAP);
179
+ const activityBudget = width - indent - visibleWidth(ACTIVITY_MARKER);
180
+ if (activityBudget >= ACTIVITY_MIN_WIDTH) {
181
+ lines.push(
182
+ `${" ".repeat(indent)}${theme.fg("dim", `${ACTIVITY_MARKER}${formatTaskSummary(activity, activityBudget)}`)}`,
183
+ );
184
+ }
185
+ }
186
+ return lines;
187
+ }
188
+
189
+ /** Render active runs as compact per-run line groups: one two-line group per
190
+ * run. All rows share one column layout. */
191
+ export function formatActiveRunLines(
192
+ runs: readonly RunView[],
193
+ theme: Theme,
194
+ width: number,
195
+ now: number = Date.now(),
196
+ ): string[] {
197
+ const active = runs.filter((run) => isRunActiveStatus(run.status));
198
+ const layout: ColumnLayout = {
199
+ idWidth: Math.max(...active.map((run) => visibleWidth(`#${run.id}`)), 0),
200
+ agentWidth: Math.max(...active.map((run) => visibleWidth(agentColumnText(run))), 0),
201
+ };
202
+ const lines: string[] = [];
203
+ let shown = 0;
204
+ for (const run of active) {
205
+ // Reserve one line so a cut is always announced by the overflow marker.
206
+ const remaining = MAX_WIDGET_LINES - 1 - lines.length;
207
+ if (remaining <= 0) break;
208
+ const group = primaryLine(run, theme, width, now, layout);
209
+ lines.push(...group.slice(0, remaining));
210
+ shown++;
211
+ }
212
+ const hidden = active.length - shown;
213
+ if (hidden > 0) {
214
+ lines.push(theme.fg("dim", `… +${hidden} more`));
215
+ }
216
+ return lines;
217
+ }
218
+
219
+ function hasActiveRun(): boolean {
220
+ return monitor.getRuns().some((run) => isRunActiveStatus(run.status) && run.activeSince !== undefined);
221
+ }
222
+
223
+ /** Install the widget for one TUI session. Its timer exists only while at
224
+ * least one active run is executing, and is disposed with the widget. */
225
+ export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
226
+ if (ctx.mode !== "tui") return;
227
+ ctx.ui.setWidget(
228
+ SUBAGENTS_WIDGET_ID,
229
+ (tui, theme) => {
230
+ let timer: ReturnType<typeof setInterval> | undefined;
231
+ let disposed = false;
232
+
233
+ const syncTimer = (): void => {
234
+ if (disposed) return;
235
+ if (hasActiveRun()) {
236
+ if (timer) return;
237
+ timer = setInterval(() => tui.requestRender(), 1_000);
238
+ timer.unref?.();
239
+ return;
240
+ }
241
+ if (timer) clearInterval(timer);
242
+ timer = undefined;
243
+ };
244
+
245
+ const unsubscribe = monitor.subscribe(() => {
246
+ syncTimer();
247
+ tui.requestRender();
248
+ });
249
+ syncTimer();
250
+
251
+ return {
252
+ render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width, Date.now()),
253
+ invalidate() {},
254
+ dispose() {
255
+ disposed = true;
256
+ unsubscribe();
257
+ if (timer) clearInterval(timer);
258
+ timer = undefined;
259
+ },
260
+ };
261
+ },
262
+ { placement: "aboveEditor" },
263
+ );
264
+ }
265
+
266
+ export function clearActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
267
+ if (ctx.mode === "tui") ctx.ui.setWidget(SUBAGENTS_WIDGET_ID, undefined);
268
+ }