@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 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.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": {
@@ -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
- /** Ensure the widget update timer is running. */
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
- /** Force an immediate widget update. */
509
- update() {
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
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
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
- this.widgetFrame++;
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 — just request a re-render of existing components.
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 candidates = callsByName.get(nameKey) ?? [];
309
- candidates.push(block);
310
- callsByName.set(nameKey, candidates);
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
- const nameKey = (block.toolName ?? "tool").toLowerCase();
315
- const candidates = callsByName.get(nameKey);
316
- if (!candidates) return;
317
- const index = candidates.indexOf(block);
318
- if (index >= 0) candidates.splice(index, 1);
319
- if (candidates.length === 0) callsByName.delete(nameKey);
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 = callsByName.get(result.toolName.toLowerCase())?.[0];
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.invalidate();
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 (visibleBlocks.length === 0) {
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 (const block of visibleBlocks) {
261
- const blockIndex = this.blocks.indexOf(block);
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
- let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
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
- this.invalidate();
400
- this.options.onChange?.();
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
- this.invalidate();
452
- this.options.onChange?.();
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.invalidate();
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> {
@@ -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("forces a full render once when the active roster changes", () => {
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", () => {