@esso0428/pi-subagents 0.15.15 → 0.15.16
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 +8 -0
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +68 -8
- 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/fleet-list.ts +34 -1
- package/test/ui/agent-widget.test.ts +39 -1
- package/test/ui/fleet-list.test.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.16] - 2026-09-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Live panel rendering now coalesces refreshes and avoids rebuilding unchanged rows**, keeping FleetView, the conversation timeline, and tool hover interactions responsive during frequent agent updates.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Same-name tool results remain paired with their originating tool calls when a conversation is refreshed.
|
|
17
|
+
|
|
10
18
|
## [0.15.15] - 2026-09-13
|
|
11
19
|
|
|
12
20
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esso0428/pi-subagents",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.16",
|
|
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/ui/agent-widget.ts
CHANGED
|
@@ -240,6 +240,8 @@ export class AgentWidget {
|
|
|
240
240
|
private tui: any | undefined;
|
|
241
241
|
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
242
242
|
private lastStatusText: string | undefined;
|
|
243
|
+
/** Snapshot of the state used for the last widget registration/render request. */
|
|
244
|
+
private lastRenderKey: string | undefined;
|
|
243
245
|
|
|
244
246
|
constructor(
|
|
245
247
|
private manager: AgentManager,
|
|
@@ -281,6 +283,7 @@ export class AgentWidget {
|
|
|
281
283
|
this.widgetRegistered = false;
|
|
282
284
|
this.tui = undefined;
|
|
283
285
|
this.lastStatusText = undefined;
|
|
286
|
+
this.lastRenderKey = undefined;
|
|
284
287
|
}
|
|
285
288
|
}
|
|
286
289
|
|
|
@@ -297,10 +300,20 @@ export class AgentWidget {
|
|
|
297
300
|
this.update();
|
|
298
301
|
}
|
|
299
302
|
|
|
300
|
-
/**
|
|
303
|
+
/** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
|
|
301
304
|
ensureTimer() {
|
|
305
|
+
if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running")) return;
|
|
302
306
|
if (!this.widgetInterval) {
|
|
303
|
-
this.widgetInterval = setInterval(() => this.update(), 80);
|
|
307
|
+
this.widgetInterval = setInterval(() => this.update(true), 80);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private syncTimer(shouldRun: boolean): void {
|
|
312
|
+
if (shouldRun) {
|
|
313
|
+
this.ensureTimer();
|
|
314
|
+
} else if (this.widgetInterval) {
|
|
315
|
+
clearInterval(this.widgetInterval);
|
|
316
|
+
this.widgetInterval = undefined;
|
|
304
317
|
}
|
|
305
318
|
}
|
|
306
319
|
|
|
@@ -505,8 +518,43 @@ export class AgentWidget {
|
|
|
505
518
|
return lines;
|
|
506
519
|
}
|
|
507
520
|
|
|
508
|
-
/**
|
|
509
|
-
|
|
521
|
+
/** Build a render-relevant snapshot without capturing mutable records. */
|
|
522
|
+
private renderKey(allAgents: ReturnType<AgentManager["listAgents"]>): string {
|
|
523
|
+
const activities = allAgents.map(a => {
|
|
524
|
+
const activity = this.agentActivity.get(a.id);
|
|
525
|
+
return activity ? {
|
|
526
|
+
id: a.id,
|
|
527
|
+
activeTools: [...activity.activeTools.entries()],
|
|
528
|
+
toolUses: activity.toolUses,
|
|
529
|
+
responseText: activity.responseText,
|
|
530
|
+
turnCount: activity.turnCount,
|
|
531
|
+
maxTurns: activity.maxTurns,
|
|
532
|
+
lifetimeUsage: activity.lifetimeUsage,
|
|
533
|
+
contextPercent: getSessionContextPercent(activity.session),
|
|
534
|
+
} : undefined;
|
|
535
|
+
});
|
|
536
|
+
return JSON.stringify({
|
|
537
|
+
frame: this.widgetFrame,
|
|
538
|
+
agents: allAgents.map(a => ({
|
|
539
|
+
id: a.id,
|
|
540
|
+
type: a.type,
|
|
541
|
+
description: a.description,
|
|
542
|
+
status: a.status,
|
|
543
|
+
toolUses: a.toolUses,
|
|
544
|
+
startedAt: a.startedAt,
|
|
545
|
+
completedAt: a.completedAt,
|
|
546
|
+
error: a.error,
|
|
547
|
+
lifetimeUsage: a.lifetimeUsage,
|
|
548
|
+
compactionCount: a.compactionCount,
|
|
549
|
+
isBackground: a.isBackground,
|
|
550
|
+
})),
|
|
551
|
+
activities,
|
|
552
|
+
finishedTurnAge: [...this.finishedTurnAge.entries()],
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
|
|
557
|
+
update(advanceSpinner = false) {
|
|
510
558
|
if (!this.uiCtx) return;
|
|
511
559
|
const allAgents = this.widgetAgents();
|
|
512
560
|
|
|
@@ -532,7 +580,8 @@ export class AgentWidget {
|
|
|
532
580
|
this.uiCtx.setStatus("subagents", undefined);
|
|
533
581
|
this.lastStatusText = undefined;
|
|
534
582
|
}
|
|
535
|
-
|
|
583
|
+
this.lastRenderKey = undefined;
|
|
584
|
+
this.syncTimer(false);
|
|
536
585
|
// Clean up stale entries
|
|
537
586
|
for (const [id] of this.finishedTurnAge) {
|
|
538
587
|
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
@@ -554,7 +603,12 @@ export class AgentWidget {
|
|
|
554
603
|
this.lastStatusText = newStatusText;
|
|
555
604
|
}
|
|
556
605
|
|
|
557
|
-
|
|
606
|
+
// Spinner animation is driven only by the timer while a visible running
|
|
607
|
+
// agent exists. Event-driven updates refresh changed stats without making
|
|
608
|
+
// an otherwise idle widget advance.
|
|
609
|
+
if (advanceSpinner && runningCount > 0) this.widgetFrame++;
|
|
610
|
+
this.syncTimer(runningCount > 0);
|
|
611
|
+
const renderKey = this.renderKey(allAgents);
|
|
558
612
|
|
|
559
613
|
// Register widget callback once; subsequent updates use requestRender()
|
|
560
614
|
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
@@ -567,14 +621,17 @@ export class AgentWidget {
|
|
|
567
621
|
// Theme changed — force re-registration so factory captures fresh theme.
|
|
568
622
|
this.widgetRegistered = false;
|
|
569
623
|
this.tui = undefined;
|
|
624
|
+
this.lastRenderKey = undefined;
|
|
570
625
|
},
|
|
571
626
|
};
|
|
572
627
|
}, { placement: "aboveEditor" });
|
|
573
628
|
this.widgetRegistered = true;
|
|
574
|
-
} else {
|
|
575
|
-
// Widget already registered —
|
|
629
|
+
} else if (renderKey !== this.lastRenderKey) {
|
|
630
|
+
// Widget already registered — request a re-render only when visible state
|
|
631
|
+
// changed (or the spinner timer advanced).
|
|
576
632
|
this.tui?.requestRender();
|
|
577
633
|
}
|
|
634
|
+
this.lastRenderKey = renderKey;
|
|
578
635
|
}
|
|
579
636
|
|
|
580
637
|
dispose() {
|
|
@@ -589,5 +646,8 @@ export class AgentWidget {
|
|
|
589
646
|
this.widgetRegistered = false;
|
|
590
647
|
this.tui = undefined;
|
|
591
648
|
this.lastStatusText = undefined;
|
|
649
|
+
this.lastRenderKey = undefined;
|
|
650
|
+
this.uiCtx = undefined;
|
|
651
|
+
this.finishedTurnAge.clear();
|
|
592
652
|
}
|
|
593
653
|
}
|
|
@@ -300,23 +300,25 @@ export function formatConversationMessages(
|
|
|
300
300
|
const blocks: ConversationBlock[] = [];
|
|
301
301
|
const usedBlockIds = new Set<string>();
|
|
302
302
|
const callsById = new Map<string, ConversationBlock>();
|
|
303
|
-
const callsByName = new Map<string, ConversationBlock[]>();
|
|
303
|
+
const callsByName = new Map<string, { blocks: ConversationBlock[]; next: number }>();
|
|
304
304
|
|
|
305
305
|
const registerCall = (name: string, block: ConversationBlock) => {
|
|
306
306
|
callsById.set(block.toolCallId!, block);
|
|
307
307
|
const nameKey = name.toLowerCase();
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
callsByName.set(nameKey,
|
|
308
|
+
const queue = callsByName.get(nameKey) ?? { blocks: [], next: 0 };
|
|
309
|
+
queue.blocks.push(block);
|
|
310
|
+
callsByName.set(nameKey, queue);
|
|
311
311
|
};
|
|
312
312
|
const unregisterCall = (block: ConversationBlock) => {
|
|
313
|
+
// Calls remain in their name queue as tombstones. Advancing its cursor is
|
|
314
|
+
// amortized O(1), unlike removing from the middle of a same-name array.
|
|
313
315
|
if (block.toolCallId) callsById.delete(block.toolCallId);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
};
|
|
317
|
+
const nextCallByName = (name: string): ConversationBlock | undefined => {
|
|
318
|
+
const queue = callsByName.get(name.toLowerCase());
|
|
319
|
+
if (!queue) return undefined;
|
|
320
|
+
while (queue.next < queue.blocks.length && queue.blocks[queue.next]?.toolStatus !== "pending") queue.next++;
|
|
321
|
+
return queue.blocks[queue.next];
|
|
320
322
|
};
|
|
321
323
|
|
|
322
324
|
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
@@ -368,7 +370,7 @@ export function formatConversationMessages(
|
|
|
368
370
|
const direct = result.toolCallId ? callsById.get(result.toolCallId) : undefined;
|
|
369
371
|
let match = direct;
|
|
370
372
|
if (!match && result.toolName) {
|
|
371
|
-
match =
|
|
373
|
+
match = nextCallByName(result.toolName);
|
|
372
374
|
}
|
|
373
375
|
if (match) {
|
|
374
376
|
applyToolResult(match, result, maxToolResultChars);
|
|
@@ -59,10 +59,21 @@ export interface ConversationTimelineMouseResult {
|
|
|
59
59
|
focusedBlockIndex?: number;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export interface ConversationTimelineLineRange {
|
|
63
|
+
startLine: number;
|
|
64
|
+
height: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConversationTimelineChange {
|
|
68
|
+
kind: "content" | "interaction";
|
|
69
|
+
/** Lines patched in-place for an interaction-only change such as hover. */
|
|
70
|
+
changedRanges?: readonly ConversationTimelineLineRange[];
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
export interface ConversationTimelineOptions {
|
|
63
74
|
cwd?: string;
|
|
64
75
|
record?: AgentRecord;
|
|
65
|
-
onChange?: () => void;
|
|
76
|
+
onChange?: (change: ConversationTimelineChange) => void;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
function safeWidth(width: number): number {
|
|
@@ -157,6 +168,7 @@ export class ConversationTimeline implements Component {
|
|
|
157
168
|
private expandedTools = new Set<string>();
|
|
158
169
|
private hoveredToolCallId: string | undefined;
|
|
159
170
|
private hoveredIoView: any;
|
|
171
|
+
private hoveredIoToolCallId: string | undefined;
|
|
160
172
|
private hoveredIoSection: "input" | "output" | null = null;
|
|
161
173
|
private lastClickedToolCallId: string | undefined;
|
|
162
174
|
private lastClickedPoint: { x: number; y: number } | undefined;
|
|
@@ -182,6 +194,12 @@ export class ConversationTimeline implements Component {
|
|
|
182
194
|
const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
|
|
183
195
|
if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
|
|
184
196
|
if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
|
|
197
|
+
if (this.hoveredIoToolCallId && !ids.has(this.hoveredIoToolCallId)) {
|
|
198
|
+
this.hoveredIoView?.setHoveredSection?.(null);
|
|
199
|
+
this.hoveredIoView = undefined;
|
|
200
|
+
this.hoveredIoToolCallId = undefined;
|
|
201
|
+
this.hoveredIoSection = null;
|
|
202
|
+
}
|
|
185
203
|
for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
|
|
186
204
|
for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
|
|
187
205
|
this.invalidate();
|
|
@@ -193,6 +211,10 @@ export class ConversationTimeline implements Component {
|
|
|
193
211
|
if (!show) {
|
|
194
212
|
this.focusedToolCallId = undefined;
|
|
195
213
|
this.hoveredToolCallId = undefined;
|
|
214
|
+
this.hoveredIoView?.setHoveredSection?.(null);
|
|
215
|
+
this.hoveredIoView = undefined;
|
|
216
|
+
this.hoveredIoToolCallId = undefined;
|
|
217
|
+
this.hoveredIoSection = null;
|
|
196
218
|
}
|
|
197
219
|
this.invalidate();
|
|
198
220
|
}
|
|
@@ -202,8 +224,10 @@ export class ConversationTimeline implements Component {
|
|
|
202
224
|
}
|
|
203
225
|
|
|
204
226
|
setFocusedToolCallId(id: string | undefined): void {
|
|
227
|
+
// Focus changes are view state. The rendered rows do not change, so keep
|
|
228
|
+
// the snapshot and its Markdown/tool output cache intact.
|
|
229
|
+
if (this.focusedToolCallId === id) return;
|
|
205
230
|
this.focusedToolCallId = id;
|
|
206
|
-
this.invalidate();
|
|
207
231
|
}
|
|
208
232
|
|
|
209
233
|
getFocusedToolCallId(): string | undefined {
|
|
@@ -223,8 +247,7 @@ export class ConversationTimeline implements Component {
|
|
|
223
247
|
? direction > 0 ? 0 : ids.length - 1
|
|
224
248
|
: (current + direction + ids.length) % ids.length;
|
|
225
249
|
this.focusedToolCallId = ids[next];
|
|
226
|
-
this.
|
|
227
|
-
this.options.onChange?.();
|
|
250
|
+
this.options.onChange?.({ kind: "interaction" });
|
|
228
251
|
return true;
|
|
229
252
|
}
|
|
230
253
|
|
|
@@ -234,7 +257,7 @@ export class ConversationTimeline implements Component {
|
|
|
234
257
|
if (this.expandedTools.has(id)) this.expandedTools.delete(id);
|
|
235
258
|
else this.expandedTools.add(id);
|
|
236
259
|
this.invalidate();
|
|
237
|
-
this.options.onChange?.();
|
|
260
|
+
this.options.onChange?.({ kind: "content" });
|
|
238
261
|
return true;
|
|
239
262
|
}
|
|
240
263
|
|
|
@@ -244,21 +267,24 @@ export class ConversationTimeline implements Component {
|
|
|
244
267
|
|
|
245
268
|
render(width: number): string[] {
|
|
246
269
|
const requestedWidth = safeWidth(width);
|
|
270
|
+
if (this.lastWidth === requestedWidth) return this.snapshot.lines.map((line) => line.text);
|
|
271
|
+
|
|
247
272
|
const lines: TimelineRenderLine[] = [];
|
|
248
273
|
const messageStarts: number[] = [];
|
|
249
274
|
const messageBlocks: ConversationBlock[] = [];
|
|
250
275
|
const interactiveToolIds: string[] = [];
|
|
251
276
|
const blockSpans: TimelineBlockSpan[] = [];
|
|
252
277
|
const toolSpans: TimelineToolSpan[] = [];
|
|
253
|
-
const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
|
|
254
278
|
|
|
255
|
-
if (
|
|
279
|
+
if (this.blocks.length === 0 || (!this.showTools && this.blocks.every((block) => block.kind === "tool"))) {
|
|
256
280
|
const text = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
|
|
257
281
|
lines.push({ text: this.theme.fg("dim", text), plain: text, blockIndex: -1 });
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
for (
|
|
261
|
-
const
|
|
284
|
+
for (let sourceBlockIndex = 0; sourceBlockIndex < this.blocks.length; sourceBlockIndex++) {
|
|
285
|
+
const block = this.blocks[sourceBlockIndex]!;
|
|
286
|
+
if (!this.showTools && block.kind === "tool") continue;
|
|
287
|
+
const blockIndex = sourceBlockIndex;
|
|
262
288
|
if (lines.length > 0) {
|
|
263
289
|
const separator = this.theme.fg("border", " " + "─ ".repeat(Math.max(1, Math.floor((requestedWidth - 6) / 2))).trimEnd());
|
|
264
290
|
lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
|
|
@@ -277,51 +303,11 @@ export class ConversationTimeline implements Component {
|
|
|
277
303
|
if (block.kind === "tool") {
|
|
278
304
|
const id = block.toolCallId;
|
|
279
305
|
if (id) interactiveToolIds.push(id);
|
|
280
|
-
|
|
281
|
-
if (id) {
|
|
282
|
-
const existing = this.toolComponents.get(id);
|
|
283
|
-
if (existing) {
|
|
284
|
-
applyToolComponentState(existing, block, this.expandedTools.has(id));
|
|
285
|
-
component = existing;
|
|
286
|
-
} else {
|
|
287
|
-
component = createToolComponent(
|
|
288
|
-
block,
|
|
289
|
-
this.tui,
|
|
290
|
-
this.options.cwd ?? process.cwd(),
|
|
291
|
-
this.expandedTools.has(id),
|
|
292
|
-
() => this.hoveredToolCallId === id,
|
|
293
|
-
) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
294
|
-
if (component) this.toolComponents.set(id, component);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
let rendered: string[];
|
|
298
|
-
try {
|
|
299
|
-
rendered = component?.render(requestedWidth) ?? fallbackToolLines(
|
|
300
|
-
block,
|
|
301
|
-
requestedWidth,
|
|
302
|
-
id ? this.expandedTools.has(id) : false,
|
|
303
|
-
id ? this.hoveredToolCallId === id : false,
|
|
304
|
-
this.theme,
|
|
305
|
-
);
|
|
306
|
-
} catch {
|
|
307
|
-
rendered = fallbackToolLines(
|
|
308
|
-
block,
|
|
309
|
-
requestedWidth,
|
|
310
|
-
id ? this.expandedTools.has(id) : false,
|
|
311
|
-
id ? this.hoveredToolCallId === id : false,
|
|
312
|
-
this.theme,
|
|
313
|
-
);
|
|
314
|
-
}
|
|
306
|
+
const { rendered, hintLine } = this.renderToolLines(block, requestedWidth);
|
|
315
307
|
for (const line of rendered) {
|
|
316
308
|
lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
|
|
317
309
|
}
|
|
318
310
|
if (id) {
|
|
319
|
-
const hintLine = !this.expandedTools.has(id)
|
|
320
|
-
? rendered.findIndex((line) => {
|
|
321
|
-
const plain = stripAnsi(line);
|
|
322
|
-
return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
|
|
323
|
-
})
|
|
324
|
-
: -1;
|
|
325
311
|
const blockHeight = lines.length - blockStartLine;
|
|
326
312
|
blockSpans.push({ blockIndex: snapshotBlockIndex, startLine: blockStartLine, height: blockHeight });
|
|
327
313
|
toolSpans.push({
|
|
@@ -363,6 +349,79 @@ export class ConversationTimeline implements Component {
|
|
|
363
349
|
return lines.map((line) => line.text);
|
|
364
350
|
}
|
|
365
351
|
|
|
352
|
+
private renderToolLines(block: ConversationBlock, width: number): { rendered: string[]; hintLine: number } {
|
|
353
|
+
const id = block.toolCallId;
|
|
354
|
+
let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
355
|
+
if (id) {
|
|
356
|
+
const existing = this.toolComponents.get(id);
|
|
357
|
+
if (existing) {
|
|
358
|
+
applyToolComponentState(existing, block, this.expandedTools.has(id));
|
|
359
|
+
component = existing;
|
|
360
|
+
} else {
|
|
361
|
+
component = createToolComponent(
|
|
362
|
+
block,
|
|
363
|
+
this.tui,
|
|
364
|
+
this.options.cwd ?? process.cwd(),
|
|
365
|
+
this.expandedTools.has(id),
|
|
366
|
+
() => this.hoveredToolCallId === id,
|
|
367
|
+
) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
368
|
+
if (component) this.toolComponents.set(id, component);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let rendered: string[];
|
|
373
|
+
try {
|
|
374
|
+
rendered = component?.render(width) ?? fallbackToolLines(
|
|
375
|
+
block,
|
|
376
|
+
width,
|
|
377
|
+
id ? this.expandedTools.has(id) : false,
|
|
378
|
+
id ? this.hoveredToolCallId === id : false,
|
|
379
|
+
this.theme,
|
|
380
|
+
);
|
|
381
|
+
} catch {
|
|
382
|
+
rendered = fallbackToolLines(
|
|
383
|
+
block,
|
|
384
|
+
width,
|
|
385
|
+
id ? this.expandedTools.has(id) : false,
|
|
386
|
+
id ? this.hoveredToolCallId === id : false,
|
|
387
|
+
this.theme,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const hintLine = id && !this.expandedTools.has(id)
|
|
391
|
+
? rendered.findIndex((line) => {
|
|
392
|
+
const plain = stripAnsi(line);
|
|
393
|
+
return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
|
|
394
|
+
})
|
|
395
|
+
: -1;
|
|
396
|
+
return { rendered, hintLine };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Hover only changes styling in a tool card. Re-render those rows in place
|
|
401
|
+
* and leave the Markdown rows and their spans untouched.
|
|
402
|
+
*/
|
|
403
|
+
private refreshToolSpan(span: TimelineToolSpan): boolean {
|
|
404
|
+
const block = this.snapshot.messageBlocks[span.blockIndex];
|
|
405
|
+
if (!block || block.kind !== "tool") return false;
|
|
406
|
+
const { rendered, hintLine } = this.renderToolLines(block, this.lastWidth);
|
|
407
|
+
if (rendered.length + (block.role === "user" || block.role === "assistant" ? 1 : 0) !== span.height) return false;
|
|
408
|
+
|
|
409
|
+
const headerOffset = block.role === "user" || block.role === "assistant" ? 1 : 0;
|
|
410
|
+
const sourceBlockIndex = this.snapshot.lines[span.startLine]?.blockIndex ?? -1;
|
|
411
|
+
for (let index = 0; index < rendered.length; index++) {
|
|
412
|
+
const line = rendered[index];
|
|
413
|
+
this.snapshot.lines[span.startLine + headerOffset + index] = {
|
|
414
|
+
text: truncateToWidth(line, this.lastWidth),
|
|
415
|
+
plain: stripAnsi(line),
|
|
416
|
+
blockIndex: sourceBlockIndex,
|
|
417
|
+
railable: true,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
span.clickStartLine = hintLine >= 0 ? span.startLine + headerOffset + hintLine : undefined;
|
|
421
|
+
span.clickHeight = hintLine >= 0 ? 1 : undefined;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
366
425
|
handleMouse(event: TuiMouseEvent): ConversationTimelineMouseResult | undefined {
|
|
367
426
|
if (event.type === "move") {
|
|
368
427
|
const hovered = this.snapshot.toolSpans.find((span) =>
|
|
@@ -392,12 +451,35 @@ export class ConversationTimeline implements Component {
|
|
|
392
451
|
if (this.hoveredIoView && this.hoveredIoView !== nextIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
|
|
393
452
|
this.hoveredIoView.setHoveredSection(null);
|
|
394
453
|
}
|
|
454
|
+
const previousHoveredToolCallId = this.hoveredToolCallId;
|
|
455
|
+
const previousIoToolCallId = this.hoveredIoToolCallId;
|
|
456
|
+
const nextIoToolCallId = nextIoView ? fullSpan?.toolCallId : undefined;
|
|
395
457
|
this.hoveredToolCallId = hovered;
|
|
396
458
|
this.hoveredIoView = nextIoView;
|
|
459
|
+
this.hoveredIoToolCallId = nextIoToolCallId;
|
|
397
460
|
this.hoveredIoSection = nextIoSection;
|
|
398
461
|
if (nextIoView) nextIoView.setHoveredSection(nextIoSection);
|
|
399
|
-
|
|
400
|
-
|
|
462
|
+
|
|
463
|
+
const changedIds = new Set(
|
|
464
|
+
[previousHoveredToolCallId, hovered, previousIoToolCallId, nextIoToolCallId]
|
|
465
|
+
.filter((id): id is string => id !== undefined),
|
|
466
|
+
);
|
|
467
|
+
const changedRanges: ConversationTimelineLineRange[] = [];
|
|
468
|
+
let patched = true;
|
|
469
|
+
for (const id of changedIds) {
|
|
470
|
+
const span = this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === id);
|
|
471
|
+
if (!span || !this.refreshToolSpan(span)) {
|
|
472
|
+
patched = false;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
changedRanges.push({ startLine: span.startLine, height: span.height });
|
|
476
|
+
}
|
|
477
|
+
if (!patched) {
|
|
478
|
+
this.invalidate();
|
|
479
|
+
this.options.onChange?.({ kind: "content" });
|
|
480
|
+
} else {
|
|
481
|
+
this.options.onChange?.({ kind: "interaction", changedRanges });
|
|
482
|
+
}
|
|
401
483
|
return { handled: true, render: true };
|
|
402
484
|
}
|
|
403
485
|
|
|
@@ -448,8 +530,12 @@ export class ConversationTimeline implements Component {
|
|
|
448
530
|
// A missing/older Pi mouse implementation must not break the viewer.
|
|
449
531
|
}
|
|
450
532
|
}
|
|
451
|
-
|
|
452
|
-
|
|
533
|
+
if (shouldExpand || shouldCollapse) {
|
|
534
|
+
this.invalidate();
|
|
535
|
+
this.options.onChange?.({ kind: "content" });
|
|
536
|
+
} else {
|
|
537
|
+
this.options.onChange?.({ kind: "interaction" });
|
|
538
|
+
}
|
|
453
539
|
return { handled: true, render: shouldExpand || shouldCollapse, focusedBlockIndex: span.blockIndex };
|
|
454
540
|
}
|
|
455
541
|
|
|
@@ -458,8 +544,7 @@ export class ConversationTimeline implements Component {
|
|
|
458
544
|
this.focusedToolCallId = undefined;
|
|
459
545
|
this.lastClickedToolCallId = undefined;
|
|
460
546
|
this.lastClickedPoint = undefined;
|
|
461
|
-
this.
|
|
462
|
-
this.options.onChange?.();
|
|
547
|
+
this.options.onChange?.({ kind: "interaction" });
|
|
463
548
|
return { handled: true, render: true, focusedBlockIndex: target.blockIndex };
|
|
464
549
|
}
|
|
465
550
|
|
|
@@ -13,7 +13,7 @@ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
|
13
13
|
import type { Theme } from "./agent-widget.js";
|
|
14
14
|
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
15
15
|
import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
|
|
16
|
-
import { ConversationTimeline, type TimelineRenderLine } from "./conversation-timeline.js";
|
|
16
|
+
import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
|
|
17
17
|
import { renderConversationRoleHeader } from "./conversation-role.js";
|
|
18
18
|
import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
|
|
19
19
|
import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
|
|
@@ -156,6 +156,8 @@ export class ConversationViewer implements Component {
|
|
|
156
156
|
private cachedMessageBlocks: ConversationBlock[] = [];
|
|
157
157
|
private cacheVersion = 0;
|
|
158
158
|
private cachedVersion = -1;
|
|
159
|
+
private sessionRefreshQueued = false;
|
|
160
|
+
private selectedBlockId: string | undefined;
|
|
159
161
|
private scrollOffset = 0;
|
|
160
162
|
private autoScroll = true;
|
|
161
163
|
private unsubscribe: (() => void) | undefined;
|
|
@@ -192,21 +194,10 @@ export class ConversationViewer implements Component {
|
|
|
192
194
|
this.timeline = new ConversationTimeline(tui, theme, {
|
|
193
195
|
cwd: operations?.ctx.cwd,
|
|
194
196
|
record,
|
|
195
|
-
onChange: () =>
|
|
196
|
-
this.cacheVersion++;
|
|
197
|
-
this.invalidate();
|
|
198
|
-
this.tui.requestRender();
|
|
199
|
-
},
|
|
197
|
+
onChange: (change) => this.handleTimelineChange(change),
|
|
200
198
|
});
|
|
201
199
|
this.timeline.setBlocks(this.blocks);
|
|
202
|
-
this.unsubscribe = session.subscribe(() =>
|
|
203
|
-
if (this.closed) return;
|
|
204
|
-
this.blocks = formatConversationMessages(this.session.messages);
|
|
205
|
-
this.timeline.setBlocks(this.blocks);
|
|
206
|
-
this.cacheVersion++;
|
|
207
|
-
this.invalidate();
|
|
208
|
-
this.tui.requestRender();
|
|
209
|
-
});
|
|
200
|
+
this.unsubscribe = session.subscribe(() => this.scheduleSessionRefresh());
|
|
210
201
|
}
|
|
211
202
|
|
|
212
203
|
handleInput(data: string): void {
|
|
@@ -365,7 +356,6 @@ export class ConversationViewer implements Component {
|
|
|
365
356
|
this.setFocusFromMessageIndex(selected);
|
|
366
357
|
this.autoScroll = false;
|
|
367
358
|
this.scrollTargetIntoView(selected);
|
|
368
|
-
this.invalidate();
|
|
369
359
|
this.tui.requestRender();
|
|
370
360
|
}
|
|
371
361
|
}
|
|
@@ -504,6 +494,39 @@ export class ConversationViewer implements Component {
|
|
|
504
494
|
this.cachedVersion = -1;
|
|
505
495
|
}
|
|
506
496
|
|
|
497
|
+
private scheduleSessionRefresh(): void {
|
|
498
|
+
if (this.closed || this.sessionRefreshQueued) return;
|
|
499
|
+
this.sessionRefreshQueued = true;
|
|
500
|
+
queueMicrotask(() => {
|
|
501
|
+
this.sessionRefreshQueued = false;
|
|
502
|
+
if (this.closed) return;
|
|
503
|
+
if (this.hasFocusedBlock) this.selectedBlockId = this.currentBlock()?.id;
|
|
504
|
+
this.blocks = formatConversationMessages(this.session.messages);
|
|
505
|
+
this.timeline.setBlocks(this.blocks);
|
|
506
|
+
this.cacheVersion++;
|
|
507
|
+
this.invalidate();
|
|
508
|
+
this.tui.requestRender();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private handleTimelineChange(change: ConversationTimelineChange): void {
|
|
513
|
+
if (change.kind === "content") {
|
|
514
|
+
this.cacheVersion++;
|
|
515
|
+
this.invalidate();
|
|
516
|
+
} else if (change.changedRanges && this.cachedWidth > 0 && this.cachedVersion === this.cacheVersion) {
|
|
517
|
+
const snapshotLines = this.timeline.getSnapshot().lines;
|
|
518
|
+
for (const range of change.changedRanges) {
|
|
519
|
+
const start = Math.max(0, range.startLine);
|
|
520
|
+
const end = Math.min(this.cachedLines.length, start + Math.max(0, range.height));
|
|
521
|
+
for (let index = start; index < end; index++) {
|
|
522
|
+
const line = snapshotLines[index];
|
|
523
|
+
if (line) this.cachedLines[index] = { ...line };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
this.tui.requestRender();
|
|
528
|
+
}
|
|
529
|
+
|
|
507
530
|
dispose(): void {
|
|
508
531
|
this.closed = true;
|
|
509
532
|
this.unsubscribe?.();
|
|
@@ -549,6 +572,19 @@ export class ConversationViewer implements Component {
|
|
|
549
572
|
const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
|
|
550
573
|
this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
|
|
551
574
|
this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
|
|
575
|
+
if (this.hasFocusedBlock) {
|
|
576
|
+
const selectedIndex = this.selectedBlockId
|
|
577
|
+
? this.cachedMessageBlocks.findIndex((block) => block.id === this.selectedBlockId)
|
|
578
|
+
: -1;
|
|
579
|
+
const focusedToolIndex = this.timeline.getFocusedToolCallId()
|
|
580
|
+
? this.cachedMessageBlocks.findIndex((block) => block.toolCallId === this.timeline.getFocusedToolCallId())
|
|
581
|
+
: -1;
|
|
582
|
+
const restoredIndex = selectedIndex >= 0 ? selectedIndex : focusedToolIndex;
|
|
583
|
+
if (restoredIndex >= 0) {
|
|
584
|
+
this.selectedMessageIdx = restoredIndex;
|
|
585
|
+
this.selectedBlockId = this.cachedMessageBlocks[restoredIndex]?.id;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
552
588
|
if (this.record.status === "running" && this.activity) {
|
|
553
589
|
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
554
590
|
const text = `▍ ${act}`;
|
|
@@ -571,6 +607,7 @@ export class ConversationViewer implements Component {
|
|
|
571
607
|
this.selectedMessageIdx = index;
|
|
572
608
|
this.hasFocusedBlock = true;
|
|
573
609
|
const block = this.cachedMessageBlocks[index];
|
|
610
|
+
this.selectedBlockId = block.id;
|
|
574
611
|
this.timeline.setFocusedToolCallId(block.kind === "tool" ? block.toolCallId : undefined);
|
|
575
612
|
return true;
|
|
576
613
|
}
|
|
@@ -617,7 +654,6 @@ export class ConversationViewer implements Component {
|
|
|
617
654
|
this.setFocusFromMessageIndex(target);
|
|
618
655
|
this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
|
|
619
656
|
this.autoScroll = false;
|
|
620
|
-
this.invalidate();
|
|
621
657
|
this.tui.requestRender();
|
|
622
658
|
}
|
|
623
659
|
|
|
@@ -631,7 +667,6 @@ export class ConversationViewer implements Component {
|
|
|
631
667
|
this.autoScroll = false;
|
|
632
668
|
this.scrollTargetIntoView(index);
|
|
633
669
|
}
|
|
634
|
-
this.invalidate();
|
|
635
670
|
this.tui.requestRender();
|
|
636
671
|
}
|
|
637
672
|
|
|
@@ -653,7 +688,6 @@ export class ConversationViewer implements Component {
|
|
|
653
688
|
this.setFocusFromMessageIndex(resolvedTarget);
|
|
654
689
|
this.autoScroll = false;
|
|
655
690
|
this.scrollTargetIntoView(resolvedTarget);
|
|
656
|
-
this.invalidate();
|
|
657
691
|
this.tui.requestRender();
|
|
658
692
|
}
|
|
659
693
|
|
|
@@ -701,7 +735,6 @@ export class ConversationViewer implements Component {
|
|
|
701
735
|
const selected = this.messageIndexForOffset(this.scrollOffset);
|
|
702
736
|
this.setFocusFromMessageIndex(selected);
|
|
703
737
|
this.autoScroll = this.scrollOffset >= max;
|
|
704
|
-
this.invalidate();
|
|
705
738
|
this.tui.requestRender();
|
|
706
739
|
}
|
|
707
740
|
|
|
@@ -709,7 +742,6 @@ export class ConversationViewer implements Component {
|
|
|
709
742
|
this.scrollOffset = 0;
|
|
710
743
|
this.setFocusFromMessageIndex(0);
|
|
711
744
|
this.autoScroll = false;
|
|
712
|
-
this.invalidate();
|
|
713
745
|
this.tui.requestRender();
|
|
714
746
|
}
|
|
715
747
|
|
|
@@ -717,7 +749,6 @@ export class ConversationViewer implements Component {
|
|
|
717
749
|
this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
718
750
|
this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
|
|
719
751
|
this.autoScroll = true;
|
|
720
|
-
this.invalidate();
|
|
721
752
|
this.tui.requestRender();
|
|
722
753
|
}
|
|
723
754
|
|
|
@@ -769,7 +800,6 @@ export class ConversationViewer implements Component {
|
|
|
769
800
|
this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
|
|
770
801
|
this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
|
|
771
802
|
this.autoScroll = false;
|
|
772
|
-
this.invalidate();
|
|
773
803
|
}
|
|
774
804
|
|
|
775
805
|
private async copyCurrentMessage(): Promise<void> {
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -107,6 +107,7 @@ export class FleetList {
|
|
|
107
107
|
private inputUnsub: (() => void) | undefined;
|
|
108
108
|
private widgetRegistered = false;
|
|
109
109
|
private lastRosterKey: string | undefined;
|
|
110
|
+
private lastRenderKey: string | undefined;
|
|
110
111
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
111
112
|
|
|
112
113
|
private enabled = true;
|
|
@@ -142,6 +143,7 @@ export class FleetList {
|
|
|
142
143
|
this.ui = ui;
|
|
143
144
|
this.widgetRegistered = false;
|
|
144
145
|
this.lastRosterKey = undefined;
|
|
146
|
+
this.lastRenderKey = undefined;
|
|
145
147
|
this.tui = undefined;
|
|
146
148
|
this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
|
|
147
149
|
}
|
|
@@ -168,6 +170,7 @@ export class FleetList {
|
|
|
168
170
|
if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
|
|
169
171
|
this.widgetRegistered = false;
|
|
170
172
|
this.lastRosterKey = undefined;
|
|
173
|
+
this.lastRenderKey = undefined;
|
|
171
174
|
this.tui = undefined;
|
|
172
175
|
this.active = false;
|
|
173
176
|
// Null last so a `viewerClose()` microtask above can't re-register the widget.
|
|
@@ -193,11 +196,13 @@ export class FleetList {
|
|
|
193
196
|
this.active = false;
|
|
194
197
|
this.selectedIndex = 0;
|
|
195
198
|
this.lastRosterKey = undefined;
|
|
199
|
+
this.lastRenderKey = undefined;
|
|
196
200
|
return;
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
this.clampSelection();
|
|
200
204
|
this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
|
|
205
|
+
const renderKey = this.renderKey(records);
|
|
201
206
|
|
|
202
207
|
if (!this.widgetRegistered) {
|
|
203
208
|
this.ui.setWidget(FLEET_KEY, (tui, theme) => {
|
|
@@ -208,9 +213,37 @@ export class FleetList {
|
|
|
208
213
|
};
|
|
209
214
|
}, { placement: "belowEditor" });
|
|
210
215
|
this.widgetRegistered = true;
|
|
211
|
-
} else {
|
|
216
|
+
} else if (renderKey !== this.lastRenderKey) {
|
|
217
|
+
// The timer runs more frequently than elapsed time is displayed. Avoid
|
|
218
|
+
// waking the TUI when the visible FleetView snapshot is unchanged, but
|
|
219
|
+
// preserve a forced redraw when rows were added/removed.
|
|
212
220
|
this.tui?.requestRender(rosterChanged);
|
|
213
221
|
}
|
|
222
|
+
this.lastRenderKey = renderKey;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Return only values that affect `renderBar()`. Elapsed time is rendered in
|
|
227
|
+
* whole seconds, so the 200ms timer can skip four out of five updates while
|
|
228
|
+
* still refreshing the row at the visible cadence.
|
|
229
|
+
*/
|
|
230
|
+
private renderKey(records: AgentRecord[]): string {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const agentState = records.map(record => {
|
|
233
|
+
const activity = this.agentActivity.get(record.id);
|
|
234
|
+
const usage = activity?.lifetimeUsage ?? record.lifetimeUsage;
|
|
235
|
+
return [
|
|
236
|
+
record.id,
|
|
237
|
+
record.type,
|
|
238
|
+
record.description,
|
|
239
|
+
record.status,
|
|
240
|
+
record.startedAt,
|
|
241
|
+
record.completedAt ?? "",
|
|
242
|
+
getLifetimeTotal(usage),
|
|
243
|
+
Math.round(((record.completedAt ?? now) - record.startedAt) / 1000),
|
|
244
|
+
].join("\u001f");
|
|
245
|
+
});
|
|
246
|
+
return [this.enabled ? "1" : "0", this.active ? "1" : "0", String(this.selectedIndex), ...agentState].join("\u001e");
|
|
214
247
|
}
|
|
215
248
|
|
|
216
249
|
// ---- Roster ----
|
|
@@ -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,23 @@ 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
|
+
});
|
|
123
161
|
});
|
|
@@ -114,12 +114,16 @@ describe("FleetList bounded window", () => {
|
|
|
114
114
|
expect(last.some(line => /↓ \d+ more/.test(line))).toBe(false);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it("
|
|
117
|
+
it("skips unchanged timer updates but redraws once when the active roster changes", () => {
|
|
118
118
|
const { manager, fleet, tui } = createFleetHarness(makeAgents(5));
|
|
119
119
|
tui.requestRender.mockClear();
|
|
120
|
+
fleet.update();
|
|
121
|
+
expect(tui.requestRender).not.toHaveBeenCalled();
|
|
122
|
+
|
|
120
123
|
manager.records.pop();
|
|
121
124
|
fleet.update();
|
|
122
125
|
expect(tui.requestRender).toHaveBeenCalledWith(true);
|
|
126
|
+
fleet.dispose();
|
|
123
127
|
});
|
|
124
128
|
|
|
125
129
|
it("clears the widget when the active roster becomes empty", () => {
|