@esso0428/pi-subagents 0.15.15 → 0.15.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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");
@@ -114,12 +358,16 @@ describe("FleetList bounded window", () => {
114
358
  expect(last.some(line => /↓ \d+ more/.test(line))).toBe(false);
115
359
  });
116
360
 
117
- it("forces a full render once when the active roster changes", () => {
361
+ it("skips unchanged timer updates but redraws once when the active roster changes", () => {
118
362
  const { manager, fleet, tui } = createFleetHarness(makeAgents(5));
119
363
  tui.requestRender.mockClear();
364
+ fleet.update();
365
+ expect(tui.requestRender).not.toHaveBeenCalled();
366
+
120
367
  manager.records.pop();
121
368
  fleet.update();
122
369
  expect(tui.requestRender).toHaveBeenCalledWith(true);
370
+ fleet.dispose();
123
371
  });
124
372
 
125
373
  it("clears the widget when the active roster becomes empty", () => {
@@ -128,4 +376,14 @@ describe("FleetList bounded window", () => {
128
376
  fleet.update();
129
377
  expect(ui.setWidget).toHaveBeenLastCalledWith("fleet", undefined);
130
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
+ });
131
389
  });