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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1244 -2
  3. package/package.json +75 -4
  4. package/src/abortable.ts +43 -0
  5. package/src/agent-color.ts +161 -0
  6. package/src/agent-file-toggle.ts +297 -0
  7. package/src/agent-manager.ts +1874 -0
  8. package/src/agent-runner.ts +1552 -0
  9. package/src/agent-source-badge.ts +75 -0
  10. package/src/agent-types.ts +346 -0
  11. package/src/child-context.ts +15 -0
  12. package/src/clock.ts +25 -0
  13. package/src/completion-outbox.ts +125 -0
  14. package/src/context.ts +58 -0
  15. package/src/cross-extension-rpc.ts +221 -0
  16. package/src/custom-agents.ts +436 -0
  17. package/src/default-agents.ts +126 -0
  18. package/src/detached-results.ts +134 -0
  19. package/src/enabled-models.ts +180 -0
  20. package/src/enterprise-local.ts +66 -0
  21. package/src/env.ts +33 -0
  22. package/src/group-join.ts +148 -0
  23. package/src/index.ts +4423 -0
  24. package/src/invocation-config.ts +189 -0
  25. package/src/memory.ts +179 -0
  26. package/src/mention-clone.ts +195 -0
  27. package/src/mention.ts +141 -0
  28. package/src/model-call.ts +83 -0
  29. package/src/model-resolver.ts +266 -0
  30. package/src/model-scope.ts +70 -0
  31. package/src/nested-tools.ts +448 -0
  32. package/src/output-file.ts +164 -0
  33. package/src/package-resources.ts +624 -0
  34. package/src/prompts.ts +156 -0
  35. package/src/quota-admission.ts +99 -0
  36. package/src/schedule-store.ts +153 -0
  37. package/src/schedule.ts +446 -0
  38. package/src/settings.ts +728 -0
  39. package/src/skill-loader.ts +137 -0
  40. package/src/startup-error.ts +29 -0
  41. package/src/status-note.ts +90 -0
  42. package/src/structured-output.ts +130 -0
  43. package/src/types.ts +404 -0
  44. package/src/ui/agent-mention.ts +216 -0
  45. package/src/ui/agent-row.ts +235 -0
  46. package/src/ui/agent-widget.ts +692 -0
  47. package/src/ui/conversation-viewer.ts +832 -0
  48. package/src/ui/fleet-list.ts +675 -0
  49. package/src/ui/notification-view.ts +120 -0
  50. package/src/ui/schedule-menu.ts +105 -0
  51. package/src/ui/select-item.ts +45 -0
  52. package/src/ui/viewer-keys.ts +39 -0
  53. package/src/ui/workflow-card.ts +462 -0
  54. package/src/ui/workflow-dialog.ts +1115 -0
  55. package/src/ui/workflow-menu.ts +193 -0
  56. package/src/usage.ts +167 -0
  57. package/src/workflow/collisions.ts +123 -0
  58. package/src/workflow/entry.ts +47 -0
  59. package/src/workflow/host.ts +401 -0
  60. package/src/workflow/journal.ts +164 -0
  61. package/src/workflow/json-schema.ts +129 -0
  62. package/src/workflow/meta.ts +326 -0
  63. package/src/workflow/progress.ts +549 -0
  64. package/src/workflow/runtime.ts +1216 -0
  65. package/src/workflow/saved.ts +259 -0
  66. package/src/workflow/task.ts +302 -0
  67. package/src/workflow/tool-description.ts +200 -0
  68. package/src/workflow/worker-source.ts +781 -0
  69. package/src/worktree.ts +230 -0
  70. package/src/xml.ts +13 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
3
+ *
4
+ * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
5
+ * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
6
+ * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
7
+ * A viewer stays open when its agent finishes; finished agents linger briefly in the list.
8
+ *
9
+ * Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key
10
+ * handling goes through `onTerminalInput` — which fires before the focused editor and
11
+ * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
12
+ */
13
+
14
+ import { isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { hasAgentBadge, renderAgentName } from "../agent-color.js";
16
+ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
17
+ import { type Clock, realClock, type TimerHandle } from "../clock.js";
18
+ import type { RunningDetached } from "../detached-results.js";
19
+ import { describeModelCall } from "../model-call.js";
20
+ import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
21
+ import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
22
+ import { type AgentActivity, buildInvocationTags, formatCost, type Theme } from "./agent-widget.js";
23
+ import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./conversation-viewer.js";
24
+
25
+ /** Widget key for the below-editor fleet list. */
26
+ const FLEET_KEY = "fleet";
27
+ /** Max fleet rows shown at once; extras collapse into indicator rows. */
28
+ const MAX_AGENT_ROWS = 5;
29
+ /** Agent rows left when overflow reserves the two indicator rows. */
30
+ const OVERFLOW_AGENT_ROWS = Math.max(1, MAX_AGENT_ROWS - 2);
31
+ /** Re-render cadence so elapsed/token stats tick while agents run. */
32
+ const TICK_MS = 200;
33
+ /** How long a finished agent lingers in the list before it drops out. */
34
+ const FINISHED_LINGER_MS = 4000;
35
+
36
+ /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
37
+ export type FleetUICtx = {
38
+ setWidget(
39
+ key: string,
40
+ content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }),
41
+ options?: { placement?: "aboveEditor" | "belowEditor" },
42
+ ): void;
43
+ onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
44
+ getEditorText(): string;
45
+ notify(message: string, type?: "info" | "warning" | "error"): void;
46
+ custom<T>(
47
+ factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void },
48
+ options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void },
49
+ ): Promise<T>;
50
+ };
51
+
52
+ /**
53
+ * A workflow run, as the fleet list needs to see it.
54
+ *
55
+ * Narrow on purpose: the list knows nothing about `WorkflowTask`, the runtime
56
+ * or the dialog, so it stays as testable as it was when it only held agents.
57
+ * The extension maps its tasks into this shape and injects an opener.
58
+ */
59
+ export interface FleetWorkflow {
60
+ id: string;
61
+ /** The `meta.name` of the run, or its id when the script named nothing. */
62
+ name: string;
63
+ status: "running" | "completed" | "failed" | "killed" | "paused";
64
+ doneCount: number;
65
+ totalCount: number;
66
+ startedAt: number;
67
+ /** Set once the run settles, which is what freezes its clock. */
68
+ completedAt?: number;
69
+ tokens: number;
70
+ }
71
+
72
+ type MainEntry = { kind: "main" };
73
+ type AgentEntry = { kind: "agent"; record: AgentRecord };
74
+ type WorkflowEntry = { kind: "workflow"; workflow: FleetWorkflow };
75
+ type FleetEntry = MainEntry | WorkflowEntry | AgentEntry;
76
+
77
+ /** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
78
+ export function formatFleetElapsed(ms: number): string {
79
+ return `${Math.max(0, Math.round(ms / 1000))}s`;
80
+ }
81
+
82
+ /** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
83
+ export function formatFleetTokens(count: number): string {
84
+ let compact: string;
85
+ if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
86
+ else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
87
+ else compact = `${count}`;
88
+ return `↓ ${compact} tokens`;
89
+ }
90
+
91
+ /**
92
+ * Place `right` flush to `width`, truncating `left` first so the stats survive.
93
+ * The final clamp guarantees the line never exceeds `width` (which would wrap and
94
+ * desync pi's line-diff → flicker) even on a terminal too narrow for the stats.
95
+ */
96
+ function rightAlign(left: string, right: string, width: number): string {
97
+ const rightW = visibleWidth(right);
98
+ const maxLeft = Math.max(0, width - rightW - 1);
99
+ const leftClamped = truncateToWidth(left, maxLeft);
100
+ const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW);
101
+ return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
102
+ }
103
+
104
+ /** Agent-row tail columns, left to right; text pads right, numbers pad left. */
105
+ const TAIL_SLOTS = [
106
+ { align: "end" }, // model
107
+ { align: "end" }, // thinking level
108
+ { align: "end" }, // live model-call activity
109
+ { align: "start" }, // elapsed
110
+ { align: "start" }, // tokens
111
+ { align: "start" }, // cost
112
+ ] as const;
113
+
114
+ export class FleetList {
115
+ private ui: FleetUICtx | undefined;
116
+ private tui: any | undefined;
117
+ private inputUnsub: (() => void) | undefined;
118
+ private widgetRegistered = false;
119
+ /** The registered widget component, so a late `dispose()` of a replaced one is ignored. */
120
+ private component: object | undefined;
121
+ private timer: TimerHandle | undefined;
122
+
123
+ private enabled = true;
124
+ /** Whether arrow keys currently navigate the list (vs. flow to the editor). */
125
+ private active = false;
126
+ /** 0 = `main`, 1..N = subagents. */
127
+ private selectedIndex = 0;
128
+ /** Set while a conversation overlay is open; calling it closes the overlay. */
129
+ private viewerClose: (() => void) | undefined;
130
+ private viewingAgentId: string | undefined;
131
+ /** Injected by the extension; absent until workflows are wired (or at all). */
132
+ private workflowSource: (() => readonly FleetWorkflow[]) | undefined;
133
+ private openWorkflow: ((id: string) => Promise<void> | void) | undefined;
134
+ /**
135
+ * Set while the workflow inspector is up.
136
+ *
137
+ * It does the two jobs `viewerClose` does for an agent's overlay — keep the
138
+ * list out of the dialog's keys, and remember which row to come back to —
139
+ * minus the close handle, because that overlay belongs to the extension.
140
+ */
141
+ private viewingWorkflowId: string | undefined;
142
+
143
+ constructor(
144
+ private manager: AgentManager,
145
+ private agentActivity: Map<string, AgentActivity>,
146
+ /**
147
+ * Read live at render time. Whether each row shows an estimated cost after
148
+ * its token count. Defaults to off — the extension supplies the user's
149
+ * `showCost` setting.
150
+ */
151
+ private showCost: () => boolean = () => false,
152
+ /**
153
+ * The user's `viewerMarkdown` setting, for a conversation overlay opened
154
+ * from here. Read live rather than captured, because the viewer's `m` key
155
+ * changes it while the overlay is up. Omitted → the viewer's own default.
156
+ */
157
+ private viewerMarkdown?: () => ViewerMarkdownMode,
158
+ /**
159
+ * Persist a mode chosen with `m` in that overlay, so the key means the same
160
+ * thing here as it does from `/agents` — one setting, not one per entry
161
+ * point. Omitted → `m` still cycles, viewer-locally.
162
+ */
163
+ private onViewerMarkdown?: (mode: ViewerMarkdownMode) => void,
164
+ private scrollbarMode: () => ScrollViewScrollbar = () => "auto",
165
+ /**
166
+ * The viewer's `b` for this row's run, supplied by the extension: only it
167
+ * knows which runs have a tool call waiting to be released, and it owns the
168
+ * widget this list shares with. Omitted → no `b` affordance.
169
+ */
170
+ private backgroundAction?: (record: AgentRecord, notify: (message: string) => void) => ViewerBackgroundAction,
171
+ /**
172
+ * Read live at render time. Whether each row names the model and thinking
173
+ * level it runs at. Defaults to off — the extension supplies the user's
174
+ * `showModel` setting, which until now only the widget honoured.
175
+ */
176
+ private showModel: () => boolean = () => false,
177
+ /**
178
+ * Agents still running in an orphaned manager (the instance before a
179
+ * `/reload`, `/new`, ...). Listed with this session's own and opened,
180
+ * stopped and steered through the orphan's manager. Omitted -> none.
181
+ */
182
+ private detachedSource: () => readonly RunningDetached[] = () => [],
183
+ private readonly clock: Clock = realClock,
184
+ ) {}
185
+
186
+ // ---- Lifecycle ----
187
+
188
+ setEnabled(enabled: boolean): void {
189
+ if (enabled === this.enabled) return;
190
+ this.enabled = enabled;
191
+ if (!enabled) this.active = false;
192
+ this.update();
193
+ }
194
+
195
+ /** Capture the UI context and (re)register the global input handler. */
196
+ setUICtx(ui: FleetUICtx): void {
197
+ if (ui === this.ui) return;
198
+ this.inputUnsub?.();
199
+ this.ui = ui;
200
+ this.widgetRegistered = false;
201
+ this.component = undefined;
202
+ this.tui = undefined;
203
+ this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
204
+ }
205
+
206
+ /** Ensure the re-render timer is running (called when an agent spawns). */
207
+ ensureTimer(): void {
208
+ if (!this.timer) this.timer = this.clock.setInterval(() => this.update(), TICK_MS);
209
+ }
210
+
211
+ /**
212
+ * Called when an agent finishes. The viewer (if open on it) stays open so the
213
+ * final output remains readable, and the row lingers in the list — just refresh.
214
+ */
215
+ onAgentFinished(_id: string): void {
216
+ this.update();
217
+ }
218
+
219
+ dispose(): void {
220
+ if (this.timer) { this.timer.cancel(); this.timer = undefined; }
221
+ this.inputUnsub?.();
222
+ this.inputUnsub = undefined;
223
+ if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; }
224
+ this.viewingAgentId = undefined;
225
+ // No handle to close the workflow inspector with, but the list is going
226
+ // away — leaving the id set would keep it swallowing input forever.
227
+ this.viewingWorkflowId = undefined;
228
+ if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
229
+ this.widgetRegistered = false;
230
+ this.component = undefined;
231
+ this.tui = undefined;
232
+ this.active = false;
233
+ // Null last so a `viewerClose()` microtask above can't re-register the widget.
234
+ this.ui = undefined;
235
+ }
236
+
237
+ /** Re-register/refresh the below-editor widget; clears it when nothing remains. */
238
+ update(): void {
239
+ if (!this.ui) return;
240
+ // A run with no agents of its own left in the list is still worth a row —
241
+ // it is the thing the user opens to see what its children did. Read off the
242
+ // roster for the same reason activation does: two counts of "is there
243
+ // anything here" drifted apart once before.
244
+ const hasRows = this.enabled && this.roster().length > 1;
245
+
246
+ if (!hasRows) {
247
+ if (this.widgetRegistered) {
248
+ this.ui.setWidget(FLEET_KEY, undefined);
249
+ this.widgetRegistered = false;
250
+ this.tui = undefined;
251
+ }
252
+ // A just-spawned agent has no session until its runner finishes binding
253
+ // extensions, and nothing else calls update() until the child's first model
254
+ // event, which a slow first response delays by minutes. Keep ticking while
255
+ // one is starting so its row appears as soon as the session exists.
256
+ if (this.enabled && this.hasStartingAgent()) this.ensureTimer();
257
+ else if (this.timer) { this.timer.cancel(); this.timer = undefined; }
258
+ this.active = false;
259
+ this.selectedIndex = 0;
260
+ return;
261
+ }
262
+
263
+ this.clampSelection();
264
+ this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
265
+
266
+ if (!this.widgetRegistered) {
267
+ this.ui.setWidget(FLEET_KEY, (tui, theme) => {
268
+ this.tui = tui;
269
+ const component = {
270
+ render: (w: number) => this.renderBar(w, theme),
271
+ invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
272
+ // Core disposes extension widgets on /reload and plan-mode enter/exit.
273
+ // Reset the flag so the next update() registers a fresh one instead of
274
+ // asking a dead component to re-render. Only the live component counts:
275
+ // a replaced one is disposed after its successor registered.
276
+ dispose: () => {
277
+ if (this.component !== component) return;
278
+ this.component = undefined;
279
+ this.widgetRegistered = false;
280
+ this.tui = undefined;
281
+ },
282
+ };
283
+ this.component = component;
284
+ return component;
285
+ }, { placement: "belowEditor" });
286
+ this.widgetRegistered = true;
287
+ } else {
288
+ this.tui?.requestRender();
289
+ }
290
+ }
291
+
292
+ /** Whether a top-level agent is live but not yet listable (no session yet). */
293
+ private hasStartingAgent(): boolean {
294
+ return this.manager.listAgents().some(a =>
295
+ isTopLevelAgent(a) && !a.session && (a.status === "running" || a.status === "queued"));
296
+ }
297
+
298
+ // ---- Roster ----
299
+
300
+ /**
301
+ * Agents shown in the list, ordered earliest-launched first so the ones you
302
+ * started sooner sit at the top. Every row is openable (has a session), so Enter
303
+ * never dead-ends. Included: running/queued, plus the agent currently being
304
+ * viewed, plus recently-finished ones (they linger briefly before dropping out).
305
+ * Pending agents with no session yet are hidden until they start.
306
+ * (`listAgents()` is newest-first, so we re-sort.)
307
+ */
308
+ private agentRecords(): AgentRecord[] {
309
+ const now = this.clock.now();
310
+ // Orphans are live objects, so a settled one is simply skipped; the
311
+ // registry drops it itself. Own records win if an id appears in both.
312
+ const own = this.manager.listAgents();
313
+ const ownIds = new Set(own.map(a => a.id));
314
+ const orphans = this.detachedSource().map(d => d.record).filter(r => !ownIds.has(r.id));
315
+ return [...own, ...orphans]
316
+ .filter(a => isTopLevelAgent(a) && a.session && (
317
+ a.status === "running" || a.status === "queued"
318
+ || a.id === this.viewingAgentId
319
+ || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
320
+ ))
321
+ .sort((a, b) => a.startedAt - b.startedAt);
322
+ }
323
+
324
+ private detachedFor(id: string): RunningDetached | undefined {
325
+ return this.detachedSource().find(d => d.record.id === id);
326
+ }
327
+
328
+ private activityFor(id: string): AgentActivity | undefined {
329
+ return this.agentActivity.get(id) ?? this.detachedFor(id)?.activity;
330
+ }
331
+
332
+ /**
333
+ * Wire workflow runs into the list.
334
+ *
335
+ * Injected rather than constructed here because the fleet list predates
336
+ * workflows and must keep working without them — a session with the feature
337
+ * switched off never calls this, and the roster is agents-only exactly as
338
+ * before.
339
+ */
340
+ setWorkflowSource(
341
+ source: () => readonly FleetWorkflow[],
342
+ open: (id: string) => Promise<void> | void,
343
+ ): void {
344
+ this.workflowSource = source;
345
+ this.openWorkflow = open;
346
+ }
347
+
348
+ /** Live runs, plus recently settled ones — the same linger the agents get. */
349
+ private workflows(): FleetWorkflow[] {
350
+ if (!this.workflowSource) return [];
351
+ const now = this.clock.now();
352
+ return [...this.workflowSource()]
353
+ .filter(run =>
354
+ run.status === "running"
355
+ || run.status === "paused"
356
+ || (run.completedAt != null && now - run.completedAt < FINISHED_LINGER_MS)
357
+ )
358
+ .sort((a, b) => a.startedAt - b.startedAt);
359
+ }
360
+
361
+ /**
362
+ * Runs sit above the agents rather than interleaved by start time: a run owns
363
+ * most of the agents under it, so listing the container first is what makes
364
+ * the list read as a hierarchy rather than a shuffle.
365
+ */
366
+ private roster(): FleetEntry[] {
367
+ return [
368
+ { kind: "main" },
369
+ ...this.workflows().map(workflow => ({ kind: "workflow" as const, workflow })),
370
+ ...this.agentRecords().map(record => ({ kind: "agent" as const, record })),
371
+ ];
372
+ }
373
+
374
+ private clampSelection(): void {
375
+ const max = this.roster().length - 1;
376
+ if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
377
+ if (this.selectedIndex < 0) this.selectedIndex = 0;
378
+ }
379
+
380
+ // ---- Key handling ----
381
+
382
+ /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
383
+ handleKey(data: string): { consume?: boolean; data?: string } | undefined {
384
+ if (!this.enabled || !this.ui) return undefined;
385
+ // Input listeners receive BOTH key-press and key-release (the kitty protocol
386
+ // emits both, and matchesKey matches either) — act on press only, or every
387
+ // tap would move/fire twice. Repeats still pass through for held-key nav.
388
+ if (isKeyRelease(data)) return undefined;
389
+ // While an overlay is open, let it own all input. Checked before the focus
390
+ // test below, which would otherwise read the dialog holding the keyboard as
391
+ // "the user left the list" and reset the selection out from under it.
392
+ if (this.viewerClose || this.viewingWorkflowId) return undefined;
393
+ // Input listeners fire BEFORE the focused component, and dialogs
394
+ // (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
395
+ // while getEditorText() still reads the detached — empty — editor. So when
396
+ // anything but the editor owns the keyboard, stay out of its keys (#123).
397
+ if (!this.editorHasFocus()) {
398
+ if (this.active) this.deactivate();
399
+ return undefined;
400
+ }
401
+
402
+ if (!this.active) {
403
+ // Activate: ↓ or ← at an empty prompt moves focus into the list.
404
+ const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
405
+ // Gated on the roster, not the agents: a session whose only row is a
406
+ // workflow run still has somewhere to go, and requiring an agent would
407
+ // render the row but refuse to move into it.
408
+ if (isActivator && this.roster().length > 1 && this.ui.getEditorText() === "") {
409
+ this.active = true;
410
+ this.selectedIndex = 0;
411
+ this.update();
412
+ return { consume: true };
413
+ }
414
+ return undefined;
415
+ }
416
+
417
+ // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
418
+ if (matchesKey(data, "down")) {
419
+ const max = this.roster().length - 1;
420
+ this.selectedIndex = Math.min(max, this.selectedIndex + 1);
421
+ this.update();
422
+ return { consume: true };
423
+ }
424
+ if (matchesKey(data, "up")) {
425
+ if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
426
+ this.selectedIndex -= 1;
427
+ this.update();
428
+ return { consume: true };
429
+ }
430
+ if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
431
+ if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
432
+
433
+ // Any other key cancels navigation and flows to the editor.
434
+ this.deactivate();
435
+ return undefined;
436
+ }
437
+
438
+ /**
439
+ * True when pi's prompt editor owns the keyboard. The editor is pluggable
440
+ * (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
441
+ * `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
442
+ * every editor and none of pi's dialogs, selectors or overlays implement.
443
+ * `focusedComponent` is TUI-private (no public accessor), hence the best-effort
444
+ * peek: unknowable focus (no tui seen yet, nothing focused) counts as the
445
+ * editor so activation keeps working.
446
+ */
447
+ editorHasFocus(): boolean {
448
+ const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
449
+ if (focused == null) return true;
450
+ const c = focused as { getText?: unknown; setText?: unknown };
451
+ return typeof c.getText === "function" && typeof c.setText === "function";
452
+ }
453
+
454
+ private deactivate(): void {
455
+ this.active = false;
456
+ this.selectedIndex = 0;
457
+ this.update();
458
+ }
459
+
460
+ private openSelected(): void {
461
+ const entry = this.roster()[this.selectedIndex];
462
+ if (!entry || entry.kind === "main") {
463
+ // `main` = return to the prompt; the native transcript is already shown.
464
+ this.deactivate();
465
+ return;
466
+ }
467
+ if (entry.kind === "workflow") {
468
+ // The extension owns this overlay and closes it, so there is no
469
+ // `viewerClose` to hold — but the list still has to know one is up, and
470
+ // still has to put the cursor back on the run when it comes down.
471
+ this.viewingWorkflowId = entry.workflow.id;
472
+ void Promise.resolve(this.openWorkflow?.(entry.workflow.id)).then(
473
+ () => this.clearViewer(),
474
+ () => this.clearViewer(),
475
+ );
476
+ return;
477
+ }
478
+ const record = entry.record;
479
+ if (!this.ui) return;
480
+ if (!record.session) {
481
+ this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
482
+ return;
483
+ }
484
+ const session = record.session;
485
+ const activity = this.activityFor(record.id);
486
+ const orphan = this.detachedFor(record.id);
487
+ this.viewingAgentId = record.id;
488
+ let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
489
+
490
+ void this.ui.custom<undefined>(
491
+ (tui, theme, keybindings, done) => {
492
+ overlayOptions = getConversationOverlayOptions(tui);
493
+ this.viewerClose = () => done(undefined);
494
+ return new ConversationViewer(
495
+ tui,
496
+ session,
497
+ record,
498
+ activity,
499
+ theme,
500
+ done,
501
+ () => {
502
+ if ((orphan ? orphan.abort() : this.manager.abort(record.id))) this.ui?.notify(`Stopped "${record.description}".`, "info");
503
+ },
504
+ keybindings,
505
+ (message: string) => (orphan ? orphan.steer(message) : this.manager.steer(record.id, message)),
506
+ this.showCost(),
507
+ this.viewerMarkdown,
508
+ this.onViewerMarkdown,
509
+ this.scrollbarMode(),
510
+ this.backgroundAction?.(record, message => this.ui?.notify(message, "info")),
511
+ this.clock,
512
+ );
513
+ },
514
+ {
515
+ overlay: true,
516
+ overlayOptions: () => overlayOptions,
517
+ },
518
+ ).then(() => this.clearViewer(), () => this.clearViewer());
519
+ }
520
+
521
+ /** Reset overlay state and return to the list (on close, auto-close, or error). */
522
+ private clearViewer(): void {
523
+ // Keep the cursor on the agent we were viewing — re-resolve by id so it
524
+ // still feels natural if the list reordered (an earlier agent finished)
525
+ // while the overlay was open. If that agent is gone, leave the index for
526
+ // update()'s clamp to settle.
527
+ const viewed = this.viewingAgentId ?? this.viewingWorkflowId;
528
+ if (viewed !== undefined) {
529
+ const idx = this.roster().findIndex(e =>
530
+ e.kind === "agent" ? e.record.id === viewed
531
+ : e.kind === "workflow" ? e.workflow.id === viewed
532
+ : false,
533
+ );
534
+ if (idx >= 0) this.selectedIndex = idx;
535
+ }
536
+ this.viewerClose = undefined;
537
+ this.viewingAgentId = undefined;
538
+ this.viewingWorkflowId = undefined;
539
+ this.update();
540
+ }
541
+
542
+ // ---- Rendering ----
543
+
544
+ private renderBar(width: number, theme: Theme): string[] {
545
+ const rows = this.roster().slice(1) as (WorkflowEntry | AgentEntry)[];
546
+ if (rows.length === 0) return [];
547
+ // Clamp locally so a render between a roster shrink and the next update()
548
+ // (e.g. on terminal resize) never loses the selection marker.
549
+ const sel = Math.min(this.selectedIndex, rows.length);
550
+
551
+ const hint = this.active
552
+ ? "↑↓ select · enter view · esc back"
553
+ : "esc to interrupt · ← for agents · ↓ to manage";
554
+ const lines: string[] = [];
555
+ lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
556
+ lines.push("");
557
+ lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
558
+
559
+ // At the cap, the row block holds rows alone. Once it overflows, reserve
560
+ // two of those slots for indicators, keeping the block exactly cap lines
561
+ // tall while the selected row stays in the agent window.
562
+ const overflow = rows.length > MAX_AGENT_ROWS;
563
+ const visible = Math.min(overflow ? OVERFLOW_AGENT_ROWS : MAX_AGENT_ROWS, rows.length);
564
+ const selRow = Math.max(0, sel - 1);
565
+ const start = selRow < visible ? 0 : selRow - visible + 1;
566
+ const hiddenBelow = rows.length - (start + visible);
567
+
568
+ // Both indicator slots remain in the row block, blank at an edge, so every
569
+ // overflowing roster has the same height.
570
+ // Column widths come from the visible agent rows so every tail lines up.
571
+ const cells = new Map<AgentRecord, string[]>();
572
+ for (const r of rows.slice(start, start + visible)) {
573
+ if (r.kind === "agent") cells.set(r.record, this.tailCells(r.record));
574
+ }
575
+ const colWidths = TAIL_SLOTS.map((_, i) =>
576
+ Math.max(0, ...[...cells.values()].map(c => visibleWidth(c[i]))));
577
+ if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
578
+ else if (overflow) lines.push("");
579
+ for (let a = start; a < start + visible; a++) {
580
+ const row = rows[a];
581
+ lines.push(
582
+ row.kind === "workflow" ?
583
+ this.renderWorkflowRow(a + 1, sel, row.workflow, width, theme)
584
+ : this.renderAgentRow(a + 1, sel, row.record, width, theme, cells.get(row.record) ?? [], colWidths),
585
+ );
586
+ }
587
+ if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
588
+ else if (overflow) lines.push("");
589
+ // pi stacks below-editor widgets directly on its footer with no spacer (#351).
590
+ lines.push("");
591
+
592
+ return lines;
593
+ }
594
+
595
+ private bullet(rosterIndex: number, sel: number, theme: Theme): string {
596
+ return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○");
597
+ }
598
+
599
+ /**
600
+ * A run's row. Shaped like an agent's — bullet, kind, name, stats flush right
601
+ * — so the two read as one list, with the agent count where an agent has its
602
+ * description and the same elapsed/token tail.
603
+ */
604
+ private renderWorkflowRow(
605
+ rosterIndex: number,
606
+ sel: number,
607
+ workflow: FleetWorkflow,
608
+ width: number,
609
+ theme: Theme,
610
+ ): string {
611
+ const selected = rosterIndex === sel;
612
+ const kind = theme.fg(selected ? "text" : "muted", "workflow");
613
+ const name = selected ? theme.fg("text", workflow.name) : workflow.name;
614
+ const left = ` ${this.bullet(rosterIndex, sel, theme)} ${kind} ${name}`;
615
+ // Frozen once the run settles, exactly as an agent's clock is.
616
+ const elapsed = (workflow.completedAt ?? this.clock.now()) - workflow.startedAt;
617
+ const agents = `${workflow.doneCount}/${workflow.totalCount} agent${workflow.totalCount === 1 ? "" : "s"}`;
618
+ const stats = `${agents} · ${formatFleetElapsed(elapsed)} · ${formatFleetTokens(workflow.tokens)}`;
619
+ return rightAlign(left, selected ? theme.fg("text", stats) : theme.fg("dim", stats), width);
620
+ }
621
+
622
+ /** One agent's stats tail as {@link TAIL_SLOTS} cells; "" where a slot has no value. */
623
+ private tailCells(record: AgentRecord): string[] {
624
+ // The record, not the activity tracker — see the note in AgentWidget's
625
+ // running line: only the record carries a nested child's spend, and only it
626
+ // outlives the agent.
627
+ const tokens = getLifetimeTotal(record.lifetimeUsage);
628
+ const elapsedMs = (record.completedAt ?? this.clock.now()) - record.startedAt; // freezes once finished
629
+ let model = "";
630
+ let thinking = "";
631
+ if (this.showModel()) {
632
+ // Leading, and paired: a thinking level means nothing without its model.
633
+ const { modelName, tags } = buildInvocationTags(record.invocation);
634
+ model = modelName ?? "";
635
+ thinking = tags.find(tag => tag.startsWith("thinking: ")) ?? "";
636
+ }
637
+ // What the in-flight model call is doing, only while the agent is running:
638
+ // a finished row has no call to describe.
639
+ const call = record.status === "running"
640
+ ? describeModelCall(this.activityFor(record.id)?.modelCall, this.clock.now())
641
+ : undefined;
642
+ const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
643
+ return [model, thinking, call ?? "", formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost];
644
+ }
645
+
646
+ private renderAgentRow(
647
+ rosterIndex: number,
648
+ sel: number,
649
+ record: AgentRecord,
650
+ width: number,
651
+ theme: Theme,
652
+ cells: string[],
653
+ colWidths: number[],
654
+ ): string {
655
+ // The selected row renders in the theme's primary text color so it reads as
656
+ // one selection (#230). A configured badge survives — Claude Code's FleetView
657
+ // keeps the agent color on the selected row too and only bolds it — which also
658
+ // keeps the row's width fixed as the selection moves.
659
+ const selected = rosterIndex === sel;
660
+ const name = renderAgentName(record.type, theme, selected
661
+ ? { fallbackColor: "text", bold: hasAgentBadge(record.type) }
662
+ : { fallbackColor: "muted" });
663
+ const description = selected ? theme.fg("text", record.description) : record.description;
664
+ const left = ` ${this.bullet(rosterIndex, sel, theme)} ${name} ${description}`;
665
+ // Fixed-width slots (blank where a row has no value) keep each column at the
666
+ // same cells on every row; an all-empty column is dropped.
667
+ const stats = TAIL_SLOTS.map((slot, i) => {
668
+ if (colWidths[i] === 0) return undefined;
669
+ const pad = " ".repeat(colWidths[i] - visibleWidth(cells[i] ?? ""));
670
+ return slot.align === "end" ? (cells[i] ?? "") + pad : pad + (cells[i] ?? "");
671
+ }).filter((c): c is string => c !== undefined).join(" · ");
672
+ const right = selected ? theme.fg("text", stats) : theme.fg("dim", stats);
673
+ return rightAlign(left, right, width);
674
+ }
675
+ }