@esso0428/pi-subagents 0.15.14 → 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 -37
  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 +38 -12
  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-timeline.ts +77 -73
  34. package/src/ui/conversation-viewer.ts +102 -108
  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 -47
  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
@@ -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,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,7 +669,10 @@ 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
 
@@ -680,51 +680,44 @@ export class ConversationViewer implements Component {
680
680
  const args = block.args ?? block.toolArguments;
681
681
  const result = block.toolResult ?? block.result ?? { content: [] };
682
682
  const title = `${toolName} · full preview`;
683
-
684
683
  void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) => {
685
684
  const body = createViewerCcstyleResult(
686
685
  toolName,
687
686
  result,
688
687
  { expanded: true, isError: result.isError === true },
689
688
  overlayTheme,
690
- {
691
- args,
692
- maxChars: FULL_TOOL_PREVIEW_MAX_CHARS,
693
- diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER },
694
- },
689
+ { args, maxChars: FULL_TOOL_PREVIEW_MAX_CHARS, diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER } },
695
690
  );
696
691
  return new FullToolPreview(tui, overlayTheme as Theme, title, body, done);
697
692
  }, {
698
- overlay: true,
699
- overlayOptions: {
700
- anchor: "center",
701
- width: "85%",
702
- minWidth: 50,
703
- maxHeight: "80%",
704
- margin: 2,
705
- },
706
- }).catch(() => undefined);
693
+ overlay: true,
694
+ overlayOptions: { anchor: "center", width: "85%", minWidth: 50, maxHeight: "80%", margin: 2 },
695
+ }).catch(() => undefined);
707
696
  }
708
697
 
709
698
  private scrollBy(delta: number): void {
710
699
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
711
700
  this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
712
- this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
701
+ const selected = this.messageIndexForOffset(this.scrollOffset);
702
+ this.setFocusFromMessageIndex(selected);
713
703
  this.autoScroll = this.scrollOffset >= max;
704
+ this.invalidate();
714
705
  this.tui.requestRender();
715
706
  }
716
707
 
717
708
  private scrollToTop(): void {
718
709
  this.scrollOffset = 0;
719
- this.selectedMessageIdx = 0;
710
+ this.setFocusFromMessageIndex(0);
720
711
  this.autoScroll = false;
712
+ this.invalidate();
721
713
  this.tui.requestRender();
722
714
  }
723
715
 
724
716
  private scrollToBottom(): void {
725
717
  this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
726
- this.selectedMessageIdx = Math.max(0, this.cachedMessageStarts.length - 1);
718
+ this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
727
719
  this.autoScroll = true;
720
+ this.invalidate();
728
721
  this.tui.requestRender();
729
722
  }
730
723
 
@@ -774,8 +767,9 @@ export class ConversationViewer implements Component {
774
767
  if (!match) return;
775
768
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
776
769
  this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
777
- this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
770
+ this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
778
771
  this.autoScroll = false;
772
+ this.invalidate();
779
773
  }
780
774
 
781
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,52 +66,6 @@ describe("viewer-local ccstyle diff renderer", () => {
66
66
  expect(rendered).not.toContain("later");
67
67
  });
68
68
 
69
- it("matches the pi-cc Input/Output tree style and hover affordance", () => {
70
- const component = createViewerCcstyleResult("read", {
71
- content: [{ type: "text", text: "one\ntwo\nthree" }],
72
- }, { expanded: true }, theme, {
73
- args: { path: "file.txt", offset: 2 },
74
- diffConfig: { expandedPreviewMaxLines: 2 },
75
- }) as any;
76
-
77
- const initial = component.render(100).join("\n");
78
- expect(initial).toContain("<accent><bold>Input</bold></accent>");
79
- expect(initial).toContain("<dim>path: </dim><muted>file.txt</muted>");
80
- expect(initial).toContain("<accent><bold>Output</bold></accent>");
81
- expect(initial).toContain("click to show more");
82
- const outputHintLine = component.showMoreLine("output");
83
- expect(outputHintLine).toEqual(expect.any(Number));
84
- component.setHoveredSection("output");
85
- expect(component.render(100).join("\n")).toContain("<text> click to show more</text>");
86
- });
87
-
88
- it("reads tool-hint hover state at render time", () => {
89
- let hovered = false;
90
- const component = createViewerCcstyleResult("read", {
91
- content: [{ type: "text", text: "one\ntwo" }],
92
- }, { expanded: false, isHovered: () => hovered }, theme, {
93
- args: { path: "file.txt" },
94
- isHovered: () => hovered,
95
- });
96
-
97
- expect(component?.render(100).join("\n")).not.toContain("<text>click to show more</text>");
98
- hovered = true;
99
- expect(component?.render(100).join("\n")).toContain("<text>click to show more</text>");
100
- });
101
-
102
- it("labels persisted overwrites correctly without reading the current file", () => {
103
- const component = renderRichToolResult("write", {
104
- content: [{ type: "text", text: "Successfully wrote" }],
105
- details: {
106
- diff: "-1 old\n+1 new",
107
- patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
108
- },
109
- }, { expanded: false }, theme, { args: { path: "file.txt", content: "new\n" } });
110
-
111
- expect(component?.render(100).join("\\n")).toContain("overwritten");
112
- expect(component?.render(100).join("\\n")).toContain("click to show more");
113
- });
114
-
115
69
  it("returns an explicit unavailable row instead of an empty success card", () => {
116
70
  const component = renderRichToolResult("write", {
117
71
  content: [{ type: "text", text: "Successfully wrote" }],