@esso0428/pi-subagents 0.15.15 → 0.15.17

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.
@@ -2,7 +2,7 @@
2
2
  * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
3
3
  *
4
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),
5
+ * `j`) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
6
6
  * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
7
7
  * A viewer stays open when its agent finishes so the final output remains readable.
8
8
  *
@@ -28,11 +28,23 @@ const FLEET_BASE_ROWS = 2; // hint + main
28
28
  const FLEET_BODY_ROWS = FLEET_MAX_RENDER_ROWS - FLEET_BASE_ROWS;
29
29
  /** Re-render cadence so elapsed/token stats tick while agents run. */
30
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
+
31
43
  /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
32
44
  export type FleetUICtx = {
33
45
  setWidget(
34
46
  key: string,
35
- content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }),
47
+ content: undefined | ((tui: any, theme: Theme) => FleetWidgetContent),
36
48
  options?: { placement?: "aboveEditor" | "belowEditor" },
37
49
  ): void;
38
50
  onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
@@ -62,30 +74,70 @@ export function formatFleetTokens(count: number): string {
62
74
  return `↓ ${compact} tokens`;
63
75
  }
64
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
+
65
85
  /**
66
- * Calculate a bounded agent slice for the complete logical roster.
67
- * `selectedRosterIndex` includes main at index 0; `end` is exclusive.
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.
68
89
  */
69
90
  export function calculateFleetAgentWindow(
70
91
  agentCount: number,
71
- selectedRosterIndex: number,
72
- ): { start: number; end: number; hiddenAbove: number; hiddenBelow: number } {
92
+ viewportStart: number,
93
+ ): FleetAgentWindow {
73
94
  const count = Math.max(0, Math.floor(agentCount));
74
95
  if (count === 0) return { start: 0, end: 0, hiddenAbove: 0, hiddenBelow: 0 };
75
96
 
76
- const selectedAgent = Math.min(
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(
77
126
  count - 1,
78
- Math.max(0, Math.floor(selectedRosterIndex) - 1),
79
- );
80
- const reserveAbove = selectedAgent > 0 ? 1 : 0;
81
- const reserveBelow = selectedAgent < count - 1 ? 1 : 0;
82
- const visibleCount = Math.max(1, FLEET_BODY_ROWS - reserveAbove - reserveBelow);
83
- const start = Math.min(
84
- Math.max(0, selectedAgent - visibleCount + 1),
85
- Math.max(0, count - visibleCount),
127
+ Math.max(0, Math.floor(selectedAgentIndex)),
86
128
  );
87
- const end = Math.min(count, start + visibleCount);
88
- return { start, end, hiddenAbove: start, hiddenBelow: count - end };
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));
89
141
  }
90
142
 
91
143
  /**
@@ -107,6 +159,7 @@ export class FleetList {
107
159
  private inputUnsub: (() => void) | undefined;
108
160
  private widgetRegistered = false;
109
161
  private lastRosterKey: string | undefined;
162
+ private lastRenderKey: string | undefined;
110
163
  private timer: ReturnType<typeof setInterval> | undefined;
111
164
 
112
165
  private enabled = true;
@@ -114,6 +167,8 @@ export class FleetList {
114
167
  private active = false;
115
168
  /** 0 = `main`, 1..N = subagents. */
116
169
  private selectedIndex = 0;
170
+ /** 0-based start in the agent-only roster; main is rendered separately. */
171
+ private viewportStart = 0;
117
172
  /** Set while a conversation overlay is open; calling it closes the overlay. */
118
173
  private viewerClose: (() => void) | undefined;
119
174
  private viewingAgentId: string | undefined;
@@ -136,14 +191,23 @@ export class FleetList {
136
191
  }
137
192
 
138
193
  /** Capture the UI context and (re)register the global input handler. */
139
- setUICtx(ui: FleetUICtx): void {
140
- if (ui === this.ui) return;
194
+ setUICtx(ui: FleetUICtx): boolean {
195
+ if (ui === this.ui) return false;
141
196
  this.inputUnsub?.();
142
197
  this.ui = ui;
143
198
  this.widgetRegistered = false;
144
199
  this.lastRosterKey = undefined;
200
+ this.lastRenderKey = undefined;
145
201
  this.tui = undefined;
146
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;
147
211
  }
148
212
 
149
213
  /** Ensure the re-render timer is running (called when an agent spawns). */
@@ -168,8 +232,11 @@ export class FleetList {
168
232
  if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
169
233
  this.widgetRegistered = false;
170
234
  this.lastRosterKey = undefined;
235
+ this.lastRenderKey = undefined;
171
236
  this.tui = undefined;
172
237
  this.active = false;
238
+ this.selectedIndex = 0;
239
+ this.viewportStart = 0;
173
240
  // Null last so a `viewerClose()` microtask above can't re-register the widget.
174
241
  this.ui = undefined;
175
242
  }
@@ -192,12 +259,16 @@ export class FleetList {
192
259
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
193
260
  this.active = false;
194
261
  this.selectedIndex = 0;
262
+ this.viewportStart = 0;
195
263
  this.lastRosterKey = undefined;
264
+ this.lastRenderKey = undefined;
196
265
  return;
197
266
  }
198
267
 
199
268
  this.clampSelection();
269
+ this.ensureSelectionVisible();
200
270
  this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
271
+ const renderKey = this.renderKey(records);
201
272
 
202
273
  if (!this.widgetRegistered) {
203
274
  this.ui.setWidget(FLEET_KEY, (tui, theme) => {
@@ -205,12 +276,47 @@ export class FleetList {
205
276
  return {
206
277
  render: (w: number) => this.renderBar(w, theme),
207
278
  invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
279
+ handleMouse: (event: FleetMouseEvent) => this.handleMouse(event),
208
280
  };
209
281
  }, { placement: "belowEditor" });
210
282
  this.widgetRegistered = true;
211
- } else {
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.
212
287
  this.tui?.requestRender(rosterChanged);
213
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");
214
320
  }
215
321
 
216
322
  // ---- Roster ----
@@ -237,8 +343,84 @@ export class FleetList {
237
343
  if (this.selectedIndex < 0) this.selectedIndex = 0;
238
344
  }
239
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
+
240
366
  // ---- Key handling ----
241
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
+
242
424
  /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
243
425
  handleKey(data: string): { consume?: boolean; data?: string } | undefined {
244
426
  if (!this.enabled || !this.ui) return undefined;
@@ -257,31 +439,17 @@ export class FleetList {
257
439
  return undefined;
258
440
  }
259
441
 
260
- if (!this.active) {
261
- // Activate: ↓ or ← at an empty prompt moves focus into the list.
262
- const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
263
- if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
264
- this.active = true;
265
- this.selectedIndex = 0;
266
- this.update();
267
- return { consume: true };
268
- }
269
- return undefined;
270
- }
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 };
271
449
 
272
- // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
273
- if (matchesKey(data, "down")) {
274
- const max = this.roster().length - 1;
275
- this.selectedIndex = Math.min(max, this.selectedIndex + 1);
276
- this.update();
277
- return { consume: true };
278
- }
279
- if (matchesKey(data, "up")) {
280
- if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
281
- this.selectedIndex -= 1;
282
- this.update();
283
- return { consume: true };
284
- }
450
+ if (!this.active) return undefined;
451
+
452
+ // Active — Enter opens, Esc / Up-past-top exits.
285
453
  if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
286
454
  if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
287
455
 
@@ -306,6 +474,7 @@ export class FleetList {
306
474
  private deactivate(): void {
307
475
  this.active = false;
308
476
  this.selectedIndex = 0;
477
+ this.viewportStart = 0;
309
478
  this.update();
310
479
  }
311
480
 
@@ -383,12 +552,12 @@ export class FleetList {
383
552
 
384
553
  const hint = this.active
385
554
  ? "↑↓ select · enter view · esc back"
386
- : "esc to interrupt · ← for agents · ↓ to manage";
555
+ : "esc to interrupt · ↓ to manage";
387
556
  const lines: string[] = [];
388
557
  lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
389
558
  lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
390
559
 
391
- const window = calculateFleetAgentWindow(agents.length, sel);
560
+ const window = calculateFleetAgentWindow(agents.length, this.viewportStart);
392
561
  if (window.hiddenAbove > 0) {
393
562
  lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
394
563
  }
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { AgentManager } from "../../src/agent-manager.js";
3
3
  import {
4
4
  AgentWidget,
@@ -48,6 +48,25 @@ function renderWidget(
48
48
  return { render, widget };
49
49
  }
50
50
 
51
+ function createWidgetHarness(records: AgentRecord[]) {
52
+ const manager = { listAgents: () => records } as unknown as AgentManager;
53
+ let factory: ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }) | undefined;
54
+ const tui = {
55
+ terminal: { columns: 120, rows: 24 },
56
+ requestRender: vi.fn(),
57
+ };
58
+ const ui = {
59
+ setStatus: vi.fn(),
60
+ setWidget: vi.fn((_key: string, content: any) => { factory = content; }),
61
+ };
62
+ const widget = new AgentWidget(manager, new Map(), () => "all");
63
+ widget.setUICtx(ui);
64
+ widget.update();
65
+ factory?.(tui, theme).render();
66
+ tui.requestRender.mockClear();
67
+ return { records, tui, ui, widget, render: () => factory?.(tui, theme).render() ?? [] };
68
+ }
69
+
51
70
  describe("AgentWidget live records", () => {
52
71
  it("keeps restored history in the live widget and manager history", () => {
53
72
  const manager = new AgentManager();
@@ -120,4 +139,34 @@ describe("AgentWidget live records", () => {
120
139
  expect(getWidgetLineBudget(9)).toBe(5);
121
140
  expect(getWidgetLineBudget(100)).toBe(MAX_WIDGET_LINES);
122
141
  });
142
+
143
+ it("does not request a render when a live snapshot is unchanged", () => {
144
+ vi.useFakeTimers();
145
+ vi.setSystemTime(10_000);
146
+ try {
147
+ const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
148
+ const { widget, tui } = createWidgetHarness([running]);
149
+
150
+ widget.update();
151
+ expect(tui.requestRender).not.toHaveBeenCalled();
152
+
153
+ running.description = "changed description";
154
+ widget.update();
155
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
156
+ widget.dispose();
157
+ } finally {
158
+ vi.useRealTimers();
159
+ }
160
+ });
161
+
162
+ it("exposes a safe render-only refresh target", () => {
163
+ const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
164
+ const { widget, tui } = createWidgetHarness([running]);
165
+ tui.requestRender.mockClear();
166
+
167
+ expect((widget as any).requestUiRefresh(true)).toBe(true);
168
+ expect(tui.requestRender).toHaveBeenCalledWith(true);
169
+ widget.dispose();
170
+ expect((widget as any).requestUiRefresh(true)).toBe(false);
171
+ });
123
172
  });
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createDeferredUiRefresh } from "../../src/ui/deferred-ui-refresh.js";
3
+
4
+ describe("createDeferredUiRefresh", () => {
5
+ afterEach(() => vi.useRealTimers());
6
+
7
+ it("fires once after the debounce delay", () => {
8
+ vi.useFakeTimers();
9
+ const requestRender = vi.fn();
10
+ const refresh = createDeferredUiRefresh(requestRender);
11
+
12
+ refresh.schedule();
13
+ vi.advanceTimersByTime(49);
14
+ expect(requestRender).not.toHaveBeenCalled();
15
+ vi.advanceTimersByTime(1);
16
+ expect(requestRender).toHaveBeenCalledTimes(1);
17
+ });
18
+
19
+ it("coalesces repeated schedules and respects the max wait", () => {
20
+ vi.useFakeTimers();
21
+ const requestRender = vi.fn();
22
+ const refresh = createDeferredUiRefresh(requestRender);
23
+
24
+ refresh.schedule();
25
+ vi.advanceTimersByTime(40);
26
+ refresh.schedule();
27
+ vi.advanceTimersByTime(40);
28
+ refresh.schedule();
29
+ vi.advanceTimersByTime(40);
30
+ refresh.schedule();
31
+ vi.advanceTimersByTime(40);
32
+ refresh.schedule();
33
+ vi.advanceTimersByTime(39);
34
+ expect(requestRender).not.toHaveBeenCalled();
35
+ vi.advanceTimersByTime(1);
36
+ expect(requestRender).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it("supports cancel and dispose without firing a stale callback", () => {
40
+ vi.useFakeTimers();
41
+ const requestRender = vi.fn();
42
+ const refresh = createDeferredUiRefresh(requestRender);
43
+
44
+ refresh.schedule();
45
+ refresh.cancel();
46
+ vi.advanceTimersByTime(250);
47
+ expect(requestRender).not.toHaveBeenCalled();
48
+
49
+ refresh.schedule();
50
+ refresh.dispose();
51
+ refresh.schedule();
52
+ vi.advanceTimersByTime(250);
53
+ expect(requestRender).not.toHaveBeenCalled();
54
+ });
55
+ });