@esso0428/pi-subagents 0.15.13 → 0.15.15

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 (41) hide show
  1. package/CHANGELOG.md +6 -31
  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 +17 -11
  11. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.d.ts +22 -10
  13. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  14. package/dist/ui/ccstyle/tool-result.js +160 -73
  15. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  16. package/dist/ui/conversation-timeline.d.ts +16 -9
  17. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  18. package/dist/ui/conversation-timeline.js +85 -61
  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 +107 -110
  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 +38 -12
  30. package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
  31. package/src/ui/ccstyle/tool-renderer.ts +23 -24
  32. package/src/ui/ccstyle/tool-result.ts +167 -107
  33. package/src/ui/conversation-timeline.ts +103 -62
  34. package/src/ui/conversation-viewer.ts +119 -124
  35. package/src/write-execution.ts +1 -7
  36. package/test/ui/agent-widget.test.ts +123 -0
  37. package/test/ui/conversation-diff.test.ts +1 -28
  38. package/test/ui/conversation-viewer.test.ts +181 -112
  39. package/test/write-execution.test.ts +0 -2
  40. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
  41. package/.pi/workflows/index.json +0 -5
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
9
9
  import { copyToClipboard } from "@earendil-works/pi-coding-agent";
10
- import { Text, type Component, Input, Key, matchesKey, type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { type Component, Input, Key, matchesKey, type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
@@ -18,44 +18,65 @@ 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";
20
20
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
21
- import { formatToolInputArgs, textFromResult } from "./ccstyle/tool-result.js";
21
+ 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 text: Text;
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
- content: string,
56
+ private readonly body: Component,
39
57
  private readonly done: (result: undefined) => void,
40
- ) {
41
- this.text = new Text(content || "(empty)", 0, 0);
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;
@@ -81,7 +102,7 @@ class FullToolPreview implements Component {
81
102
  const bodyWidth = Math.max(1, inner - 1);
82
103
  const terminalRows = Math.max(1, this.tui.terminal.rows);
83
104
  const viewport = Math.max(1, Math.min(30, Math.floor(terminalRows * 0.8), terminalRows - 6));
84
- const wrapped = this.text.render(bodyWidth);
105
+ const wrapped = this.body.render(bodyWidth);
85
106
  this.totalLines = wrapped.length;
86
107
  this.pageSize = viewport;
87
108
  this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.totalLines - this.pageSize));
@@ -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("│")}`,
@@ -106,7 +127,7 @@ class FullToolPreview implements Component {
106
127
  }
107
128
 
108
129
  invalidate(): void {
109
- this.text.invalidate();
130
+ this.body.invalidate();
110
131
  }
111
132
 
112
133
  private scrollTo(offset: number): void {
@@ -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,6 +149,7 @@ 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[] = [];
@@ -221,12 +226,6 @@ export class ConversationViewer implements Component {
221
226
  }
222
227
  if (matchesKey(data, "enter")) {
223
228
  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
229
  if (this.timeline.isToolFocused()) {
231
230
  this.timeline.toggleFocusedTool();
232
231
  return;
@@ -301,8 +300,9 @@ export class ConversationViewer implements Component {
301
300
  this.scrollBy(viewportHeight);
302
301
  } else if (matchesKey(data, "home")) {
303
302
  this.scrollOffset = 0;
304
- this.selectedMessageIdx = 0;
303
+ this.setFocusFromMessageIndex(0);
305
304
  this.autoScroll = false;
305
+ this.invalidate();
306
306
  this.tui.requestRender();
307
307
  } else if (matchesKey(data, "end")) {
308
308
  this.scrollToBottom();
@@ -318,17 +318,14 @@ export class ConversationViewer implements Component {
318
318
  if (event.type === "move") {
319
319
  const closeChanged = closeHit !== this.hoveredClose;
320
320
  this.hoveredClose = closeHit;
321
- if (this.composer || this.searchMode) {
322
- return closeChanged ? { handled: true, render: true } : undefined;
323
- }
321
+ if (this.composer || this.searchMode) return closeChanged ? { handled: true, render: true } : undefined;
324
322
  const contentLines = this.buildContentLines(innerW);
325
323
  const viewportHeight = this.viewportHeight();
326
324
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
325
+ if (this.autoScroll) this.scrollOffset = maxScroll;
327
326
  const visibleStart = Math.min(this.scrollOffset, maxScroll);
328
327
  const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
329
328
  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
329
  const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
333
330
  return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
334
331
  }
@@ -346,33 +343,42 @@ export class ConversationViewer implements Component {
346
343
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
347
344
  const visibleStart = Math.min(this.scrollOffset, maxScroll);
348
345
  const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
349
-
350
346
  if (event.type === "wheel") {
351
347
  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);
348
+ this.scrollBy(Math.trunc(event.wheelDelta ?? 0));
355
349
  return { handled: true, render: true };
356
350
  }
357
-
358
351
  const contentY = event.y - contentOrigin + visibleStart;
359
352
  if (contentY < 0 || contentY >= contentLines.length) return undefined;
353
+ // Rows have one leading space and one trailing space inside the frame.
354
+ // Neither padding nor the frame itself is a focus target.
355
+ if ((event.type === "click" || event.type === "press") && (event.x < 2 || event.x >= 2 + innerW)) return undefined;
360
356
  const result = this.timeline.handleMouse({ ...event, y: contentY });
361
- if (result?.handled) {
362
- if (event.type === "click") {
357
+ if (result?.handled && event.type === "click") {
358
+ let selected = result.focusedBlockIndex;
359
+ if (selected === undefined) {
363
360
  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++;
361
+ selected = this.timeline.getSnapshot().messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
362
+ }
363
+ if (selected !== undefined && selected >= 0) {
364
+ this.buildContentLines(innerW);
365
+ this.setFocusFromMessageIndex(selected);
366
+ this.autoScroll = false;
367
+ this.scrollTargetIntoView(selected);
368
368
  this.invalidate();
369
369
  this.tui.requestRender();
370
370
  }
371
- return result;
372
371
  }
373
372
  return result;
374
373
  }
375
374
 
375
+ private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
376
+ if (y !== 1) return false;
377
+ const labelWidth = visibleWidth("[esc]");
378
+ const labelStart = 2 + Math.max(0, innerW - labelWidth);
379
+ return x >= labelStart && x < 2 + innerW;
380
+ }
381
+
376
382
  render(width: number): string[] {
377
383
  if (width < 6) return [];
378
384
  const th = this.theme;
@@ -490,13 +496,6 @@ export class ConversationViewer implements Component {
490
496
  return lines;
491
497
  }
492
498
 
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
499
  invalidate(): void {
501
500
  this.cachedWidth = 0;
502
501
  this.cachedLines = [];
@@ -566,6 +565,16 @@ export class ConversationViewer implements Component {
566
565
  return this.cachedMessageStarts.length === 0 ? 0 : Math.max(0, Math.min(this.selectedMessageIdx, this.cachedMessageStarts.length - 1));
567
566
  }
568
567
 
568
+ /** Keep the selected transcript block and the tool focus as one target. */
569
+ private setFocusFromMessageIndex(index: number): boolean {
570
+ if (index < 0 || index >= this.cachedMessageBlocks.length) return false;
571
+ this.selectedMessageIdx = index;
572
+ this.hasFocusedBlock = true;
573
+ const block = this.cachedMessageBlocks[index];
574
+ this.timeline.setFocusedToolCallId(block.kind === "tool" ? block.toolCallId : undefined);
575
+ return true;
576
+ }
577
+
569
578
  private currentBlockGlobalIndex(): number {
570
579
  const block = this.cachedMessageBlocks[this.currentMessageIndex()];
571
580
  return block ? this.blocks.indexOf(block) : -1;
@@ -598,33 +607,27 @@ export class ConversationViewer implements Component {
598
607
  if (messageIndices.length === 0) return;
599
608
  const current = this.currentMessageIndex();
600
609
  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;
610
+ const precedingPosition = messageIndices.reduce((last, index, position) => index < current ? position : last, -1);
611
+ const nextPosition = messageIndices.findIndex((index) => index > current);
612
+ const position = currentPosition >= 0
613
+ ? currentPosition + dir
614
+ : dir > 0 ? nextPosition : precedingPosition;
611
615
  if (position < 0 || position >= messageIndices.length) return;
612
616
  const target = messageIndices[position];
613
- this.selectedMessageIdx = target;
614
- this.timeline.setFocusedToolCallId(undefined);
617
+ this.setFocusFromMessageIndex(target);
615
618
  this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
616
619
  this.autoScroll = false;
620
+ this.invalidate();
617
621
  this.tui.requestRender();
618
622
  }
619
623
 
620
624
  private moveToolFocus(direction: 1 | -1): void {
621
625
  if (!this.timeline.moveFocus(direction)) return;
622
626
  const focused = this.timeline.getFocusedToolCallId();
623
- const refreshed = this.timeline.getSnapshot();
624
- const index = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focused);
627
+ this.buildContentLines(this.lastInnerW || 80);
628
+ const index = this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focused);
625
629
  if (index >= 0) {
626
- this.selectedMessageIdx = index;
627
- this.buildContentLines(this.lastInnerW || 80);
630
+ this.setFocusFromMessageIndex(index);
628
631
  this.autoScroll = false;
629
632
  this.scrollTargetIntoView(index);
630
633
  }
@@ -639,23 +642,17 @@ export class ConversationViewer implements Component {
639
642
  .filter((index): index is number => index >= 0);
640
643
  if (toolIndices.length === 0) return;
641
644
 
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
645
+ const current = this.currentMessageIndex();
646
+ const resolvedTarget = !this.hasFocusedBlock
648
647
  ? direction > 0 ? toolIndices[0] : toolIndices[toolIndices.length - 1]
649
648
  : direction > 0
650
649
  ? toolIndices.find((index) => index > current)
651
650
  : [...toolIndices].reverse().find((index) => index < current);
652
- if (target === undefined) return;
651
+ if (resolvedTarget === undefined) return;
653
652
 
654
- const block = this.cachedMessageBlocks[target];
655
- this.selectedMessageIdx = target;
653
+ this.setFocusFromMessageIndex(resolvedTarget);
656
654
  this.autoScroll = false;
657
- this.scrollTargetIntoView(target);
658
- this.timeline.setFocusedToolCallId(block.toolCallId);
655
+ this.scrollTargetIntoView(resolvedTarget);
659
656
  this.invalidate();
660
657
  this.tui.requestRender();
661
658
  }
@@ -672,58 +669,55 @@ export class ConversationViewer implements Component {
672
669
 
673
670
  private openFocusedToolPreview(): void {
674
671
  this.buildContentLines(this.lastInnerW || 80);
675
- const block = this.currentBlock();
672
+ const focusedToolCallId = this.timeline.getFocusedToolCallId();
673
+ const block = focusedToolCallId
674
+ ? this.cachedMessageBlocks.find((candidate) => candidate.toolCallId === focusedToolCallId)
675
+ : this.currentBlock();
676
676
  const custom = this.operations?.ctx?.ui?.custom;
677
677
  if (block?.kind !== "tool" || typeof custom !== "function") return;
678
678
 
679
- const input = formatToolInputArgs(block.args ?? block.toolArguments, FULL_TOOL_PREVIEW_MAX_CHARS);
680
- const result = block.toolResult ?? block.result;
681
- const output = result
682
- ? textFromResult(result, true, FULL_TOOL_PREVIEW_MAX_CHARS)
683
- : "(no result yet)";
684
- const content = [
685
- "Input",
686
- "=====",
687
- input || "(empty)",
688
- "",
689
- "Output",
690
- "======",
691
- output || "(empty)",
692
- ].join("\n");
693
- const title = `${block.toolName || "Tool"} · full preview`;
694
-
695
- void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) =>
696
- new FullToolPreview(tui, overlayTheme as Theme, title, content, done), {
697
- overlay: true,
698
- overlayOptions: {
699
- anchor: "center",
700
- width: "85%",
701
- minWidth: 50,
702
- maxHeight: "80%",
703
- margin: 2,
704
- },
705
- }).catch(() => undefined);
679
+ const toolName = block.toolName || "tool";
680
+ const args = block.args ?? block.toolArguments;
681
+ const result = block.toolResult ?? block.result ?? { content: [] };
682
+ const title = `${toolName} · full preview`;
683
+ void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) => {
684
+ const body = createViewerCcstyleResult(
685
+ toolName,
686
+ result,
687
+ { expanded: true, isError: result.isError === true },
688
+ overlayTheme,
689
+ { args, maxChars: FULL_TOOL_PREVIEW_MAX_CHARS, diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER } },
690
+ );
691
+ return new FullToolPreview(tui, overlayTheme as Theme, title, body, done);
692
+ }, {
693
+ overlay: true,
694
+ overlayOptions: { anchor: "center", width: "85%", minWidth: 50, maxHeight: "80%", margin: 2 },
695
+ }).catch(() => undefined);
706
696
  }
707
697
 
708
698
  private scrollBy(delta: number): void {
709
699
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
710
700
  this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
711
- this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
701
+ const selected = this.messageIndexForOffset(this.scrollOffset);
702
+ this.setFocusFromMessageIndex(selected);
712
703
  this.autoScroll = this.scrollOffset >= max;
704
+ this.invalidate();
713
705
  this.tui.requestRender();
714
706
  }
715
707
 
716
708
  private scrollToTop(): void {
717
709
  this.scrollOffset = 0;
718
- this.selectedMessageIdx = 0;
710
+ this.setFocusFromMessageIndex(0);
719
711
  this.autoScroll = false;
712
+ this.invalidate();
720
713
  this.tui.requestRender();
721
714
  }
722
715
 
723
716
  private scrollToBottom(): void {
724
717
  this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
725
- this.selectedMessageIdx = Math.max(0, this.cachedMessageStarts.length - 1);
718
+ this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
726
719
  this.autoScroll = true;
720
+ this.invalidate();
727
721
  this.tui.requestRender();
728
722
  }
729
723
 
@@ -773,8 +767,9 @@ export class ConversationViewer implements Component {
773
767
  if (!match) return;
774
768
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
775
769
  this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
776
- this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
770
+ this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
777
771
  this.autoScroll = false;
772
+ this.invalidate();
778
773
  }
779
774
 
780
775
  private async copyCurrentMessage(): Promise<void> {
@@ -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,
@@ -0,0 +1,123 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { AgentManager } from "../../src/agent-manager.js";
3
+ import {
4
+ AgentWidget,
5
+ getWidgetLineBudget,
6
+ MAX_WIDGET_LINES,
7
+ type AgentActivity,
8
+ type Theme,
9
+ } from "../../src/ui/agent-widget.js";
10
+ import type { AgentRecord } from "../../src/types.js";
11
+
12
+ const theme: Theme = {
13
+ fg: (_color, text) => text,
14
+ bold: (text) => text,
15
+ };
16
+
17
+ const managers: AgentManager[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const manager of managers.splice(0)) manager.dispose();
21
+ });
22
+
23
+ function makeRecord(overrides: Partial<AgentRecord> = {}): AgentRecord {
24
+ return {
25
+ id: "agent-1",
26
+ type: "Explore",
27
+ description: "Inspect the repository",
28
+ status: "completed",
29
+ toolUses: 0,
30
+ startedAt: 1_000,
31
+ completedAt: 2_000,
32
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
33
+ compactionCount: 0,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function renderWidget(
39
+ records: AgentRecord[],
40
+ rows = 24,
41
+ activity = new Map<string, AgentActivity>(),
42
+ mode: "all" | "background" | "off" = "all",
43
+ ) {
44
+ const manager = { listAgents: () => records } as unknown as AgentManager;
45
+ const widget = new AgentWidget(manager, activity, () => mode);
46
+ const tui = { terminal: { columns: 200, rows } };
47
+ const render = () => (widget as any).renderWidget(tui, theme) as string[];
48
+ return { render, widget };
49
+ }
50
+
51
+ describe("AgentWidget live records", () => {
52
+ it("keeps restored history in the live widget and manager history", () => {
53
+ const manager = new AgentManager();
54
+ managers.push(manager);
55
+ manager.restoreCompleted([makeRecord({ id: "history", description: "restored history" })]);
56
+
57
+ const widget = new AgentWidget(manager, new Map(), () => "all");
58
+ const tui = { terminal: { columns: 120, rows: 24 } };
59
+
60
+ expect((widget as any).renderWidget(tui, theme).join("\n")).toContain("restored history");
61
+ expect(manager.listAgents().map(record => record.id)).toEqual(["history"]);
62
+ expect(manager.getRecord("history")).toBeDefined();
63
+ });
64
+
65
+ it("keeps a live completed record visible after markFinished", () => {
66
+ const record = makeRecord({ id: "live", description: "live completion" });
67
+ const { render, widget } = renderWidget([record]);
68
+
69
+ widget.markFinished(record.id);
70
+
71
+ expect(render().join("\n")).toContain("live completion");
72
+ });
73
+
74
+ it("normalizes multiline rows and bounds output by the responsive ceiling", () => {
75
+ const running = makeRecord({
76
+ id: "running",
77
+ status: "running",
78
+ completedAt: undefined,
79
+ description: "running\nsecond line",
80
+ });
81
+ const finished = makeRecord({
82
+ id: "finished",
83
+ description: "finished\nsecond line",
84
+ });
85
+ const extraFinished = Array.from({ length: 10 }, (_, i) => makeRecord({
86
+ id: `finished-${i}`,
87
+ description: `extra ${i}`,
88
+ }));
89
+ const activity = new Map<string, AgentActivity>([
90
+ [running.id, {
91
+ activeTools: new Map(),
92
+ toolUses: 0,
93
+ responseText: "activity\nsecond line",
94
+ turnCount: 1,
95
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
96
+ }],
97
+ ]);
98
+ const rows = 9;
99
+ const { render } = renderWidget([running, finished, ...extraFinished], rows, activity);
100
+ const lines = render();
101
+
102
+ expect(lines.every(line => !/[\r\n]/.test(line))).toBe(true);
103
+ expect(lines.length).toBeLessThanOrEqual(getWidgetLineBudget(rows));
104
+ expect(lines.length).toBeLessThanOrEqual(MAX_WIDGET_LINES);
105
+ expect(lines.join("\n")).toContain("running second line");
106
+ expect(lines.some(line => line.includes("more"))).toBe(true);
107
+ });
108
+
109
+ it("counts queued records in the overflow indicator on short terminals", () => {
110
+ const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
111
+ const queued = makeRecord({ id: "queued", status: "queued", completedAt: undefined });
112
+ const { render } = renderWidget([running, queued], 6);
113
+
114
+ const output = render().join("\\n");
115
+ expect(output).toContain("+2 more");
116
+ expect(output).toContain("1 queued");
117
+ });
118
+
119
+ it("reserves editor space on short terminals while retaining the widget ceiling", () => {
120
+ expect(getWidgetLineBudget(9)).toBe(5);
121
+ expect(getWidgetLineBudget(100)).toBe(MAX_WIDGET_LINES);
122
+ });
123
+ });
@@ -4,7 +4,7 @@ import {
4
4
  renderEditDiffResult,
5
5
  renderWriteDiffResult,
6
6
  } from "../../src/ui/ccstyle/diff/diff-renderer.js";
7
- import { createViewerCcstyleResult, renderRichToolResult } from "../../src/ui/ccstyle/tool-result.js";
7
+ import { renderRichToolResult } from "../../src/ui/ccstyle/tool-result.js";
8
8
  import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../src/ui/ccstyle/diff/types.js";
9
9
 
10
10
  const theme = {
@@ -66,33 +66,6 @@ describe("viewer-local ccstyle diff renderer", () => {
66
66
  expect(rendered).not.toContain("later");
67
67
  });
68
68
 
69
- it("reads tool-hint hover state at render time", () => {
70
- let hovered = false;
71
- const component = createViewerCcstyleResult("read", {
72
- content: [{ type: "text", text: "one\ntwo" }],
73
- }, { expanded: false, isHovered: () => hovered }, theme, {
74
- args: { path: "file.txt" },
75
- isHovered: () => hovered,
76
- });
77
-
78
- expect(component?.render(100).join("\n")).not.toContain("<text>click to show more</text>");
79
- hovered = true;
80
- expect(component?.render(100).join("\n")).toContain("<text>click to show more</text>");
81
- });
82
-
83
- it("labels persisted overwrites correctly without reading the current file", () => {
84
- const component = renderRichToolResult("write", {
85
- content: [{ type: "text", text: "Successfully wrote" }],
86
- details: {
87
- diff: "-1 old\n+1 new",
88
- patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
89
- },
90
- }, { expanded: false }, theme, { args: { path: "file.txt", content: "new\n" } });
91
-
92
- expect(component?.render(100).join("\\n")).toContain("overwritten");
93
- expect(component?.render(100).join("\\n")).toContain("click to show more");
94
- });
95
-
96
69
  it("returns an explicit unavailable row instead of an empty success card", () => {
97
70
  const component = renderRichToolResult("write", {
98
71
  content: [{ type: "text", text: "Successfully wrote" }],