@esso0428/pi-subagents 0.15.14 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -32
  2. package/dist/ui/agent-widget.d.ts +8 -0
  3. package/dist/ui/agent-widget.d.ts.map +1 -1
  4. package/dist/ui/agent-widget.js +40 -12
  5. package/dist/ui/agent-widget.js.map +1 -1
  6. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  7. package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
  8. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  9. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  10. package/dist/ui/ccstyle/tool-renderer.js +4 -10
  11. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.d.ts +9 -16
  13. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  14. package/dist/ui/ccstyle/tool-result.js +71 -121
  15. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  16. package/dist/ui/conversation-timeline.d.ts +14 -9
  17. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  18. package/dist/ui/conversation-timeline.js +60 -72
  19. package/dist/ui/conversation-timeline.js.map +1 -1
  20. package/dist/ui/conversation-viewer.d.ts +4 -1
  21. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  22. package/dist/ui/conversation-viewer.js +95 -94
  23. package/dist/ui/conversation-viewer.js.map +1 -1
  24. package/dist/write-execution.d.ts +0 -2
  25. package/dist/write-execution.d.ts.map +1 -1
  26. package/dist/write-execution.js +1 -5
  27. package/dist/write-execution.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/ui/agent-widget.ts +106 -20
  30. package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
  31. package/src/ui/ccstyle/tool-renderer.ts +9 -22
  32. package/src/ui/ccstyle/tool-result.ts +82 -186
  33. package/src/ui/conversation-blocks.ts +13 -11
  34. package/src/ui/conversation-timeline.ts +211 -122
  35. package/src/ui/conversation-viewer.ts +149 -125
  36. package/src/ui/fleet-list.ts +34 -1
  37. package/src/write-execution.ts +1 -7
  38. package/test/ui/agent-widget.test.ts +161 -0
  39. package/test/ui/conversation-diff.test.ts +1 -47
  40. package/test/ui/conversation-viewer.test.ts +181 -112
  41. package/test/ui/fleet-list.test.ts +5 -1
  42. package/test/write-execution.test.ts +0 -2
  43. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
  44. package/.pi/workflows/index.json +0 -5
@@ -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";
@@ -22,40 +22,61 @@ import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
22
22
 
23
23
  /** Base lines consumed by the overlay: borders, header, separator and footer. */
24
24
  const CHROME_LINES_BASE = 6;
25
+ const MIN_VIEWPORT = 3;
26
+ export const VIEWPORT_HEIGHT_PCT = 70;
27
+ const MAX_COPY_CHARS = 500_000;
28
+ const MATCH_BG = "\x1b[48;5;238m";
29
+ const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
30
+ const BG_RESET = "\x1b[49m\x1b[39m";
31
+
32
+ /** The live fields needed by the viewer; historical viewers use a static source. */
33
+ export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
34
+
35
+ export function createStaticConversationSource(
36
+ messages: AgentSession["messages"],
37
+ ): ConversationSource {
38
+ return { messages, subscribe: () => () => {} };
39
+ }
40
+
41
+ type RenderLine = TimelineRenderLine;
42
+
25
43
  const FULL_TOOL_PREVIEW_MAX_CHARS = 500_000;
26
44
 
27
45
  class FullToolPreview implements Component {
28
- private readonly body: Component;
29
46
  private scrollOffset = 0;
30
47
  private pageSize = 1;
31
48
  private totalLines = 1;
49
+ private pendingTop = false;
32
50
  private hoveredClose = false;
33
51
 
34
52
  constructor(
35
53
  private readonly tui: TUI,
36
54
  private readonly theme: Theme,
37
55
  private readonly title: string,
38
- body: Component,
56
+ private readonly body: Component,
39
57
  private readonly done: (result: undefined) => void,
40
- ) {
41
- this.body = body;
42
- }
58
+ ) {}
43
59
 
44
60
  handleInput(data: string): void {
45
61
  if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
46
62
  this.done(undefined);
47
63
  return;
48
64
  }
49
- const delta = matchesKey(data, Key.up) ? -1 : matchesKey(data, Key.down) ? 1 :
50
- matchesKey(data, "pageUp") ? -this.pageSize : matchesKey(data, "pageDown") ? this.pageSize :
51
- matchesKey(data, Key.home) ? -this.totalLines : matchesKey(data, Key.end) ? this.totalLines : 0;
52
- if (delta !== 0) {
53
- this.scrollTo(this.scrollOffset + delta);
65
+ if (data === "g") {
66
+ if (this.pendingTop) this.scrollTo(0);
67
+ this.pendingTop = true;
68
+ return;
54
69
  }
70
+ const delta = data === "j" || matchesKey(data, Key.down) ? 1 :
71
+ data === "k" || matchesKey(data, Key.up) ? -1 :
72
+ matchesKey(data, "pageUp") ? -this.pageSize : matchesKey(data, "pageDown") ? this.pageSize :
73
+ matchesKey(data, Key.home) ? -this.totalLines : data === "G" || matchesKey(data, Key.end) ? this.totalLines : 0;
74
+ this.pendingTop = false;
75
+ if (delta !== 0) this.scrollTo(this.scrollOffset + delta);
55
76
  }
56
77
 
57
78
  handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
58
- const closeHit = event.y === 1 && event.x >= Math.max(0, event.width - 7) && event.x < event.width;
79
+ const closeHit = event.y === 1 && event.x >= Math.max(0, event.width - 7) && event.x < Math.max(0, event.width - 1);
59
80
  if (event.type === "move") {
60
81
  if (closeHit === this.hoveredClose) return undefined;
61
82
  this.hoveredClose = closeHit;
@@ -92,8 +113,8 @@ class FullToolPreview implements Component {
92
113
  return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
93
114
  };
94
115
  const close = this.theme.fg(this.hoveredClose ? "text" : "dim", "[esc]");
95
- const header = `${this.theme.bold(this.theme.fg("accent", this.title))}`;
96
- const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · ↑↓ PgUp/PgDn Home/End · [esc] close`;
116
+ const header = this.theme.bold(this.theme.fg("accent", this.title));
117
+ const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · j/k gg/G ↑↓ PgUp/PgDn · [esc] close`;
97
118
  return [
98
119
  border(`╭${"─".repeat(inner)}╮`),
99
120
  `${border("│")}${pad(` ${header}`, inner - visibleWidth(close))}${close}${border("│")}`,
@@ -116,23 +137,6 @@ class FullToolPreview implements Component {
116
137
  this.tui.requestRender();
117
138
  }
118
139
  }
119
- const MIN_VIEWPORT = 3;
120
- export const VIEWPORT_HEIGHT_PCT = 70;
121
- const MAX_COPY_CHARS = 500_000;
122
- const MATCH_BG = "\x1b[48;5;238m";
123
- const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
124
- const BG_RESET = "\x1b[49m\x1b[39m";
125
-
126
- /** The live fields needed by the viewer; historical viewers use a static source. */
127
- export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
128
-
129
- export function createStaticConversationSource(
130
- messages: AgentSession["messages"],
131
- ): ConversationSource {
132
- return { messages, subscribe: () => () => {} };
133
- }
134
-
135
- type RenderLine = TimelineRenderLine;
136
140
 
137
141
  /** Optional operations are appended so all existing constructor call sites stay valid. */
138
142
  export interface ConversationViewerOperations {
@@ -145,12 +149,15 @@ export class ConversationViewer implements Component {
145
149
  private blocks: ConversationBlock[] = [];
146
150
  private showTools = true;
147
151
  private selectedMessageIdx = 0;
152
+ private hasFocusedBlock = false;
148
153
  private cachedWidth = 0;
149
154
  private cachedLines: RenderLine[] = [];
150
155
  private cachedMessageStarts: number[] = [];
151
156
  private cachedMessageBlocks: ConversationBlock[] = [];
152
157
  private cacheVersion = 0;
153
158
  private cachedVersion = -1;
159
+ private sessionRefreshQueued = false;
160
+ private selectedBlockId: string | undefined;
154
161
  private scrollOffset = 0;
155
162
  private autoScroll = true;
156
163
  private unsubscribe: (() => void) | undefined;
@@ -187,21 +194,10 @@ export class ConversationViewer implements Component {
187
194
  this.timeline = new ConversationTimeline(tui, theme, {
188
195
  cwd: operations?.ctx.cwd,
189
196
  record,
190
- onChange: () => {
191
- this.cacheVersion++;
192
- this.invalidate();
193
- this.tui.requestRender();
194
- },
197
+ onChange: (change) => this.handleTimelineChange(change),
195
198
  });
196
199
  this.timeline.setBlocks(this.blocks);
197
- this.unsubscribe = session.subscribe(() => {
198
- if (this.closed) return;
199
- this.blocks = formatConversationMessages(this.session.messages);
200
- this.timeline.setBlocks(this.blocks);
201
- this.cacheVersion++;
202
- this.invalidate();
203
- this.tui.requestRender();
204
- });
200
+ this.unsubscribe = session.subscribe(() => this.scheduleSessionRefresh());
205
201
  }
206
202
 
207
203
  handleInput(data: string): void {
@@ -221,12 +217,6 @@ export class ConversationViewer implements Component {
221
217
  }
222
218
  if (matchesKey(data, "enter")) {
223
219
  this.buildContentLines(this.lastInnerW || 80);
224
- const current = this.currentBlock();
225
- if (current?.kind === "tool" && current.toolCallId) {
226
- this.timeline.setFocusedToolCallId(current.toolCallId);
227
- this.timeline.toggleFocusedTool();
228
- return;
229
- }
230
220
  if (this.timeline.isToolFocused()) {
231
221
  this.timeline.toggleFocusedTool();
232
222
  return;
@@ -301,8 +291,9 @@ export class ConversationViewer implements Component {
301
291
  this.scrollBy(viewportHeight);
302
292
  } else if (matchesKey(data, "home")) {
303
293
  this.scrollOffset = 0;
304
- this.selectedMessageIdx = 0;
294
+ this.setFocusFromMessageIndex(0);
305
295
  this.autoScroll = false;
296
+ this.invalidate();
306
297
  this.tui.requestRender();
307
298
  } else if (matchesKey(data, "end")) {
308
299
  this.scrollToBottom();
@@ -318,17 +309,14 @@ export class ConversationViewer implements Component {
318
309
  if (event.type === "move") {
319
310
  const closeChanged = closeHit !== this.hoveredClose;
320
311
  this.hoveredClose = closeHit;
321
- if (this.composer || this.searchMode) {
322
- return closeChanged ? { handled: true, render: true } : undefined;
323
- }
312
+ if (this.composer || this.searchMode) return closeChanged ? { handled: true, render: true } : undefined;
324
313
  const contentLines = this.buildContentLines(innerW);
325
314
  const viewportHeight = this.viewportHeight();
326
315
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
316
+ if (this.autoScroll) this.scrollOffset = maxScroll;
327
317
  const visibleStart = Math.min(this.scrollOffset, maxScroll);
328
318
  const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
329
319
  const contentY = event.y - contentOrigin + visibleStart;
330
- // Forward moves outside the content too so a previously hovered tool
331
- // hint is cleared when the pointer returns to the header/footer.
332
320
  const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
333
321
  return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
334
322
  }
@@ -346,33 +334,41 @@ export class ConversationViewer implements Component {
346
334
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
347
335
  const visibleStart = Math.min(this.scrollOffset, maxScroll);
348
336
  const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
349
-
350
337
  if (event.type === "wheel") {
351
338
  if (event.y < contentOrigin || event.y >= contentOrigin + viewportHeight) return undefined;
352
- const delta = Math.trunc(event.wheelDelta ?? 0);
353
- if (delta === 0) return { handled: true, render: false };
354
- this.scrollBy(delta);
339
+ this.scrollBy(Math.trunc(event.wheelDelta ?? 0));
355
340
  return { handled: true, render: true };
356
341
  }
357
-
358
342
  const contentY = event.y - contentOrigin + visibleStart;
359
343
  if (contentY < 0 || contentY >= contentLines.length) return undefined;
344
+ // Rows have one leading space and one trailing space inside the frame.
345
+ // Neither padding nor the frame itself is a focus target.
346
+ if ((event.type === "click" || event.type === "press") && (event.x < 2 || event.x >= 2 + innerW)) return undefined;
360
347
  const result = this.timeline.handleMouse({ ...event, y: contentY });
361
- if (result?.handled) {
362
- if (event.type === "click") {
348
+ if (result?.handled && event.type === "click") {
349
+ let selected = result.focusedBlockIndex;
350
+ if (selected === undefined) {
363
351
  const focusedTool = this.timeline.getFocusedToolCallId();
364
- const refreshed = this.timeline.getSnapshot();
365
- const selected = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
366
- if (selected >= 0) this.selectedMessageIdx = selected;
367
- this.cacheVersion++;
368
- this.invalidate();
352
+ selected = this.timeline.getSnapshot().messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
353
+ }
354
+ if (selected !== undefined && selected >= 0) {
355
+ this.buildContentLines(innerW);
356
+ this.setFocusFromMessageIndex(selected);
357
+ this.autoScroll = false;
358
+ this.scrollTargetIntoView(selected);
369
359
  this.tui.requestRender();
370
360
  }
371
- return result;
372
361
  }
373
362
  return result;
374
363
  }
375
364
 
365
+ private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
366
+ if (y !== 1) return false;
367
+ const labelWidth = visibleWidth("[esc]");
368
+ const labelStart = 2 + Math.max(0, innerW - labelWidth);
369
+ return x >= labelStart && x < 2 + innerW;
370
+ }
371
+
376
372
  render(width: number): string[] {
377
373
  if (width < 6) return [];
378
374
  const th = this.theme;
@@ -490,13 +486,6 @@ export class ConversationViewer implements Component {
490
486
  return lines;
491
487
  }
492
488
 
493
- private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
494
- if (y !== 1) return false;
495
- const labelWidth = visibleWidth("[esc]");
496
- const labelStart = 2 + Math.max(0, innerW - labelWidth);
497
- return x >= labelStart && x < 2 + innerW;
498
- }
499
-
500
489
  invalidate(): void {
501
490
  this.cachedWidth = 0;
502
491
  this.cachedLines = [];
@@ -505,6 +494,39 @@ export class ConversationViewer implements Component {
505
494
  this.cachedVersion = -1;
506
495
  }
507
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
+
508
530
  dispose(): void {
509
531
  this.closed = true;
510
532
  this.unsubscribe?.();
@@ -550,6 +572,19 @@ export class ConversationViewer implements Component {
550
572
  const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
551
573
  this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
552
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
+ }
553
588
  if (this.record.status === "running" && this.activity) {
554
589
  const act = describeActivity(this.activity.activeTools, this.activity.responseText);
555
590
  const text = `▍ ${act}`;
@@ -566,6 +601,17 @@ export class ConversationViewer implements Component {
566
601
  return this.cachedMessageStarts.length === 0 ? 0 : Math.max(0, Math.min(this.selectedMessageIdx, this.cachedMessageStarts.length - 1));
567
602
  }
568
603
 
604
+ /** Keep the selected transcript block and the tool focus as one target. */
605
+ private setFocusFromMessageIndex(index: number): boolean {
606
+ if (index < 0 || index >= this.cachedMessageBlocks.length) return false;
607
+ this.selectedMessageIdx = index;
608
+ this.hasFocusedBlock = true;
609
+ const block = this.cachedMessageBlocks[index];
610
+ this.selectedBlockId = block.id;
611
+ this.timeline.setFocusedToolCallId(block.kind === "tool" ? block.toolCallId : undefined);
612
+ return true;
613
+ }
614
+
569
615
  private currentBlockGlobalIndex(): number {
570
616
  const block = this.cachedMessageBlocks[this.currentMessageIndex()];
571
617
  return block ? this.blocks.indexOf(block) : -1;
@@ -598,20 +644,14 @@ export class ConversationViewer implements Component {
598
644
  if (messageIndices.length === 0) return;
599
645
  const current = this.currentMessageIndex();
600
646
  const currentPosition = messageIndices.indexOf(current);
601
- let precedingPosition = -1;
602
- for (let index = 0; index < messageIndices.length; index++) {
603
- if (messageIndices[index] < current) precedingPosition = index;
604
- }
605
- const basePosition = currentPosition >= 0
606
- ? currentPosition
607
- : dir > 0
608
- ? messageIndices.findIndex((index) => index > current)
609
- : precedingPosition;
610
- const position = basePosition < 0 ? (dir > 0 ? 0 : messageIndices.length - 1) : basePosition + dir;
647
+ const precedingPosition = messageIndices.reduce((last, index, position) => index < current ? position : last, -1);
648
+ const nextPosition = messageIndices.findIndex((index) => index > current);
649
+ const position = currentPosition >= 0
650
+ ? currentPosition + dir
651
+ : dir > 0 ? nextPosition : precedingPosition;
611
652
  if (position < 0 || position >= messageIndices.length) return;
612
653
  const target = messageIndices[position];
613
- this.selectedMessageIdx = target;
614
- this.timeline.setFocusedToolCallId(undefined);
654
+ this.setFocusFromMessageIndex(target);
615
655
  this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
616
656
  this.autoScroll = false;
617
657
  this.tui.requestRender();
@@ -620,15 +660,13 @@ export class ConversationViewer implements Component {
620
660
  private moveToolFocus(direction: 1 | -1): void {
621
661
  if (!this.timeline.moveFocus(direction)) return;
622
662
  const focused = this.timeline.getFocusedToolCallId();
623
- const refreshed = this.timeline.getSnapshot();
624
- const index = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focused);
663
+ this.buildContentLines(this.lastInnerW || 80);
664
+ const index = this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focused);
625
665
  if (index >= 0) {
626
- this.selectedMessageIdx = index;
627
- this.buildContentLines(this.lastInnerW || 80);
666
+ this.setFocusFromMessageIndex(index);
628
667
  this.autoScroll = false;
629
668
  this.scrollTargetIntoView(index);
630
669
  }
631
- this.invalidate();
632
670
  this.tui.requestRender();
633
671
  }
634
672
 
@@ -639,24 +677,17 @@ export class ConversationViewer implements Component {
639
677
  .filter((index): index is number => index >= 0);
640
678
  if (toolIndices.length === 0) return;
641
679
 
642
- const focusedId = this.timeline.getFocusedToolCallId();
643
- const focusedIndex = focusedId
644
- ? this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focusedId)
645
- : -1;
646
- const current = focusedIndex >= 0 ? focusedIndex : this.currentMessageIndex();
647
- const target = focusedIndex < 0
680
+ const current = this.currentMessageIndex();
681
+ const resolvedTarget = !this.hasFocusedBlock
648
682
  ? direction > 0 ? toolIndices[0] : toolIndices[toolIndices.length - 1]
649
683
  : direction > 0
650
684
  ? toolIndices.find((index) => index > current)
651
685
  : [...toolIndices].reverse().find((index) => index < current);
652
- if (target === undefined) return;
686
+ if (resolvedTarget === undefined) return;
653
687
 
654
- const block = this.cachedMessageBlocks[target];
655
- this.selectedMessageIdx = target;
688
+ this.setFocusFromMessageIndex(resolvedTarget);
656
689
  this.autoScroll = false;
657
- this.scrollTargetIntoView(target);
658
- this.timeline.setFocusedToolCallId(block.toolCallId);
659
- this.invalidate();
690
+ this.scrollTargetIntoView(resolvedTarget);
660
691
  this.tui.requestRender();
661
692
  }
662
693
 
@@ -672,7 +703,10 @@ export class ConversationViewer implements Component {
672
703
 
673
704
  private openFocusedToolPreview(): void {
674
705
  this.buildContentLines(this.lastInnerW || 80);
675
- const block = this.currentBlock();
706
+ const focusedToolCallId = this.timeline.getFocusedToolCallId();
707
+ const block = focusedToolCallId
708
+ ? this.cachedMessageBlocks.find((candidate) => candidate.toolCallId === focusedToolCallId)
709
+ : this.currentBlock();
676
710
  const custom = this.operations?.ctx?.ui?.custom;
677
711
  if (block?.kind !== "tool" || typeof custom !== "function") return;
678
712
 
@@ -680,50 +714,40 @@ export class ConversationViewer implements Component {
680
714
  const args = block.args ?? block.toolArguments;
681
715
  const result = block.toolResult ?? block.result ?? { content: [] };
682
716
  const title = `${toolName} · full preview`;
683
-
684
717
  void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) => {
685
718
  const body = createViewerCcstyleResult(
686
719
  toolName,
687
720
  result,
688
721
  { expanded: true, isError: result.isError === true },
689
722
  overlayTheme,
690
- {
691
- args,
692
- maxChars: FULL_TOOL_PREVIEW_MAX_CHARS,
693
- diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER },
694
- },
723
+ { args, maxChars: FULL_TOOL_PREVIEW_MAX_CHARS, diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER } },
695
724
  );
696
725
  return new FullToolPreview(tui, overlayTheme as Theme, title, body, done);
697
726
  }, {
698
- overlay: true,
699
- overlayOptions: {
700
- anchor: "center",
701
- width: "85%",
702
- minWidth: 50,
703
- maxHeight: "80%",
704
- margin: 2,
705
- },
706
- }).catch(() => undefined);
727
+ overlay: true,
728
+ overlayOptions: { anchor: "center", width: "85%", minWidth: 50, maxHeight: "80%", margin: 2 },
729
+ }).catch(() => undefined);
707
730
  }
708
731
 
709
732
  private scrollBy(delta: number): void {
710
733
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
711
734
  this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
712
- this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
735
+ const selected = this.messageIndexForOffset(this.scrollOffset);
736
+ this.setFocusFromMessageIndex(selected);
713
737
  this.autoScroll = this.scrollOffset >= max;
714
738
  this.tui.requestRender();
715
739
  }
716
740
 
717
741
  private scrollToTop(): void {
718
742
  this.scrollOffset = 0;
719
- this.selectedMessageIdx = 0;
743
+ this.setFocusFromMessageIndex(0);
720
744
  this.autoScroll = false;
721
745
  this.tui.requestRender();
722
746
  }
723
747
 
724
748
  private scrollToBottom(): void {
725
749
  this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
726
- this.selectedMessageIdx = Math.max(0, this.cachedMessageStarts.length - 1);
750
+ this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
727
751
  this.autoScroll = true;
728
752
  this.tui.requestRender();
729
753
  }
@@ -774,7 +798,7 @@ export class ConversationViewer implements Component {
774
798
  if (!match) return;
775
799
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
776
800
  this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
777
- this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
801
+ this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
778
802
  this.autoScroll = false;
779
803
  }
780
804
 
@@ -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 ----
@@ -9,8 +9,6 @@ export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
9
9
  export const MAX_WRITE_METADATA_ENTRIES = 100;
10
10
 
11
11
  export interface DurableWriteDetails {
12
- /** Persisted so history can distinguish create vs overwrite without file access. */
13
- fileExistedBeforeWrite?: boolean;
14
12
  diff?: string;
15
13
  patch?: string;
16
14
  firstChangedLine?: number;
@@ -108,10 +106,7 @@ function durableDetails(
108
106
  content: string,
109
107
  ): DurableWriteDetails {
110
108
  if (metadata.diffUnavailableReason) {
111
- return {
112
- fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
113
- diffUnavailableReason: metadata.diffUnavailableReason,
114
- };
109
+ return { diffUnavailableReason: metadata.diffUnavailableReason };
115
110
  }
116
111
  const previousContent = metadata.previousContent ?? "";
117
112
  const diffResult = generateDiffString(previousContent, content);
@@ -121,7 +116,6 @@ function durableDetails(
121
116
  return { diffUnavailableReason: `durable diff exceeds ${MAX_DURABLE_DIFF_BYTES} bytes` };
122
117
  }
123
118
  return {
124
- fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
125
119
  diff: diffResult.diff,
126
120
  patch,
127
121
  firstChangedLine: diffResult.firstChangedLine,