@esso0428/pi-subagents 0.16.0 → 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.
- package/CHANGELOG.md +5 -0
- package/README.md +10 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -73
- package/dist/index.js.map +1 -1
- package/dist/settings.d.ts +1 -8
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +0 -5
- package/dist/settings.js.map +1 -1
- package/dist/ui/agent-widget.d.ts +55 -24
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +246 -121
- package/dist/ui/agent-widget.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +17 -71
- package/src/settings.ts +1 -12
- package/src/ui/agent-widget.ts +264 -114
- package/dist/ui/deferred-ui-refresh.d.ts +0 -15
- package/dist/ui/deferred-ui-refresh.d.ts.map +0 -1
- package/dist/ui/deferred-ui-refresh.js +0 -50
- package/dist/ui/deferred-ui-refresh.js.map +0 -1
- package/dist/ui/fleet-list.d.ts +0 -163
- package/dist/ui/fleet-list.d.ts.map +0 -1
- package/dist/ui/fleet-list.js +0 -509
- package/dist/ui/fleet-list.js.map +0 -1
- package/src/ui/deferred-ui-refresh.ts +0 -72
- package/src/ui/fleet-list.ts +0 -584
package/src/ui/fleet-list.ts
DELETED
|
@@ -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
|
-
}
|