@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 +6 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +47 -5
- package/src/ui/agent-widget.ts +18 -10
- package/src/ui/deferred-ui-refresh.ts +72 -0
- package/src/ui/fleet-list.ts +182 -46
- package/test/ui/agent-widget.test.ts +11 -0
- package/test/ui/deferred-ui-refresh.test.ts +55 -0
- package/test/ui/fleet-list.test.ts +259 -5
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,
|
|
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.
|
|
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) {
|
|
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 {
|
|
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 {
|
|
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
|
}
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -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
|
-
*
|
|
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) =>
|
|
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
|
|
67
|
-
* `
|
|
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
|
-
|
|
72
|
-
):
|
|
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
|
|
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(
|
|
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
|
|
88
|
-
|
|
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):
|
|
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 [
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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 ·
|
|
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,
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|