@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.
- package/CHANGELOG.md +6 -31
- 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 +17 -11
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts +22 -10
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +160 -73
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +16 -9
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +85 -61
- 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 +107 -110
- 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 +23 -24
- package/src/ui/ccstyle/tool-result.ts +167 -107
- package/src/ui/conversation-timeline.ts +103 -62
- package/src/ui/conversation-viewer.ts +119 -124
- package/src/write-execution.ts +1 -7
- package/test/ui/agent-widget.test.ts +123 -0
- package/test/ui/conversation-diff.test.ts +1 -28
- 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
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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.
|
|
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 =
|
|
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("│")}`,
|
|
@@ -106,7 +127,7 @@ class FullToolPreview implements Component {
|
|
|
106
127
|
}
|
|
107
128
|
|
|
108
129
|
invalidate(): void {
|
|
109
|
-
this.
|
|
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.
|
|
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,58 +669,55 @@ 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
|
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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> {
|
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,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" }],
|