@esso0428/pi-subagents 0.15.17 → 0.17.0

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