@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.
- package/CHANGELOG.md +6 -37
- package/dist/ui/agent-widget.d.ts +8 -0
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +40 -12
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.js +4 -10
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts +9 -16
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +71 -121
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +14 -9
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +60 -72
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +4 -1
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +95 -94
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/write-execution.d.ts +0 -2
- package/dist/write-execution.d.ts.map +1 -1
- package/dist/write-execution.js +1 -5
- package/dist/write-execution.js.map +1 -1
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +38 -12
- package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
- package/src/ui/ccstyle/tool-renderer.ts +9 -22
- package/src/ui/ccstyle/tool-result.ts +82 -186
- package/src/ui/conversation-timeline.ts +77 -73
- package/src/ui/conversation-viewer.ts +102 -108
- package/src/write-execution.ts +1 -7
- package/test/ui/agent-widget.test.ts +123 -0
- package/test/ui/conversation-diff.test.ts +1 -47
- package/test/ui/conversation-viewer.test.ts +181 -112
- package/test/write-execution.test.ts +0 -2
- package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 =
|
|
96
|
-
const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · ↑↓ PgUp/PgDn
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
357
|
+
if (result?.handled && event.type === "click") {
|
|
358
|
+
let selected = result.focusedBlockIndex;
|
|
359
|
+
if (selected === undefined) {
|
|
363
360
|
const focusedTool = this.timeline.getFocusedToolCallId();
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
this.
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
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
|
-
|
|
624
|
-
const index =
|
|
627
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
628
|
+
const index = this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focused);
|
|
625
629
|
if (index >= 0) {
|
|
626
|
-
this.
|
|
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
|
|
643
|
-
const
|
|
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 (
|
|
651
|
+
if (resolvedTarget === undefined) return;
|
|
653
652
|
|
|
654
|
-
|
|
655
|
-
this.selectedMessageIdx = target;
|
|
653
|
+
this.setFocusFromMessageIndex(resolvedTarget);
|
|
656
654
|
this.autoScroll = false;
|
|
657
|
-
this.scrollTargetIntoView(
|
|
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
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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> {
|
package/src/write-execution.ts
CHANGED
|
@@ -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 {
|
|
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" }],
|