@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +47 -5
- package/src/ui/agent-widget.ts +85 -17
- package/src/ui/conversation-blocks.ts +13 -11
- package/src/ui/conversation-timeline.ts +141 -56
- package/src/ui/conversation-viewer.ts +52 -22
- package/src/ui/deferred-ui-refresh.ts +72 -0
- package/src/ui/fleet-list.ts +215 -46
- package/test/ui/agent-widget.test.ts +50 -1
- package/test/ui/deferred-ui-refresh.test.ts +55 -0
- package/test/ui/fleet-list.test.ts +264 -6
|
@@ -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");
|
|
@@ -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("
|
|
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
|
});
|