@esso0428/pi-subagents 0.16.0 → 0.17.1

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.
@@ -1,584 +0,0 @@
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
- * `j`) 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 so the final output remains readable.
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 type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
- import { canOpenActiveAgent } from "../agent-history-list.js";
17
- import type { AgentManager } from "../agent-manager.js";
18
- import type { AgentRecord } from "../types.js";
19
- import { getLifetimeTotal } from "../usage.js";
20
- import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
21
- import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
22
-
23
- /** Widget key for the below-editor fleet list. */
24
- const FLEET_KEY = "fleet";
25
- /** Maximum total rows contributed by FleetView, including hint and indicators. */
26
- export const FLEET_MAX_RENDER_ROWS = 6;
27
- const FLEET_BASE_ROWS = 2; // hint + main
28
- const FLEET_BODY_ROWS = FLEET_MAX_RENDER_ROWS - FLEET_BASE_ROWS;
29
- /** Re-render cadence so elapsed/token stats tick while agents run. */
30
- const TICK_MS = 200;
31
- /** Normalized mouse input accepted by FleetView's widget surface. */
32
- export type FleetMouseEvent = { type: string; wheelDelta?: number };
33
-
34
- type FleetMouseResult = { handled: true; render: true };
35
-
36
- type FleetWidgetContent = {
37
- render(width: number): string[];
38
- invalidate(): void;
39
- dispose?(): void;
40
- handleMouse?(event: FleetMouseEvent): FleetMouseResult | undefined;
41
- };
42
-
43
- /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
44
- export type FleetUICtx = {
45
- setWidget(
46
- key: string,
47
- content: undefined | ((tui: any, theme: Theme) => FleetWidgetContent),
48
- options?: { placement?: "aboveEditor" | "belowEditor" },
49
- ): void;
50
- onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
51
- getEditorText(): string;
52
- notify(message: string, type?: "info" | "warning" | "error"): void;
53
- custom<T>(
54
- factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void },
55
- options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void },
56
- ): Promise<T>;
57
- };
58
-
59
- type MainEntry = { kind: "main" };
60
- type AgentEntry = { kind: "agent"; record: AgentRecord };
61
- type FleetEntry = MainEntry | AgentEntry;
62
-
63
- /** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
64
- export function formatFleetElapsed(ms: number): string {
65
- return `${Math.max(0, Math.round(ms / 1000))}s`;
66
- }
67
-
68
- /** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
69
- export function formatFleetTokens(count: number): string {
70
- let compact: string;
71
- if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
72
- else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
73
- else compact = `${count}`;
74
- return `↓ ${compact} tokens`;
75
- }
76
-
77
- /** A bounded slice of the agent-only roster rendered by FleetView. */
78
- export type FleetAgentWindow = {
79
- start: number;
80
- end: number;
81
- hiddenAbove: number;
82
- hiddenBelow: number;
83
- };
84
-
85
- /**
86
- * Calculate a bounded agent slice from an agent-only viewport start.
87
- * `end` is exclusive; `main` is rendered separately and is not part of this
88
- * window. Marker rows are reserved only when the corresponding side is hidden.
89
- */
90
- export function calculateFleetAgentWindow(
91
- agentCount: number,
92
- viewportStart: number,
93
- ): FleetAgentWindow {
94
- const count = Math.max(0, Math.floor(agentCount));
95
- if (count === 0) return { start: 0, end: 0, hiddenAbove: 0, hiddenBelow: 0 };
96
-
97
- const maxStart = Math.max(0, count - (FLEET_BODY_ROWS - 1));
98
- const start = Math.min(maxStart, Math.max(0, Math.floor(viewportStart)));
99
- const hasAbove = start > 0;
100
- const agentBudget = Math.max(1, FLEET_BODY_ROWS - (hasAbove ? 1 : 0));
101
- let end = Math.min(count, start + agentBudget);
102
- if (end < count) end -= 1; // reserve the ↓ marker row
103
-
104
- return {
105
- start,
106
- end,
107
- hiddenAbove: start,
108
- hiddenBelow: count - end,
109
- };
110
- }
111
-
112
- /**
113
- * Return the smallest viewport adjustment that makes an agent visible. The
114
- * selected index is 0-based within the agent-only roster; `main` is handled by
115
- * FleetList rather than this pure helper.
116
- */
117
- export function ensureFleetSelectionVisible(
118
- agentCount: number,
119
- selectedAgentIndex: number,
120
- viewportStart: number,
121
- ): number {
122
- const count = Math.max(0, Math.floor(agentCount));
123
- if (count === 0) return 0;
124
-
125
- const selected = Math.min(
126
- count - 1,
127
- Math.max(0, Math.floor(selectedAgentIndex)),
128
- );
129
- const maxStart = Math.max(0, count - (FLEET_BODY_ROWS - 1));
130
- let start = Math.min(maxStart, Math.max(0, Math.floor(viewportStart)));
131
- const current = calculateFleetAgentWindow(count, start);
132
-
133
- if (selected < current.start) {
134
- return Math.min(maxStart, selected);
135
- }
136
- if (selected < current.end) return start;
137
-
138
- // With a possible ↓ marker, selected - 1 is the least forward shift that
139
- // exposes the selected row while preserving the marker row budget.
140
- return Math.min(maxStart, Math.max(start, selected - 1));
141
- }
142
-
143
- /**
144
- * Place `right` flush to `width`, truncating `left` first so the stats survive.
145
- * The final clamp guarantees the line never exceeds `width` (which would wrap and
146
- * desync pi's line-diff → flicker) even on a terminal too narrow for the stats.
147
- */
148
- function rightAlign(left: string, right: string, width: number): string {
149
- const rightW = visibleWidth(right);
150
- const maxLeft = Math.max(0, width - rightW - 1);
151
- const leftClamped = truncateToWidth(left, maxLeft);
152
- const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW);
153
- return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
154
- }
155
-
156
- export class FleetList {
157
- private ui: FleetUICtx | undefined;
158
- private tui: any | undefined;
159
- private inputUnsub: (() => void) | undefined;
160
- private widgetRegistered = false;
161
- private lastRosterKey: string | undefined;
162
- private lastRenderKey: string | undefined;
163
- private timer: ReturnType<typeof setInterval> | undefined;
164
-
165
- private enabled = true;
166
- /** Whether arrow keys currently navigate the list (vs. flow to the editor). */
167
- private active = false;
168
- /** 0 = `main`, 1..N = subagents. */
169
- private selectedIndex = 0;
170
- /** 0-based start in the agent-only roster; main is rendered separately. */
171
- private viewportStart = 0;
172
- /** Set while a conversation overlay is open; calling it closes the overlay. */
173
- private viewerClose: (() => void) | undefined;
174
- private viewingAgentId: string | undefined;
175
-
176
- constructor(
177
- private manager: AgentManager,
178
- private agentActivity: Map<string, AgentActivity>,_getCwd: () => string | undefined = () => undefined,
179
- private pi?: ExtensionAPI,
180
- private getCtx: () => ExtensionContext | undefined = () => undefined,
181
- ) {}
182
-
183
- // ---- Lifecycle ----
184
-
185
- setEnabled(enabled: boolean): void {
186
- if (enabled === this.enabled) return;
187
- this.enabled = enabled;
188
- if (!enabled) this.active = false;
189
- this.update();
190
- }
191
-
192
- /** Capture the UI context and (re)register the global input handler. */
193
- setUICtx(ui: FleetUICtx): boolean {
194
- if (ui === this.ui) return false;
195
- this.inputUnsub?.();
196
- this.ui = ui;
197
- this.widgetRegistered = false;
198
- this.lastRosterKey = undefined;
199
- this.lastRenderKey = undefined;
200
- this.tui = undefined;
201
- this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
202
- return true;
203
- }
204
-
205
- /** Request a render on the currently registered TUI without touching input. */
206
- requestUiRefresh(force = true): boolean {
207
- if (!this.tui) return false;
208
- this.tui.requestRender(force);
209
- return true;
210
- }
211
-
212
- /** Ensure the re-render timer is running (called when an agent spawns). */
213
- ensureTimer(): void {
214
- if (!this.timer) this.timer = setInterval(() => this.update(), TICK_MS);
215
- }
216
-
217
- /**
218
- * Called when an agent finishes. The viewer (if open on it) stays open so the
219
- * final output remains readable; refresh the active-only roster.
220
- */
221
- onAgentFinished(_id: string): void {
222
- this.update();
223
- }
224
-
225
- dispose(): void {
226
- if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
227
- this.inputUnsub?.();
228
- this.inputUnsub = undefined;
229
- if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; }
230
- this.viewingAgentId = undefined;
231
- if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
232
- this.widgetRegistered = false;
233
- this.lastRosterKey = undefined;
234
- this.lastRenderKey = undefined;
235
- this.tui = undefined;
236
- this.active = false;
237
- this.selectedIndex = 0;
238
- this.viewportStart = 0;
239
- // Null last so a `viewerClose()` microtask above can't re-register the widget.
240
- this.ui = undefined;
241
- }
242
-
243
- /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
244
- update(): void {
245
- if (!this.ui) return;
246
- const records = this.agentRecords();
247
- const hasAgents = this.enabled && records.length > 0;
248
- const rosterKey = records.map(record => record.id).join("\0");
249
- const rosterChanged = rosterKey !== this.lastRosterKey;
250
- this.lastRosterKey = rosterKey;
251
-
252
- if (!hasAgents) {
253
- if (this.widgetRegistered) {
254
- this.ui.setWidget(FLEET_KEY, undefined);
255
- this.widgetRegistered = false;
256
- this.tui = undefined;
257
- }
258
- if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
259
- this.active = false;
260
- this.selectedIndex = 0;
261
- this.viewportStart = 0;
262
- this.lastRosterKey = undefined;
263
- this.lastRenderKey = undefined;
264
- return;
265
- }
266
-
267
- this.clampSelection();
268
- this.ensureSelectionVisible();
269
- this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
270
- const renderKey = this.renderKey(records);
271
-
272
- if (!this.widgetRegistered) {
273
- this.ui.setWidget(FLEET_KEY, (tui, theme) => {
274
- this.tui = tui;
275
- return {
276
- render: (w: number) => this.renderBar(w, theme),
277
- invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
278
- handleMouse: (event: FleetMouseEvent) => this.handleMouse(event),
279
- };
280
- }, { placement: "belowEditor" });
281
- this.widgetRegistered = true;
282
- } else if (renderKey !== this.lastRenderKey) {
283
- // The timer runs more frequently than elapsed time is displayed. Avoid
284
- // waking the TUI when the visible FleetView snapshot is unchanged, but
285
- // preserve a forced redraw when rows were added/removed.
286
- this.tui?.requestRender(rosterChanged);
287
- }
288
- this.lastRenderKey = renderKey;
289
- }
290
-
291
- /**
292
- * Return only values that affect `renderBar()`. Elapsed time is rendered in
293
- * whole seconds, so the 200ms timer can skip four out of five updates while
294
- * still refreshing the row at the visible cadence.
295
- */
296
- private renderKey(records: AgentRecord[]): string {
297
- const now = Date.now();
298
- const agentState = records.map(record => {
299
- const activity = this.agentActivity.get(record.id);
300
- const usage = activity?.lifetimeUsage ?? record.lifetimeUsage;
301
- return [
302
- record.id,
303
- record.type,
304
- record.description,
305
- record.status,
306
- record.startedAt,
307
- record.completedAt ?? "",
308
- getLifetimeTotal(usage),
309
- Math.round(((record.completedAt ?? now) - record.startedAt) / 1000),
310
- ].join("\u001f");
311
- });
312
- return [
313
- this.enabled ? "1" : "0",
314
- this.active ? "1" : "0",
315
- String(this.selectedIndex),
316
- String(this.viewportStart),
317
- ...agentState,
318
- ].join("\u001e");
319
- }
320
-
321
- // ---- Roster ----
322
-
323
- /**
324
- * Active agents shown in the list, ordered earliest-launched first so the
325
- * ones you started sooner sit at the top. Pending agents with no session yet
326
- * are hidden until they start. (`listAgents()` is newest-first, so we
327
- * re-sort.)
328
- */
329
- private agentRecords(): AgentRecord[] {
330
- return this.manager.listAgents()
331
- .filter(record => canOpenActiveAgent(record))
332
- .sort((a, b) => a.startedAt - b.startedAt);
333
- }
334
-
335
- private roster(): FleetEntry[] {
336
- return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))];
337
- }
338
-
339
- private clampSelection(): void {
340
- const max = this.roster().length - 1;
341
- if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
342
- if (this.selectedIndex < 0) this.selectedIndex = 0;
343
- }
344
-
345
- /** Keep the selected agent visible without recentering an already-valid window. */
346
- private ensureSelectionVisible(): void {
347
- const agents = this.agentRecords();
348
- if (agents.length === 0) {
349
- this.viewportStart = 0;
350
- return;
351
- }
352
- if (this.selectedIndex > 0) {
353
- this.viewportStart = ensureFleetSelectionVisible(
354
- agents.length,
355
- this.selectedIndex - 1,
356
- this.viewportStart,
357
- );
358
- return;
359
- }
360
- // `main` does not constrain the agent window, but a roster shrink can make
361
- // its previous start invalid. Use the same pure clamp as renderBar.
362
- this.viewportStart = calculateFleetAgentWindow(agents.length, this.viewportStart).start;
363
- }
364
-
365
- // ---- Key handling ----
366
-
367
- /**
368
- * Move the selection while keeping keyboard and wheel input on one path.
369
- * Returns whether the direction was handled; inactive upward input and input
370
- * that cannot activate the list are left for the editor/transcript.
371
- */
372
- private moveSelection(direction: -1 | 1): boolean {
373
- const max = this.roster().length - 1;
374
- if (max < 0) return false;
375
-
376
- if (!this.active) {
377
- // Match ↓ activation: only a downward direction at an empty prompt with
378
- // the editor focused enters the list. Upward input remains a no-op.
379
- if (direction !== 1 || !this.editorHasFocus() || this.agentRecords().length === 0 || this.ui?.getEditorText() !== "") {
380
- return false;
381
- }
382
- this.active = true;
383
- this.selectedIndex = 0;
384
- this.viewportStart = 0;
385
- this.update();
386
- return true;
387
- }
388
-
389
- this.clampSelection();
390
- if (direction === -1 && this.selectedIndex === 0) {
391
- this.deactivate();
392
- return true;
393
- }
394
- this.selectedIndex = direction === 1
395
- ? Math.min(max, this.selectedIndex + 1)
396
- : Math.max(0, this.selectedIndex - 1);
397
- this.ensureSelectionVisible();
398
- this.update();
399
- return true;
400
- }
401
-
402
- /** Handle normalized wheel input from the registered widget. */
403
- handleMouse(event: FleetMouseEvent): FleetMouseResult | undefined {
404
- if (!this.enabled || !this.ui || this.viewerClose) return undefined;
405
- if (event.type !== "wheel") return undefined;
406
-
407
- const wheelDelta = event.wheelDelta;
408
- const direction: -1 | 1 | undefined = wheelDelta == null
409
- ? undefined
410
- : wheelDelta < 0
411
- ? -1
412
- : wheelDelta > 0
413
- ? 1
414
- : undefined;
415
- if (!direction || !this.moveSelection(direction)) return undefined;
416
-
417
- // The widget handled the normalized wheel, including at selection
418
- // boundaries; explicitly request a redraw so it never falls through to
419
- // Pi's transcript fallback.
420
- return { handled: true, render: true };
421
- }
422
-
423
- /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
424
- handleKey(data: string): { consume?: boolean; data?: string } | undefined {
425
- if (!this.enabled || !this.ui) return undefined;
426
- // Input listeners receive BOTH key-press and key-release (the kitty protocol
427
- // emits both, and matchesKey matches either) — act on press only, or every
428
- // tap would move/fire twice. Repeats still pass through for held-key nav.
429
- if (isKeyRelease(data)) return undefined;
430
- // While an overlay is open, let it own all input.
431
- if (this.viewerClose) return undefined;
432
- // Input listeners fire BEFORE the focused component, and dialogs
433
- // (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
434
- // while getEditorText() still reads the detached — empty — editor. So when
435
- // anything but the editor owns the keyboard, stay out of its keys (#123).
436
- if (!this.editorHasFocus()) {
437
- if (this.active) this.deactivate();
438
- return undefined;
439
- }
440
-
441
- const direction: -1 | 1 | undefined = matchesKey(data, "down") || matchesKey(data, "j")
442
- || (!this.active && matchesKey(data, "left"))
443
- ? 1
444
- : matchesKey(data, "up") || matchesKey(data, "k")
445
- ? -1
446
- : undefined;
447
- if (direction && this.moveSelection(direction)) return { consume: true };
448
-
449
- if (!this.active) return undefined;
450
-
451
- // Active — Enter opens, Esc / Up-past-top exits.
452
- if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
453
- if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
454
-
455
- // Any other key cancels navigation and flows to the editor.
456
- this.deactivate();
457
- return undefined;
458
- }
459
-
460
- /**
461
- * True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
462
- * subclass (CustomEditor) while every dialog/selector is not, and the loader
463
- * aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
464
- * check. `focusedComponent` is TUI-private (no public accessor), hence the
465
- * best-effort peek: unknowable focus (no tui seen yet, nothing focused)
466
- * counts as the editor so activation keeps working.
467
- */
468
- private editorHasFocus(): boolean {
469
- const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
470
- return focused == null || focused instanceof Editor;
471
- }
472
-
473
- private deactivate(): void {
474
- this.active = false;
475
- this.selectedIndex = 0;
476
- this.viewportStart = 0;
477
- this.update();
478
- }
479
-
480
- private openSelected(): void {
481
- const entry = this.roster()[this.selectedIndex];
482
- if (!entry || entry.kind === "main") {
483
- // `main` = return to the prompt; the native transcript is already shown.
484
- this.deactivate();
485
- return;
486
- }
487
- const record = entry.record;
488
- if (!this.ui) return;
489
- if (!canOpenActiveAgent(record) || !record.session) {
490
- this.ui.notify(`Agent is ${record.status} — no live session available.`, "info");
491
- return;
492
- }
493
- const session = record.session;
494
- const ctx = this.getCtx();
495
- const pi = this.pi;
496
- if (!ctx || !pi) {
497
- this.ui.notify("Agent conversation is unavailable — no active session.", "info");
498
- return;
499
- }
500
- const activity = this.agentActivity.get(record.id);
501
- this.viewingAgentId = record.id;
502
-
503
- void this.ui.custom<undefined>(
504
- (tui, theme, keybindings, done) => {
505
- this.viewerClose = () => done(undefined);
506
- return new ConversationViewer(
507
- tui,
508
- session,
509
- record,
510
- activity,
511
- theme,
512
- done,
513
- () => {
514
- if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
515
- },
516
- keybindings,
517
- (message: string) => this.manager.steer(record.id, message),
518
- { pi, ctx, readOnly: false },
519
- );
520
- },
521
- {
522
- overlay: true,
523
- overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
524
- },
525
- ).then(() => this.clearViewer(), () => this.clearViewer());
526
- }
527
-
528
- /** Reset overlay state and return to the list (on close, auto-close, or error). */
529
- private clearViewer(): void {
530
- // Keep the cursor on the agent we were viewing — re-resolve by id so it
531
- // still feels natural if the list reordered (an earlier agent finished)
532
- // while the overlay was open. If that agent is gone, leave the index for
533
- // update()'s clamp to settle.
534
- if (this.viewingAgentId) {
535
- const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
536
- if (idx >= 0) this.selectedIndex = idx;
537
- }
538
- this.viewerClose = undefined;
539
- this.viewingAgentId = undefined;
540
- this.update();
541
- }
542
-
543
- // ---- Rendering ----
544
-
545
- private renderBar(width: number, theme: Theme): string[] {
546
- const agents = this.roster().slice(1) as AgentEntry[];
547
- if (agents.length === 0) return [];
548
- // Clamp locally so a render between a roster shrink and the next update()
549
- // (e.g. on terminal resize) never loses the selection marker.
550
- const sel = Math.min(this.selectedIndex, agents.length);
551
-
552
- const hint = this.active
553
- ? "↑↓ select · enter view · esc back"
554
- : "esc to interrupt · ↓ to manage";
555
- const lines: string[] = [];
556
- lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
557
- lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
558
-
559
- const window = calculateFleetAgentWindow(agents.length, this.viewportStart);
560
- if (window.hiddenAbove > 0) {
561
- lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
562
- }
563
- for (let a = window.start; a < window.end; a++) {
564
- lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
565
- }
566
- if (window.hiddenBelow > 0) {
567
- lines.push(rightAlign("", theme.fg("dim", `↓ ${window.hiddenBelow} more`), width));
568
- }
569
-
570
- return lines;
571
- }
572
-
573
- private bullet(rosterIndex: number, sel: number, theme: Theme): string {
574
- return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○");
575
- }
576
-
577
- private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
578
- const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", getDisplayName(record.type))} ${record.description}`;
579
- const tokens = getLifetimeTotal(this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage);
580
- const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
581
- const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`);
582
- return rightAlign(left, right, width);
583
- }
584
- }