@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
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
|
/**
|
|
@@ -107,6 +159,7 @@ export class FleetList {
|
|
|
107
159
|
private inputUnsub: (() => void) | undefined;
|
|
108
160
|
private widgetRegistered = false;
|
|
109
161
|
private lastRosterKey: string | undefined;
|
|
162
|
+
private lastRenderKey: string | undefined;
|
|
110
163
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
111
164
|
|
|
112
165
|
private enabled = true;
|
|
@@ -114,6 +167,8 @@ export class FleetList {
|
|
|
114
167
|
private active = false;
|
|
115
168
|
/** 0 = `main`, 1..N = subagents. */
|
|
116
169
|
private selectedIndex = 0;
|
|
170
|
+
/** 0-based start in the agent-only roster; main is rendered separately. */
|
|
171
|
+
private viewportStart = 0;
|
|
117
172
|
/** Set while a conversation overlay is open; calling it closes the overlay. */
|
|
118
173
|
private viewerClose: (() => void) | undefined;
|
|
119
174
|
private viewingAgentId: string | undefined;
|
|
@@ -136,14 +191,23 @@ export class FleetList {
|
|
|
136
191
|
}
|
|
137
192
|
|
|
138
193
|
/** Capture the UI context and (re)register the global input handler. */
|
|
139
|
-
setUICtx(ui: FleetUICtx):
|
|
140
|
-
if (ui === this.ui) return;
|
|
194
|
+
setUICtx(ui: FleetUICtx): boolean {
|
|
195
|
+
if (ui === this.ui) return false;
|
|
141
196
|
this.inputUnsub?.();
|
|
142
197
|
this.ui = ui;
|
|
143
198
|
this.widgetRegistered = false;
|
|
144
199
|
this.lastRosterKey = undefined;
|
|
200
|
+
this.lastRenderKey = undefined;
|
|
145
201
|
this.tui = undefined;
|
|
146
202
|
this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Request a render on the currently registered TUI without touching input. */
|
|
207
|
+
requestUiRefresh(force = true): boolean {
|
|
208
|
+
if (!this.tui) return false;
|
|
209
|
+
this.tui.requestRender(force);
|
|
210
|
+
return true;
|
|
147
211
|
}
|
|
148
212
|
|
|
149
213
|
/** Ensure the re-render timer is running (called when an agent spawns). */
|
|
@@ -168,8 +232,11 @@ export class FleetList {
|
|
|
168
232
|
if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
|
|
169
233
|
this.widgetRegistered = false;
|
|
170
234
|
this.lastRosterKey = undefined;
|
|
235
|
+
this.lastRenderKey = undefined;
|
|
171
236
|
this.tui = undefined;
|
|
172
237
|
this.active = false;
|
|
238
|
+
this.selectedIndex = 0;
|
|
239
|
+
this.viewportStart = 0;
|
|
173
240
|
// Null last so a `viewerClose()` microtask above can't re-register the widget.
|
|
174
241
|
this.ui = undefined;
|
|
175
242
|
}
|
|
@@ -192,12 +259,16 @@ export class FleetList {
|
|
|
192
259
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
193
260
|
this.active = false;
|
|
194
261
|
this.selectedIndex = 0;
|
|
262
|
+
this.viewportStart = 0;
|
|
195
263
|
this.lastRosterKey = undefined;
|
|
264
|
+
this.lastRenderKey = undefined;
|
|
196
265
|
return;
|
|
197
266
|
}
|
|
198
267
|
|
|
199
268
|
this.clampSelection();
|
|
269
|
+
this.ensureSelectionVisible();
|
|
200
270
|
this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
|
|
271
|
+
const renderKey = this.renderKey(records);
|
|
201
272
|
|
|
202
273
|
if (!this.widgetRegistered) {
|
|
203
274
|
this.ui.setWidget(FLEET_KEY, (tui, theme) => {
|
|
@@ -205,12 +276,47 @@ export class FleetList {
|
|
|
205
276
|
return {
|
|
206
277
|
render: (w: number) => this.renderBar(w, theme),
|
|
207
278
|
invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
|
|
279
|
+
handleMouse: (event: FleetMouseEvent) => this.handleMouse(event),
|
|
208
280
|
};
|
|
209
281
|
}, { placement: "belowEditor" });
|
|
210
282
|
this.widgetRegistered = true;
|
|
211
|
-
} else {
|
|
283
|
+
} else if (renderKey !== this.lastRenderKey) {
|
|
284
|
+
// The timer runs more frequently than elapsed time is displayed. Avoid
|
|
285
|
+
// waking the TUI when the visible FleetView snapshot is unchanged, but
|
|
286
|
+
// preserve a forced redraw when rows were added/removed.
|
|
212
287
|
this.tui?.requestRender(rosterChanged);
|
|
213
288
|
}
|
|
289
|
+
this.lastRenderKey = renderKey;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Return only values that affect `renderBar()`. Elapsed time is rendered in
|
|
294
|
+
* whole seconds, so the 200ms timer can skip four out of five updates while
|
|
295
|
+
* still refreshing the row at the visible cadence.
|
|
296
|
+
*/
|
|
297
|
+
private renderKey(records: AgentRecord[]): string {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
const agentState = records.map(record => {
|
|
300
|
+
const activity = this.agentActivity.get(record.id);
|
|
301
|
+
const usage = activity?.lifetimeUsage ?? record.lifetimeUsage;
|
|
302
|
+
return [
|
|
303
|
+
record.id,
|
|
304
|
+
record.type,
|
|
305
|
+
record.description,
|
|
306
|
+
record.status,
|
|
307
|
+
record.startedAt,
|
|
308
|
+
record.completedAt ?? "",
|
|
309
|
+
getLifetimeTotal(usage),
|
|
310
|
+
Math.round(((record.completedAt ?? now) - record.startedAt) / 1000),
|
|
311
|
+
].join("\u001f");
|
|
312
|
+
});
|
|
313
|
+
return [
|
|
314
|
+
this.enabled ? "1" : "0",
|
|
315
|
+
this.active ? "1" : "0",
|
|
316
|
+
String(this.selectedIndex),
|
|
317
|
+
String(this.viewportStart),
|
|
318
|
+
...agentState,
|
|
319
|
+
].join("\u001e");
|
|
214
320
|
}
|
|
215
321
|
|
|
216
322
|
// ---- Roster ----
|
|
@@ -237,8 +343,84 @@ export class FleetList {
|
|
|
237
343
|
if (this.selectedIndex < 0) this.selectedIndex = 0;
|
|
238
344
|
}
|
|
239
345
|
|
|
346
|
+
/** Keep the selected agent visible without recentering an already-valid window. */
|
|
347
|
+
private ensureSelectionVisible(): void {
|
|
348
|
+
const agents = this.agentRecords();
|
|
349
|
+
if (agents.length === 0) {
|
|
350
|
+
this.viewportStart = 0;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (this.selectedIndex > 0) {
|
|
354
|
+
this.viewportStart = ensureFleetSelectionVisible(
|
|
355
|
+
agents.length,
|
|
356
|
+
this.selectedIndex - 1,
|
|
357
|
+
this.viewportStart,
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// `main` does not constrain the agent window, but a roster shrink can make
|
|
362
|
+
// its previous start invalid. Use the same pure clamp as renderBar.
|
|
363
|
+
this.viewportStart = calculateFleetAgentWindow(agents.length, this.viewportStart).start;
|
|
364
|
+
}
|
|
365
|
+
|
|
240
366
|
// ---- Key handling ----
|
|
241
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Move the selection while keeping keyboard and wheel input on one path.
|
|
370
|
+
* Returns whether the direction was handled; inactive upward input and input
|
|
371
|
+
* that cannot activate the list are left for the editor/transcript.
|
|
372
|
+
*/
|
|
373
|
+
private moveSelection(direction: -1 | 1): boolean {
|
|
374
|
+
const max = this.roster().length - 1;
|
|
375
|
+
if (max < 0) return false;
|
|
376
|
+
|
|
377
|
+
if (!this.active) {
|
|
378
|
+
// Match ↓ activation: only a downward direction at an empty prompt with
|
|
379
|
+
// the editor focused enters the list. Upward input remains a no-op.
|
|
380
|
+
if (direction !== 1 || !this.editorHasFocus() || this.agentRecords().length === 0 || this.ui?.getEditorText() !== "") {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
this.active = true;
|
|
384
|
+
this.selectedIndex = 0;
|
|
385
|
+
this.viewportStart = 0;
|
|
386
|
+
this.update();
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
this.clampSelection();
|
|
391
|
+
if (direction === -1 && this.selectedIndex === 0) {
|
|
392
|
+
this.deactivate();
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
this.selectedIndex = direction === 1
|
|
396
|
+
? Math.min(max, this.selectedIndex + 1)
|
|
397
|
+
: Math.max(0, this.selectedIndex - 1);
|
|
398
|
+
this.ensureSelectionVisible();
|
|
399
|
+
this.update();
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Handle normalized wheel input from the registered widget. */
|
|
404
|
+
handleMouse(event: FleetMouseEvent): FleetMouseResult | undefined {
|
|
405
|
+
if (!this.enabled || !this.ui || this.viewerClose) return undefined;
|
|
406
|
+
if (event.type !== "wheel") return undefined;
|
|
407
|
+
|
|
408
|
+
const wheelDelta = event.wheelDelta;
|
|
409
|
+
const direction: -1 | 1 | undefined = wheelDelta == null
|
|
410
|
+
? undefined
|
|
411
|
+
: wheelDelta < 0
|
|
412
|
+
? -1
|
|
413
|
+
: wheelDelta > 0
|
|
414
|
+
? 1
|
|
415
|
+
: undefined;
|
|
416
|
+
if (!direction || !this.moveSelection(direction)) return undefined;
|
|
417
|
+
|
|
418
|
+
// The widget handled the normalized wheel, including at selection
|
|
419
|
+
// boundaries; explicitly request a redraw so it never falls through to
|
|
420
|
+
// Pi's transcript fallback.
|
|
421
|
+
return { handled: true, render: true };
|
|
422
|
+
}
|
|
423
|
+
|
|
242
424
|
/** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
|
|
243
425
|
handleKey(data: string): { consume?: boolean; data?: string } | undefined {
|
|
244
426
|
if (!this.enabled || !this.ui) return undefined;
|
|
@@ -257,31 +439,17 @@ export class FleetList {
|
|
|
257
439
|
return undefined;
|
|
258
440
|
}
|
|
259
441
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return { consume: true };
|
|
268
|
-
}
|
|
269
|
-
return undefined;
|
|
270
|
-
}
|
|
442
|
+
const direction: -1 | 1 | undefined = matchesKey(data, "down") || matchesKey(data, "j")
|
|
443
|
+
|| (!this.active && matchesKey(data, "left"))
|
|
444
|
+
? 1
|
|
445
|
+
: matchesKey(data, "up") || matchesKey(data, "k")
|
|
446
|
+
? -1
|
|
447
|
+
: undefined;
|
|
448
|
+
if (direction && this.moveSelection(direction)) return { consume: true };
|
|
271
449
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.selectedIndex = Math.min(max, this.selectedIndex + 1);
|
|
276
|
-
this.update();
|
|
277
|
-
return { consume: true };
|
|
278
|
-
}
|
|
279
|
-
if (matchesKey(data, "up")) {
|
|
280
|
-
if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
|
|
281
|
-
this.selectedIndex -= 1;
|
|
282
|
-
this.update();
|
|
283
|
-
return { consume: true };
|
|
284
|
-
}
|
|
450
|
+
if (!this.active) return undefined;
|
|
451
|
+
|
|
452
|
+
// Active — Enter opens, Esc / Up-past-top exits.
|
|
285
453
|
if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
|
|
286
454
|
if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
|
|
287
455
|
|
|
@@ -306,6 +474,7 @@ export class FleetList {
|
|
|
306
474
|
private deactivate(): void {
|
|
307
475
|
this.active = false;
|
|
308
476
|
this.selectedIndex = 0;
|
|
477
|
+
this.viewportStart = 0;
|
|
309
478
|
this.update();
|
|
310
479
|
}
|
|
311
480
|
|
|
@@ -383,12 +552,12 @@ export class FleetList {
|
|
|
383
552
|
|
|
384
553
|
const hint = this.active
|
|
385
554
|
? "↑↓ select · enter view · esc back"
|
|
386
|
-
: "esc to interrupt ·
|
|
555
|
+
: "esc to interrupt · ↓ to manage";
|
|
387
556
|
const lines: string[] = [];
|
|
388
557
|
lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
|
|
389
558
|
lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
|
|
390
559
|
|
|
391
|
-
const window = calculateFleetAgentWindow(agents.length,
|
|
560
|
+
const window = calculateFleetAgentWindow(agents.length, this.viewportStart);
|
|
392
561
|
if (window.hiddenAbove > 0) {
|
|
393
562
|
lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
|
|
394
563
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { AgentManager } from "../../src/agent-manager.js";
|
|
3
3
|
import {
|
|
4
4
|
AgentWidget,
|
|
@@ -48,6 +48,25 @@ function renderWidget(
|
|
|
48
48
|
return { render, widget };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function createWidgetHarness(records: AgentRecord[]) {
|
|
52
|
+
const manager = { listAgents: () => records } as unknown as AgentManager;
|
|
53
|
+
let factory: ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }) | undefined;
|
|
54
|
+
const tui = {
|
|
55
|
+
terminal: { columns: 120, rows: 24 },
|
|
56
|
+
requestRender: vi.fn(),
|
|
57
|
+
};
|
|
58
|
+
const ui = {
|
|
59
|
+
setStatus: vi.fn(),
|
|
60
|
+
setWidget: vi.fn((_key: string, content: any) => { factory = content; }),
|
|
61
|
+
};
|
|
62
|
+
const widget = new AgentWidget(manager, new Map(), () => "all");
|
|
63
|
+
widget.setUICtx(ui);
|
|
64
|
+
widget.update();
|
|
65
|
+
factory?.(tui, theme).render();
|
|
66
|
+
tui.requestRender.mockClear();
|
|
67
|
+
return { records, tui, ui, widget, render: () => factory?.(tui, theme).render() ?? [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
describe("AgentWidget live records", () => {
|
|
52
71
|
it("keeps restored history in the live widget and manager history", () => {
|
|
53
72
|
const manager = new AgentManager();
|
|
@@ -120,4 +139,34 @@ describe("AgentWidget live records", () => {
|
|
|
120
139
|
expect(getWidgetLineBudget(9)).toBe(5);
|
|
121
140
|
expect(getWidgetLineBudget(100)).toBe(MAX_WIDGET_LINES);
|
|
122
141
|
});
|
|
142
|
+
|
|
143
|
+
it("does not request a render when a live snapshot is unchanged", () => {
|
|
144
|
+
vi.useFakeTimers();
|
|
145
|
+
vi.setSystemTime(10_000);
|
|
146
|
+
try {
|
|
147
|
+
const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
|
|
148
|
+
const { widget, tui } = createWidgetHarness([running]);
|
|
149
|
+
|
|
150
|
+
widget.update();
|
|
151
|
+
expect(tui.requestRender).not.toHaveBeenCalled();
|
|
152
|
+
|
|
153
|
+
running.description = "changed description";
|
|
154
|
+
widget.update();
|
|
155
|
+
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
|
156
|
+
widget.dispose();
|
|
157
|
+
} finally {
|
|
158
|
+
vi.useRealTimers();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("exposes a safe render-only refresh target", () => {
|
|
163
|
+
const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
|
|
164
|
+
const { widget, tui } = createWidgetHarness([running]);
|
|
165
|
+
tui.requestRender.mockClear();
|
|
166
|
+
|
|
167
|
+
expect((widget as any).requestUiRefresh(true)).toBe(true);
|
|
168
|
+
expect(tui.requestRender).toHaveBeenCalledWith(true);
|
|
169
|
+
widget.dispose();
|
|
170
|
+
expect((widget as any).requestUiRefresh(true)).toBe(false);
|
|
171
|
+
});
|
|
123
172
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createDeferredUiRefresh } from "../../src/ui/deferred-ui-refresh.js";
|
|
3
|
+
|
|
4
|
+
describe("createDeferredUiRefresh", () => {
|
|
5
|
+
afterEach(() => vi.useRealTimers());
|
|
6
|
+
|
|
7
|
+
it("fires once after the debounce delay", () => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
const requestRender = vi.fn();
|
|
10
|
+
const refresh = createDeferredUiRefresh(requestRender);
|
|
11
|
+
|
|
12
|
+
refresh.schedule();
|
|
13
|
+
vi.advanceTimersByTime(49);
|
|
14
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
15
|
+
vi.advanceTimersByTime(1);
|
|
16
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("coalesces repeated schedules and respects the max wait", () => {
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
const requestRender = vi.fn();
|
|
22
|
+
const refresh = createDeferredUiRefresh(requestRender);
|
|
23
|
+
|
|
24
|
+
refresh.schedule();
|
|
25
|
+
vi.advanceTimersByTime(40);
|
|
26
|
+
refresh.schedule();
|
|
27
|
+
vi.advanceTimersByTime(40);
|
|
28
|
+
refresh.schedule();
|
|
29
|
+
vi.advanceTimersByTime(40);
|
|
30
|
+
refresh.schedule();
|
|
31
|
+
vi.advanceTimersByTime(40);
|
|
32
|
+
refresh.schedule();
|
|
33
|
+
vi.advanceTimersByTime(39);
|
|
34
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
35
|
+
vi.advanceTimersByTime(1);
|
|
36
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("supports cancel and dispose without firing a stale callback", () => {
|
|
40
|
+
vi.useFakeTimers();
|
|
41
|
+
const requestRender = vi.fn();
|
|
42
|
+
const refresh = createDeferredUiRefresh(requestRender);
|
|
43
|
+
|
|
44
|
+
refresh.schedule();
|
|
45
|
+
refresh.cancel();
|
|
46
|
+
vi.advanceTimersByTime(250);
|
|
47
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
48
|
+
|
|
49
|
+
refresh.schedule();
|
|
50
|
+
refresh.dispose();
|
|
51
|
+
refresh.schedule();
|
|
52
|
+
vi.advanceTimersByTime(250);
|
|
53
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
});
|