@esso0428/pi-subagents 0.15.16 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.17] - 2026-09-14
11
+
12
+ ### Fixed
13
+ - **FleetView preserves the selected row while navigating** by maintaining a bounded viewport over the full roster; `j`/`k`, arrow keys, and normalized wheel input share one directional transition and consume handled wheel events without stealing input from other focused components.
14
+ - **Structural Fleet/widget changes refresh the text-input viewport after a short defer** instead of requiring a manual redraw; refreshes coalesce, respect a maximum wait, cancel on session switch, and dispose before shutdown cleanup.
15
+
10
16
  ## [0.15.16] - 2026-09-13
11
17
 
12
18
  ### Added
package/README.md CHANGED
@@ -15,7 +15,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
15
15
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
16
16
  - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
17
17
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off`
18
- - **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
18
+ - **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓`/wheel to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. The selected row stays visible while the bounded list window moves through the full roster. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
19
19
  - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
20
20
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` or `.agents/agents/<name>.md` (project) or globally, with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
21
21
  - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.15.16",
3
+ "version": "0.15.17",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  type UICtx,
51
51
  } from "./ui/agent-widget.js";
52
52
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
53
+ import { createDeferredUiRefresh } from "./ui/deferred-ui-refresh.js";
53
54
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
54
55
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
55
56
 
@@ -374,6 +375,7 @@ export default function (pi: ExtensionAPI) {
374
375
  fleet.onAgentFinished(record.id);
375
376
  scheduleNudge(record.id, () => emitIndividualNudge(record));
376
377
  widget.update();
378
+ scheduleUiRefresh();
377
379
  }
378
380
 
379
381
  // ---- Group join manager ----
@@ -385,7 +387,11 @@ export default function (pi: ExtensionAPI) {
385
387
  scheduleNudge(groupKey, () => {
386
388
  // Re-check at send time
387
389
  const unconsumed = records.filter(r => !r.resultConsumed);
388
- if (unconsumed.length === 0) { widget.update(); return; }
390
+ if (unconsumed.length === 0) {
391
+ widget.update();
392
+ scheduleUiRefresh();
393
+ return;
394
+ }
389
395
 
390
396
  const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
391
397
  const label = partial
@@ -406,6 +412,7 @@ export default function (pi: ExtensionAPI) {
406
412
  }, { deliverAs: "followUp", triggerTurn: true });
407
413
  });
408
414
  widget.update();
415
+ scheduleUiRefresh();
409
416
  },
410
417
  30_000,
411
418
  );
@@ -463,6 +470,7 @@ export default function (pi: ExtensionAPI) {
463
470
  widget.markFinished(record.id);
464
471
  fleet.onAgentFinished(record.id);
465
472
  widget.update();
473
+ scheduleUiRefresh();
466
474
  return;
467
475
  }
468
476
 
@@ -470,6 +478,7 @@ export default function (pi: ExtensionAPI) {
470
478
  // don't send an individual nudge — finalizeBatch will pick it up retroactively.
471
479
  if (currentBatchAgents.some(a => a.id === record.id)) {
472
480
  widget.update();
481
+ scheduleUiRefresh();
473
482
  return;
474
483
  }
475
484
 
@@ -480,6 +489,7 @@ export default function (pi: ExtensionAPI) {
480
489
  // 'held' → do nothing, group will fire later
481
490
  // 'delivered' → group callback already fired
482
491
  widget.update();
492
+ scheduleUiRefresh();
483
493
  }, undefined, (record) => {
484
494
  // Emit started event when agent transitions to running (including from queue)
485
495
  pi.events.emit("subagents:started", {
@@ -487,6 +497,11 @@ export default function (pi: ExtensionAPI) {
487
497
  type: record.type,
488
498
  description: record.description,
489
499
  });
500
+ widget.ensureTimer();
501
+ widget.update();
502
+ fleet.ensureTimer();
503
+ fleet.update();
504
+ scheduleUiRefresh();
490
505
  }, (record, info) => {
491
506
  // Emit compacted event when agent's session compacts (preserves count on record).
492
507
  pi.events.emit("subagents:compacted", {
@@ -581,6 +596,7 @@ export default function (pi: ExtensionAPI) {
581
596
  });
582
597
 
583
598
  pi.on("session_before_switch", () => {
599
+ deferredUiRefresh.cancel();
584
600
  manager.clearCompleted(true);
585
601
  scheduler.stop();
586
602
  });
@@ -602,6 +618,7 @@ export default function (pi: ExtensionAPI) {
602
618
  manager.abortAll();
603
619
  for (const timer of pendingNudges.values()) clearTimeout(timer);
604
620
  pendingNudges.clear();
621
+ deferredUiRefresh.dispose();
605
622
  fleet.dispose();
606
623
  manager.dispose();
607
624
  });
@@ -614,13 +631,34 @@ export default function (pi: ExtensionAPI) {
614
631
  let widgetMode: WidgetMode = "background";
615
632
  function getWidgetMode(): WidgetMode { return widgetMode; }
616
633
  const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
617
- function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
634
+ function setWidgetMode(m: WidgetMode): void {
635
+ const changed = widgetMode !== m;
636
+ widgetMode = m;
637
+ widget.update();
638
+ if (changed) scheduleUiRefresh();
639
+ }
618
640
 
619
641
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
620
642
  const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd, pi, () => currentCtx);
643
+
644
+ // One render-only scheduler is shared by both widgets. The callback resolves
645
+ // the live TUI target at fire time because session switches invalidate the
646
+ // previous widget context; both widgets use the same TUI, so one successful
647
+ // request is sufficient.
648
+ const deferredUiRefresh = createDeferredUiRefresh(() => {
649
+ if (widget.requestUiRefresh(true)) return;
650
+ fleet.requestUiRefresh(true);
651
+ });
652
+ const scheduleUiRefresh = (): void => deferredUiRefresh.schedule();
653
+
621
654
  let fleetViewEnabled = true;
622
655
  function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
623
- function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
656
+ function setFleetViewEnabled(b: boolean): void {
657
+ const changed = fleetViewEnabled !== b;
658
+ fleetViewEnabled = b;
659
+ fleet.setEnabled(b);
660
+ if (changed) scheduleUiRefresh();
661
+ }
624
662
 
625
663
  // Project/global default for writing the subagent .output transcript. A custom
626
664
  // agent's `output_transcript` frontmatter overrides this per spawn; when the
@@ -720,8 +758,9 @@ export default function (pi: ExtensionAPI) {
720
758
 
721
759
  // Grab UI context from first tool execution + clear lingering widget on new turn
722
760
  pi.on("tool_execution_start", async (_event, ctx) => {
723
- widget.setUICtx(ctx.ui as UICtx);
724
- fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
761
+ const widgetContextChanged = widget.setUICtx(ctx.ui as UICtx);
762
+ const fleetContextChanged = fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
763
+ if (widgetContextChanged || fleetContextChanged) scheduleUiRefresh();
725
764
  widget.onTurnStart();
726
765
  });
727
766
 
@@ -1319,6 +1358,7 @@ Terse command-style prompts produce shallow, generic work.
1319
1358
  widget.update();
1320
1359
  fleet.ensureTimer();
1321
1360
  fleet.update();
1361
+ scheduleUiRefresh();
1322
1362
 
1323
1363
  // Emit created event
1324
1364
  pi.events.emit("subagents:created", {
@@ -1379,8 +1419,10 @@ Terse command-style prompts produce shallow, generic work.
1379
1419
  fgId = a.id;
1380
1420
  agentActivity.set(a.id, fgState);
1381
1421
  widget.ensureTimer();
1422
+ widget.update();
1382
1423
  fleet.ensureTimer();
1383
1424
  fleet.update();
1425
+ scheduleUiRefresh();
1384
1426
  break;
1385
1427
  }
1386
1428
  }
@@ -275,16 +275,24 @@ export class AgentWidget {
275
275
  }
276
276
 
277
277
  /** Set the UI context (grabbed from first tool execution). */
278
- setUICtx(ctx: UICtx) {
279
- if (ctx !== this.uiCtx) {
280
- // UICtx changed — the widget registered on the old context is gone.
281
- // Force re-registration on next update().
282
- this.uiCtx = ctx;
283
- this.widgetRegistered = false;
284
- this.tui = undefined;
285
- this.lastStatusText = undefined;
286
- this.lastRenderKey = undefined;
287
- }
278
+ setUICtx(ctx: UICtx): boolean {
279
+ if (ctx === this.uiCtx) return false;
280
+
281
+ // UICtx changed — the widget registered on the old context is gone.
282
+ // Force re-registration on next update().
283
+ this.uiCtx = ctx;
284
+ this.widgetRegistered = false;
285
+ this.tui = undefined;
286
+ this.lastStatusText = undefined;
287
+ this.lastRenderKey = undefined;
288
+ return true;
289
+ }
290
+
291
+ /** Request a render on the currently registered TUI without touching input. */
292
+ requestUiRefresh(force = true): boolean {
293
+ if (!this.tui) return false;
294
+ this.tui.requestRender(force);
295
+ return true;
288
296
  }
289
297
 
290
298
  /**
@@ -0,0 +1,72 @@
1
+ /** Coalesced, deferred render requests for structural UI changes. */
2
+
3
+ const DEFAULT_DELAY_MS = 50;
4
+ const DEFAULT_MAX_WAIT_MS = 200;
5
+
6
+ export interface DeferredUiRefresh {
7
+ /** Schedule one render request after the debounce/max-wait window. */
8
+ schedule(): void;
9
+ /** Cancel the current pending render request, if any. */
10
+ cancel(): void;
11
+ /** Cancel and permanently disable this scheduler. */
12
+ dispose(): void;
13
+ }
14
+
15
+ export function createDeferredUiRefresh(
16
+ requestRender: () => void,
17
+ options: {
18
+ delayMs?: number;
19
+ maxWaitMs?: number;
20
+ now?: () => number;
21
+ } = {},
22
+ ): DeferredUiRefresh {
23
+ const delayMs = Math.max(0, options.delayMs ?? DEFAULT_DELAY_MS);
24
+ const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
25
+ const now = options.now ?? Date.now;
26
+
27
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
28
+ let firstScheduledAt: number | undefined;
29
+ let disposed = false;
30
+
31
+ const clearPendingTimer = () => {
32
+ if (debounceTimer !== undefined) {
33
+ clearTimeout(debounceTimer);
34
+ debounceTimer = undefined;
35
+ }
36
+ };
37
+
38
+ const flush = () => {
39
+ // Clear all state before invoking user code. A render callback may schedule
40
+ // another refresh, which should begin a fresh debounce window.
41
+ clearPendingTimer();
42
+ firstScheduledAt = undefined;
43
+ if (disposed) return;
44
+ requestRender();
45
+ };
46
+
47
+ const schedule = () => {
48
+ if (disposed) return;
49
+
50
+ const current = now();
51
+ if (firstScheduledAt === undefined) firstScheduledAt = current;
52
+
53
+ clearPendingTimer();
54
+ const maxDeadline = firstScheduledAt + maxWaitMs;
55
+ const remainingMaxWait = Math.max(0, maxDeadline - current);
56
+ const wait = Math.min(delayMs, remainingMaxWait);
57
+ debounceTimer = setTimeout(flush, wait);
58
+ };
59
+
60
+ const cancel = () => {
61
+ clearPendingTimer();
62
+ firstScheduledAt = undefined;
63
+ };
64
+
65
+ const dispose = () => {
66
+ if (disposed) return;
67
+ cancel();
68
+ disposed = true;
69
+ };
70
+
71
+ return { schedule, cancel, dispose };
72
+ }
@@ -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
  /**
@@ -115,6 +167,8 @@ export class FleetList {
115
167
  private active = false;
116
168
  /** 0 = `main`, 1..N = subagents. */
117
169
  private selectedIndex = 0;
170
+ /** 0-based start in the agent-only roster; main is rendered separately. */
171
+ private viewportStart = 0;
118
172
  /** Set while a conversation overlay is open; calling it closes the overlay. */
119
173
  private viewerClose: (() => void) | undefined;
120
174
  private viewingAgentId: string | undefined;
@@ -137,8 +191,8 @@ export class FleetList {
137
191
  }
138
192
 
139
193
  /** Capture the UI context and (re)register the global input handler. */
140
- setUICtx(ui: FleetUICtx): void {
141
- if (ui === this.ui) return;
194
+ setUICtx(ui: FleetUICtx): boolean {
195
+ if (ui === this.ui) return false;
142
196
  this.inputUnsub?.();
143
197
  this.ui = ui;
144
198
  this.widgetRegistered = false;
@@ -146,6 +200,14 @@ export class FleetList {
146
200
  this.lastRenderKey = undefined;
147
201
  this.tui = undefined;
148
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;
149
211
  }
150
212
 
151
213
  /** Ensure the re-render timer is running (called when an agent spawns). */
@@ -173,6 +235,8 @@ export class FleetList {
173
235
  this.lastRenderKey = undefined;
174
236
  this.tui = undefined;
175
237
  this.active = false;
238
+ this.selectedIndex = 0;
239
+ this.viewportStart = 0;
176
240
  // Null last so a `viewerClose()` microtask above can't re-register the widget.
177
241
  this.ui = undefined;
178
242
  }
@@ -195,12 +259,14 @@ export class FleetList {
195
259
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
196
260
  this.active = false;
197
261
  this.selectedIndex = 0;
262
+ this.viewportStart = 0;
198
263
  this.lastRosterKey = undefined;
199
264
  this.lastRenderKey = undefined;
200
265
  return;
201
266
  }
202
267
 
203
268
  this.clampSelection();
269
+ this.ensureSelectionVisible();
204
270
  this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
205
271
  const renderKey = this.renderKey(records);
206
272
 
@@ -210,6 +276,7 @@ export class FleetList {
210
276
  return {
211
277
  render: (w: number) => this.renderBar(w, theme),
212
278
  invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
279
+ handleMouse: (event: FleetMouseEvent) => this.handleMouse(event),
213
280
  };
214
281
  }, { placement: "belowEditor" });
215
282
  this.widgetRegistered = true;
@@ -243,7 +310,13 @@ export class FleetList {
243
310
  Math.round(((record.completedAt ?? now) - record.startedAt) / 1000),
244
311
  ].join("\u001f");
245
312
  });
246
- return [this.enabled ? "1" : "0", this.active ? "1" : "0", String(this.selectedIndex), ...agentState].join("\u001e");
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");
247
320
  }
248
321
 
249
322
  // ---- Roster ----
@@ -270,8 +343,84 @@ export class FleetList {
270
343
  if (this.selectedIndex < 0) this.selectedIndex = 0;
271
344
  }
272
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
+
273
366
  // ---- Key handling ----
274
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
+
275
424
  /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
276
425
  handleKey(data: string): { consume?: boolean; data?: string } | undefined {
277
426
  if (!this.enabled || !this.ui) return undefined;
@@ -290,31 +439,17 @@ export class FleetList {
290
439
  return undefined;
291
440
  }
292
441
 
293
- if (!this.active) {
294
- // Activate: ↓ or ← at an empty prompt moves focus into the list.
295
- const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
296
- if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
297
- this.active = true;
298
- this.selectedIndex = 0;
299
- this.update();
300
- return { consume: true };
301
- }
302
- return undefined;
303
- }
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 };
304
449
 
305
- // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
306
- if (matchesKey(data, "down")) {
307
- const max = this.roster().length - 1;
308
- this.selectedIndex = Math.min(max, this.selectedIndex + 1);
309
- this.update();
310
- return { consume: true };
311
- }
312
- if (matchesKey(data, "up")) {
313
- if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
314
- this.selectedIndex -= 1;
315
- this.update();
316
- return { consume: true };
317
- }
450
+ if (!this.active) return undefined;
451
+
452
+ // Active — Enter opens, Esc / Up-past-top exits.
318
453
  if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
319
454
  if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
320
455
 
@@ -339,6 +474,7 @@ export class FleetList {
339
474
  private deactivate(): void {
340
475
  this.active = false;
341
476
  this.selectedIndex = 0;
477
+ this.viewportStart = 0;
342
478
  this.update();
343
479
  }
344
480
 
@@ -416,12 +552,12 @@ export class FleetList {
416
552
 
417
553
  const hint = this.active
418
554
  ? "↑↓ select · enter view · esc back"
419
- : "esc to interrupt · ← for agents · ↓ to manage";
555
+ : "esc to interrupt · ↓ to manage";
420
556
  const lines: string[] = [];
421
557
  lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
422
558
  lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
423
559
 
424
- const window = calculateFleetAgentWindow(agents.length, sel);
560
+ const window = calculateFleetAgentWindow(agents.length, this.viewportStart);
425
561
  if (window.hiddenAbove > 0) {
426
562
  lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
427
563
  }
@@ -158,4 +158,15 @@ describe("AgentWidget live records", () => {
158
158
  vi.useRealTimers();
159
159
  }
160
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
+ });
161
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
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  calculateFleetAgentWindow,
4
+ ensureFleetSelectionVisible,
4
5
  FLEET_MAX_RENDER_ROWS,
5
6
  FleetList,
6
7
  } from "../../src/ui/fleet-list.js";
@@ -35,6 +36,7 @@ function createFleetHarness(records: any[]) {
35
36
  const tui = { terminal: { rows: 24 }, requestRender: vi.fn() } as any;
36
37
  let terminalInput: ((data: string) => { consume?: boolean } | undefined) | undefined;
37
38
  let factory: any;
39
+ let widget: any;
38
40
  const ui: any = {
39
41
  setWidget: vi.fn((_key: string, content: any) => { factory = content; }),
40
42
  onTerminalInput: (handler: typeof terminalInput) => {
@@ -48,10 +50,14 @@ function createFleetHarness(records: any[]) {
48
50
  const fleet = new FleetList(manager, new Map());
49
51
  fleet.setUICtx(ui);
50
52
  fleet.update();
51
- const render = (width = 120) => factory ? factory(tui, theme).render(width) : [];
53
+ const render = (width = 120) => {
54
+ widget = factory ? factory(tui, theme) : undefined;
55
+ return widget?.render(width) ?? [];
56
+ };
52
57
  const input = (data: string) => terminalInput?.(data);
58
+ const mouse = (wheelDelta: number) => widget?.handleMouse({ type: "wheel", wheelDelta });
53
59
  render(); // The real TUI invokes the widget factory before keyboard input arrives.
54
- return { manager, fleet, tui, ui, render, input };
60
+ return { manager, fleet, tui, ui, render, input, mouse, getWidget: () => widget };
55
61
  }
56
62
 
57
63
  describe("canOpenActiveAgent", () => {
@@ -63,11 +69,68 @@ describe("canOpenActiveAgent", () => {
63
69
  });
64
70
  });
65
71
 
72
+ describe("FleetView focus-preserving window", () => {
73
+ it("does not move the window while the selected agent is visible", () => {
74
+ expect(calculateFleetAgentWindow(12, 0)).toMatchObject({
75
+ start: 0,
76
+ end: 3,
77
+ hiddenAbove: 0,
78
+ hiddenBelow: 9,
79
+ });
80
+ expect(ensureFleetSelectionVisible(12, 0, 0)).toBe(0);
81
+ expect(ensureFleetSelectionVisible(12, 1, 0)).toBe(0);
82
+ expect(ensureFleetSelectionVisible(12, 2, 0)).toBe(0);
83
+ });
84
+
85
+ it("moves only enough to show a selected agent below the window", () => {
86
+ const nextStart = ensureFleetSelectionVisible(12, 3, 0);
87
+ expect(nextStart).toBe(2);
88
+ const window = calculateFleetAgentWindow(12, nextStart);
89
+ expect(3).toBeGreaterThanOrEqual(window.start);
90
+ expect(3).toBeLessThan(window.end);
91
+ });
92
+
93
+ it("moves only enough to show a selected agent above the window", () => {
94
+ const nextStart = ensureFleetSelectionVisible(12, 3, 4);
95
+ expect(nextStart).toBe(3);
96
+ const window = calculateFleetAgentWindow(12, nextStart);
97
+ expect(3).toBeGreaterThanOrEqual(window.start);
98
+ expect(3).toBeLessThan(window.end);
99
+ });
100
+
101
+ it("preserves the marker row budget", () => {
102
+ for (const count of [0, 1, 2, 4, 5, 12]) {
103
+ for (let start = 0; start < Math.max(1, count); start++) {
104
+ const window = calculateFleetAgentWindow(count, start);
105
+ const rows = 2
106
+ + (window.hiddenAbove > 0 ? 1 : 0)
107
+ + (window.end - window.start)
108
+ + (window.hiddenBelow > 0 ? 1 : 0);
109
+ expect(rows).toBeLessThanOrEqual(FLEET_MAX_RENDER_ROWS);
110
+ }
111
+ }
112
+ });
113
+
114
+ it("clamps empty, negative, and oversized inputs", () => {
115
+ expect(calculateFleetAgentWindow(0, 99)).toEqual({
116
+ start: 0,
117
+ end: 0,
118
+ hiddenAbove: 0,
119
+ hiddenBelow: 0,
120
+ });
121
+ expect(ensureFleetSelectionVisible(12, -1, -4)).toBe(0);
122
+ expect(ensureFleetSelectionVisible(12, 999, 999)).toBe(9);
123
+ });
124
+ });
125
+
66
126
  describe("FleetList bounded window", () => {
67
- it("keeps every FleetView state within six total rows", () => {
127
+ it("keeps every FleetView viewport within six total rows", () => {
68
128
  for (const agentCount of [0, 1, 2, 4, 5, 12]) {
69
129
  for (let selected = 0; selected <= agentCount; selected++) {
70
- const window = calculateFleetAgentWindow(agentCount, selected);
130
+ const viewportStart = agentCount > 0
131
+ ? ensureFleetSelectionVisible(agentCount, Math.max(0, selected - 1), 0)
132
+ : 0;
133
+ const window = calculateFleetAgentWindow(agentCount, viewportStart);
71
134
  const rows = 2 + (window.hiddenAbove > 0 ? 1 : 0)
72
135
  + (window.end - window.start)
73
136
  + (window.hiddenBelow > 0 ? 1 : 0);
@@ -81,13 +144,194 @@ describe("FleetList bounded window", () => {
81
144
  });
82
145
 
83
146
  it("reserves indicator rows while keeping the selected middle agent visible", () => {
84
- const window = calculateFleetAgentWindow(12, 6);
147
+ const viewportStart = ensureFleetSelectionVisible(12, 5, 0);
148
+ expect(viewportStart).toBe(4);
149
+ const window = calculateFleetAgentWindow(12, viewportStart);
85
150
  expect(window.hiddenAbove).toBeGreaterThan(0);
86
151
  expect(window.hiddenBelow).toBeGreaterThan(0);
87
152
  expect(5).toBeGreaterThanOrEqual(window.start);
88
153
  expect(5).toBeLessThan(window.end);
89
154
  });
90
155
 
156
+ it("keeps the rendered window stable while moving inside it", () => {
157
+ const { render, input } = createFleetHarness(makeAgents(12));
158
+ input("\u001b[B"); // activate, select main
159
+ const before = render().join("\n");
160
+
161
+ input("\u001b[B"); // select agent 0, still in the first window
162
+ const afterFirst = render().join("\n");
163
+ input("\u001b[B"); // select agent 1, still in the first window
164
+ const afterSecond = render().join("\n");
165
+
166
+ expect(afterFirst).toContain("agent 0");
167
+ expect(afterSecond).toContain("agent 1");
168
+ expect(afterSecond).toContain("agent 0");
169
+ expect(afterSecond).toContain("↓ 9 more");
170
+ expect(afterSecond).not.toContain("↑");
171
+ expect(before).toContain("↓ 9 more");
172
+ });
173
+
174
+ it("moves the window only when the selected row leaves it", () => {
175
+ const { render, input } = createFleetHarness(makeAgents(12));
176
+ input("\u001b[B");
177
+ input("\u001b[B");
178
+ input("\u001b[B");
179
+ const beforeBoundary = render().join("\n");
180
+ input("\u001b[B");
181
+ const afterBoundary = render().join("\n");
182
+
183
+ expect(beforeBoundary).toContain("agent 1");
184
+ expect(afterBoundary).toContain("agent 3");
185
+ expect(afterBoundary).toContain("↑");
186
+ expect(afterBoundary).not.toEqual(beforeBoundary);
187
+ });
188
+
189
+ it("treats vim and arrow keys as equivalent directional input", () => {
190
+ const arrows = createFleetHarness(makeAgents(12));
191
+ const vim = createFleetHarness(makeAgents(12));
192
+ arrows.input("\u001b[B");
193
+ vim.input("\u001b[B");
194
+
195
+ for (let i = 0; i < 4; i++) {
196
+ arrows.input("\u001b[B");
197
+ vim.input("j");
198
+ expect(vim.render()).toEqual(arrows.render());
199
+ }
200
+ for (let i = 0; i < 2; i++) {
201
+ arrows.input("\u001b[A");
202
+ vim.input("k");
203
+ expect(vim.render()).toEqual(arrows.render());
204
+ }
205
+ });
206
+
207
+ it("activates inactive FleetView equivalently for j, down, and wheel-down", () => {
208
+ const arrows = createFleetHarness(makeAgents(2));
209
+ const vim = createFleetHarness(makeAgents(2));
210
+ const wheel = createFleetHarness(makeAgents(2));
211
+
212
+ expect(arrows.input("\u001b[B")).toEqual({ consume: true });
213
+ expect(vim.input("j")).toEqual({ consume: true });
214
+ expect(wheel.mouse(1)).toEqual({ handled: true, render: true });
215
+ expect(vim.render()).toEqual(arrows.render());
216
+ expect(wheel.render()).toEqual(arrows.render());
217
+ expect(arrows.render()[0]).toContain("↑↓ select");
218
+ });
219
+
220
+ it("activates inactive FleetView with left at an empty editor", () => {
221
+ const { render, input } = createFleetHarness(makeAgents(1));
222
+
223
+ expect(input("\u001b[D")).toEqual({ consume: true });
224
+ expect(render()[0]).toContain("↑↓ select");
225
+ expect(render()[1]).toContain("● main");
226
+ });
227
+
228
+ it("keeps left activation consistent and does not become down when active", () => {
229
+ const left = createFleetHarness(makeAgents(1));
230
+ const down = createFleetHarness(makeAgents(1));
231
+
232
+ expect(left.render()[0]).toContain("↓ to manage");
233
+ expect(left.input("\u001b[D")).toEqual({ consume: true });
234
+ expect(down.input("\u001b[B")).toEqual({ consume: true });
235
+ expect(left.render()).toEqual(down.render());
236
+ expect(left.render()[1]).toContain("● main");
237
+
238
+ const active = left.render();
239
+ left.input("\u001b[D");
240
+ expect(left.render()).toEqual(active);
241
+ expect(left.render()[1]).toContain("● main");
242
+ });
243
+
244
+ it("keeps the existing left activation contract", () => {
245
+ const { render, input } = createFleetHarness(makeAgents(2));
246
+
247
+ expect(input("\u001b[D")).toEqual({ consume: true });
248
+ expect(render()[0]).toContain("↑↓ select");
249
+ expect(render()[1]).toContain("● main");
250
+
251
+ input("\u001b[D");
252
+ expect(render()[1]).toContain("● main");
253
+ });
254
+
255
+ it("exposes normalized wheel input and maps its direction like arrows", () => {
256
+ const arrows = createFleetHarness(makeAgents(12));
257
+ const wheel = createFleetHarness(makeAgents(12));
258
+ expect(typeof wheel.getWidget().handleMouse).toBe("function");
259
+ arrows.input("\u001b[B");
260
+ wheel.input("\u001b[B");
261
+
262
+ arrows.input("\u001b[B");
263
+ expect(wheel.mouse(1)).toEqual({ handled: true, render: true });
264
+ expect(wheel.render()).toEqual(arrows.render());
265
+ arrows.input("\u001b[B");
266
+ expect(wheel.mouse(2)).toEqual({ handled: true, render: true });
267
+ expect(wheel.render()).toEqual(arrows.render());
268
+
269
+ arrows.input("\u001b[A");
270
+ expect(wheel.mouse(-1)).toEqual({ handled: true, render: true });
271
+ expect(wheel.render()).toEqual(arrows.render());
272
+ expect(wheel.getWidget().handleMouse({ type: "wheel", wheelDelta: 0 })).toBeUndefined();
273
+ expect(wheel.getWidget().handleMouse({ type: "wheel" })).toBeUndefined();
274
+ expect(wheel.getWidget().handleMouse({ type: "mousemove", wheelDelta: 1 })).toBeUndefined();
275
+ });
276
+
277
+ it("activates on downward wheel only when inactive at an empty editor", () => {
278
+ const { render, mouse } = createFleetHarness(makeAgents(2));
279
+
280
+ expect(render()[0]).toContain("↓ to manage");
281
+ expect(mouse(-1)).toBeUndefined();
282
+ expect(render()[0]).toContain("↓ to manage");
283
+
284
+ expect(mouse(1)).toEqual({ handled: true, render: true });
285
+ expect(render()[0]).toContain("↑↓ select");
286
+ expect(render()[1]).toContain("● main");
287
+ });
288
+
289
+ it("keeps vim, arrows, and wheel navigation at roster boundaries", () => {
290
+ const { render, input, mouse } = createFleetHarness(makeAgents(2));
291
+ input("\u001b[B"); // activate on main
292
+ expect(mouse(-1)).toEqual({ handled: true, render: true }); // up from main exits, rather than moving past the top
293
+ expect(render()[0]).toContain("← for agents");
294
+
295
+ input("\u001b[B");
296
+ input("j");
297
+ input("j"); // bottom agent
298
+ const atBottom = render().join("\n");
299
+ input("\u001b[B");
300
+ expect(render().join("\n")).toEqual(atBottom);
301
+ expect(mouse(1)).toEqual({ handled: true, render: true });
302
+ expect(render().join("\n")).toEqual(atBottom);
303
+
304
+ input("k");
305
+ input("k");
306
+ expect(render()[1]).toContain("● main");
307
+ input("\u001b[A"); // up from main deactivates at the top boundary
308
+ expect(render()[0]).toContain("↓ to manage");
309
+ });
310
+
311
+ it("keeps keyboard and wheel directions equivalent at active boundaries", () => {
312
+ const arrows = createFleetHarness(makeAgents(2));
313
+ const vim = createFleetHarness(makeAgents(2));
314
+ const wheel = createFleetHarness(makeAgents(2));
315
+ const compare = (arrow: () => unknown, vi: () => unknown, mouse: () => unknown) => {
316
+ expect(vi()).toEqual(arrow());
317
+ expect(mouse()).toEqual({ handled: true, render: true });
318
+ expect(vim.render()).toEqual(arrows.render());
319
+ expect(wheel.render()).toEqual(arrows.render());
320
+ };
321
+
322
+ compare(() => arrows.input("\u001b[B"), () => vim.input("j"), () => wheel.mouse(1));
323
+ compare(() => arrows.input("\u001b[B"), () => vim.input("j"), () => wheel.mouse(1));
324
+ compare(() => arrows.input("\u001b[B"), () => vim.input("j"), () => wheel.mouse(1));
325
+
326
+ // Down at the bottom clamps identically, while up eventually deactivates
327
+ // all three controls at the top boundary.
328
+ compare(() => arrows.input("\u001b[B"), () => vim.input("j"), () => wheel.mouse(1));
329
+ compare(() => arrows.input("\u001b[A"), () => vim.input("k"), () => wheel.mouse(-1));
330
+ compare(() => arrows.input("\u001b[A"), () => vim.input("k"), () => wheel.mouse(-1));
331
+ compare(() => arrows.input("\u001b[A"), () => vim.input("k"), () => wheel.mouse(-1));
332
+ expect(arrows.render()[0]).toContain("↓ to manage");
333
+ });
334
+
91
335
  it("slides the visible window while navigating the full roster", () => {
92
336
  const { render, input } = createFleetHarness(makeAgents(12));
93
337
  input("\u001b[B");
@@ -132,4 +376,14 @@ describe("FleetList bounded window", () => {
132
376
  fleet.update();
133
377
  expect(ui.setWidget).toHaveBeenLastCalledWith("fleet", undefined);
134
378
  });
379
+
380
+ it("refreshes the current TUI without handling keyboard input", () => {
381
+ const { fleet, tui } = createFleetHarness(makeAgents(1));
382
+ tui.requestRender.mockClear();
383
+
384
+ expect((fleet as any).requestUiRefresh(true)).toBe(true);
385
+ expect(tui.requestRender).toHaveBeenCalledWith(true);
386
+ fleet.dispose();
387
+ expect((fleet as any).requestUiRefresh(true)).toBe(false);
388
+ });
135
389
  });