@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/README.md +25 -2
- package/package.json +1 -1
- package/src/agents.ts +237 -237
- package/src/announcements.ts +81 -78
- package/src/dispatch.ts +73 -10
- package/src/format.ts +181 -165
- package/src/index.ts +102 -100
- package/src/prompt.ts +1 -1
- package/src/runtime.ts +33 -0
- package/src/status.ts +66 -0
- package/src/widget.ts +268 -266
- package/src/worktree.ts +974 -943
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
case "
|
|
73
|
-
return "
|
|
74
|
-
|
|
75
|
-
return "
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
* identity
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
case "
|
|
89
|
-
return `
|
|
90
|
-
case "
|
|
91
|
-
return `
|
|
92
|
-
case "
|
|
93
|
-
return `
|
|
94
|
-
|
|
95
|
-
return `
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- visibleWidth(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
timer
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
}
|