@esso0428/pi-subagents 0.15.12 → 0.15.14
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 +12 -0
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.js +19 -7
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts +24 -5
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +168 -31
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +2 -0
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +37 -1
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +3 -0
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +176 -4
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/package.json +1 -1
- package/src/ui/ccstyle/tool-renderer.ts +20 -8
- package/src/ui/ccstyle/tool-result.ts +195 -31
- package/src/ui/conversation-timeline.ts +38 -1
- package/src/ui/conversation-viewer.ts +183 -4
- package/test/ui/conversation-diff.test.ts +19 -0
- package/test/ui/conversation-viewer.test.ts +65 -2
|
@@ -25,13 +25,13 @@ function rawTextFromResult(result: any): string {
|
|
|
25
25
|
: "";
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function textFromResult(result: any, expanded = false): string {
|
|
29
|
-
const content = sanitizeToolResultText(rawTextFromResult(result),
|
|
28
|
+
export function textFromResult(result: any, expanded = false, maxChars = 16_384): string {
|
|
29
|
+
const content = sanitizeToolResultText(rawTextFromResult(result), maxChars);
|
|
30
30
|
const detail = result?.details;
|
|
31
31
|
if (!expanded || detail === undefined) return content;
|
|
32
32
|
const details = sanitizeToolResultText(
|
|
33
33
|
typeof detail === "string" ? detail : safeJson(detail),
|
|
34
|
-
|
|
34
|
+
maxChars,
|
|
35
35
|
);
|
|
36
36
|
if (!details || details === content) return content;
|
|
37
37
|
return content ? `${content}\nDetails:\n${details}` : details;
|
|
@@ -51,56 +51,209 @@ export function outputLineCount(result: any): number {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function countLines(text: string): number {
|
|
54
|
-
return text.trim() ? text.trim().split("\n").length : 0;
|
|
54
|
+
return text.trim() ? text.trim().split("\n").filter((line) => line.trim().length > 0).length : 0;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Format arguments as stable, readable `key: value` rows for the Input rail. */
|
|
57
58
|
export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
|
|
58
59
|
if (args === undefined || args === null) return "";
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
if (typeof args !== "object") {
|
|
61
|
+
const text = sanitizeToolResultText(String(args));
|
|
62
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(args)) {
|
|
65
|
+
const text = safeJson(args);
|
|
66
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const preferred = [
|
|
70
|
+
"path", "file_path", "command", "query", "pattern", "url", "name",
|
|
71
|
+
"message", "content", "old_string", "new_string",
|
|
72
|
+
];
|
|
73
|
+
const entries = Object.entries(args as Record<string, unknown>)
|
|
74
|
+
.filter(([, value]) => value !== undefined)
|
|
75
|
+
.sort(([left], [right]) => {
|
|
76
|
+
const li = preferred.indexOf(left);
|
|
77
|
+
const ri = preferred.indexOf(right);
|
|
78
|
+
if (li < 0 && ri < 0) return left.localeCompare(right);
|
|
79
|
+
if (li < 0) return 1;
|
|
80
|
+
if (ri < 0) return -1;
|
|
81
|
+
return li - ri;
|
|
82
|
+
});
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
for (const [rawKey, value] of entries) {
|
|
85
|
+
const key = sanitizeToolResultText(rawKey);
|
|
86
|
+
if (typeof value === "string") {
|
|
87
|
+
const safeValue = sanitizeToolResultText(value);
|
|
88
|
+
if (safeValue.includes("\n")) {
|
|
89
|
+
lines.push(`${key}:`);
|
|
90
|
+
for (const line of safeValue.split("\n")) lines.push(` ${line}`);
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(`${key}: ${safeValue}`);
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
|
97
|
+
lines.push(`${key}: ${String(value)}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const json = safeJson(value);
|
|
101
|
+
if (json.includes("\n")) {
|
|
102
|
+
lines.push(`${key}:`);
|
|
103
|
+
for (const line of json.split("\n")) lines.push(` ${line}`);
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(`${key}: ${json}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const text = lines.join("\n");
|
|
109
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
63
110
|
}
|
|
64
111
|
|
|
65
112
|
function hasExpandableDetail(text: string, args: unknown): boolean {
|
|
66
113
|
return countLines(text) > 1 || formatToolInputArgs(args).trim().length > 0;
|
|
67
114
|
}
|
|
68
115
|
|
|
69
|
-
|
|
116
|
+
export type ToolIoSection = "input" | "output";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Viewer-local Input/Output card. The rail and show-more rows intentionally
|
|
120
|
+
* mirror ccstyle, but state is kept on this component so history remains
|
|
121
|
+
* deterministic and no global terminal mouse handler is needed.
|
|
122
|
+
*/
|
|
70
123
|
class ExpandedToolIoView implements Component {
|
|
71
|
-
private readonly input: string;
|
|
72
|
-
private readonly output: string;
|
|
73
124
|
private readonly isError: boolean;
|
|
125
|
+
private input: string;
|
|
126
|
+
private output: string;
|
|
127
|
+
private maxOutputLines: number;
|
|
128
|
+
private maxInputLines: number;
|
|
129
|
+
private theme: any;
|
|
130
|
+
private cachedWidth: number | undefined;
|
|
131
|
+
private cachedLines: string[] | undefined;
|
|
132
|
+
private lineSections: Array<ToolIoSection | null> = [];
|
|
133
|
+
private hoveredSection: ToolIoSection | null = null;
|
|
134
|
+
private showMoreRows: Partial<Record<ToolIoSection, number>> = {};
|
|
74
135
|
|
|
75
|
-
constructor(
|
|
136
|
+
constructor(
|
|
137
|
+
input: string,
|
|
138
|
+
output: string,
|
|
139
|
+
isError: boolean,
|
|
140
|
+
maxOutputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
|
|
141
|
+
maxInputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
|
|
142
|
+
theme?: any,
|
|
143
|
+
) {
|
|
76
144
|
this.input = input;
|
|
77
|
-
this.output = output
|
|
145
|
+
this.output = output;
|
|
78
146
|
this.isError = isError;
|
|
147
|
+
this.maxOutputLines = Math.max(1, maxOutputLines);
|
|
148
|
+
this.maxInputLines = Math.max(1, maxInputLines);
|
|
149
|
+
this.theme = theme;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getInputBody(): string {
|
|
153
|
+
return this.input;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getOutputBody(): string {
|
|
157
|
+
return this.output.trim() ? this.output : "Done";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setHoveredSection(section: ToolIoSection | null): void {
|
|
161
|
+
if (this.hoveredSection === section) return;
|
|
162
|
+
this.hoveredSection = section;
|
|
163
|
+
this.invalidate();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
sectionAtLine(line: number): ToolIoSection | null {
|
|
167
|
+
return this.lineSections[line] ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
showMoreLine(section: ToolIoSection): number | undefined {
|
|
171
|
+
return this.showMoreRows[section];
|
|
79
172
|
}
|
|
80
173
|
|
|
81
174
|
render(width: number): string[] {
|
|
82
175
|
const safeWidth = toolViewportWidth(width);
|
|
176
|
+
if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
|
|
177
|
+
|
|
178
|
+
const bodyColor = this.isError ? "error" : "toolOutput";
|
|
179
|
+
const rail = " │ ";
|
|
180
|
+
const contentWidth = Math.max(1, safeWidth - visibleWidth(rail));
|
|
83
181
|
const rows: string[] = [];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
182
|
+
this.lineSections = [];
|
|
183
|
+
this.showMoreRows = {};
|
|
184
|
+
|
|
185
|
+
const push = (line: string, section: ToolIoSection | null) => {
|
|
186
|
+
rows.push(truncateToWidth(line, safeWidth, ""));
|
|
187
|
+
this.lineSections.push(section);
|
|
188
|
+
};
|
|
189
|
+
// The theme is optional so the component remains safe in small/test hosts.
|
|
190
|
+
const theme = this.theme;
|
|
191
|
+
const color = (name: string, value: string): string => typeof theme?.fg === "function" ? theme.fg(name, value) : value;
|
|
192
|
+
const bold = (value: string): string => typeof theme?.bold === "function" ? theme.bold(value) : value;
|
|
193
|
+
const pushHeader = (corner: "├" | "└", title: string) => push(
|
|
194
|
+
color("dim", ` ${corner} `) + color("accent", bold(title)),
|
|
195
|
+
null,
|
|
196
|
+
);
|
|
197
|
+
const pushRail = (value: string, section: ToolIoSection, continued: boolean) => push(
|
|
198
|
+
color("dim", continued ? rail : " ") + value,
|
|
199
|
+
section,
|
|
200
|
+
);
|
|
201
|
+
const pushBlank = () => push(color("dim", " │"), null);
|
|
202
|
+
const styleInput = (line: string): string => {
|
|
203
|
+
const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
|
|
204
|
+
return match
|
|
205
|
+
? color("dim", `${match[1]}${match[2]}`) + color("muted", match[3] ?? "")
|
|
206
|
+
: color("muted", line);
|
|
207
|
+
};
|
|
208
|
+
const pushBody = (body: string, section: ToolIoSection, limit: number, continued: boolean, input: boolean) => {
|
|
209
|
+
const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
|
|
210
|
+
if (!raw.trim()) {
|
|
211
|
+
pushRail(color("dim", "(empty)"), section, continued);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const sources = raw.split("\n");
|
|
215
|
+
const wrapped: string[] = [];
|
|
216
|
+
for (const source of sources) {
|
|
217
|
+
const styled = input ? styleInput(source) : color(bodyColor, source);
|
|
218
|
+
const parts = wrapTextWithAnsi(styled, contentWidth);
|
|
219
|
+
wrapped.push(...(parts.length ? parts : [styled]));
|
|
220
|
+
}
|
|
221
|
+
const truncated = sources.length > limit || wrapped.length > limit;
|
|
222
|
+
const visible = truncated ? wrapped.slice(0, limit) : wrapped;
|
|
223
|
+
for (const line of visible) pushRail(line, section, continued);
|
|
224
|
+
if (truncated) {
|
|
225
|
+
const hidden = Math.max(1, wrapped.length - visible.length);
|
|
226
|
+
const hintColor = this.hoveredSection === section ? "text" : "dim";
|
|
227
|
+
pushRail(
|
|
228
|
+
color("dim", `… +${hidden} more lines`) +
|
|
229
|
+
color("dim", " •") + color(hintColor, ` ${showMoreHintText()}`),
|
|
230
|
+
section,
|
|
231
|
+
continued,
|
|
232
|
+
);
|
|
233
|
+
this.showMoreRows[section] = rows.length - 1;
|
|
92
234
|
}
|
|
93
235
|
};
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
236
|
+
|
|
237
|
+
const hasInput = this.input.trim().length > 0;
|
|
238
|
+
const output = this.getOutputBody();
|
|
239
|
+
if (hasInput) {
|
|
240
|
+
pushHeader("├", "Input");
|
|
241
|
+
pushBody(this.input, "input", this.maxInputLines, true, true);
|
|
242
|
+
pushBlank();
|
|
99
243
|
}
|
|
100
|
-
|
|
244
|
+
pushHeader("└", "Output");
|
|
245
|
+
pushBody(output, "output", this.maxOutputLines, false, false);
|
|
246
|
+
|
|
247
|
+
this.cachedWidth = safeWidth;
|
|
248
|
+
this.cachedLines = rows;
|
|
249
|
+
return rows;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
invalidate(): void {
|
|
253
|
+
this.cachedWidth = undefined;
|
|
254
|
+
this.cachedLines = undefined;
|
|
101
255
|
}
|
|
102
256
|
|
|
103
|
-
invalidate(): void {}
|
|
104
257
|
}
|
|
105
258
|
|
|
106
259
|
function unavailableComponent(reason: string, theme: any): Component {
|
|
@@ -210,9 +363,20 @@ function settledIcon(context: any): string {
|
|
|
210
363
|
function renderFallbackResult(result: any, options: any, theme: any, context: any): Component {
|
|
211
364
|
const error = options?.isError || context?.isError;
|
|
212
365
|
const expanded = options?.expanded === true || context?.expanded === true;
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
366
|
+
const maxChars = Number.isFinite(context?.maxChars) ? Math.max(0, Math.floor(context.maxChars)) : 16_384;
|
|
367
|
+
const text = textFromResult(result, expanded, maxChars);
|
|
368
|
+
if (expanded && (text.trim() || formatToolInputArgs(context?.args, maxChars).trim())) {
|
|
369
|
+
const maxLines = Number.isFinite(context?.diffConfig?.expandedPreviewMaxLines)
|
|
370
|
+
? Math.max(1, Math.floor(context.diffConfig.expandedPreviewMaxLines))
|
|
371
|
+
: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
|
|
372
|
+
return new ExpandedToolIoView(
|
|
373
|
+
formatToolInputArgs(context?.args, maxChars),
|
|
374
|
+
text,
|
|
375
|
+
Boolean(error),
|
|
376
|
+
maxLines,
|
|
377
|
+
maxLines,
|
|
378
|
+
theme,
|
|
379
|
+
);
|
|
216
380
|
}
|
|
217
381
|
const lines = text ? text.split("\n") : [];
|
|
218
382
|
const count = lines.length;
|
|
@@ -143,6 +143,8 @@ export class ConversationTimeline implements Component {
|
|
|
143
143
|
private focusedToolCallId: string | undefined;
|
|
144
144
|
private expandedTools = new Set<string>();
|
|
145
145
|
private hoveredToolCallId: string | undefined;
|
|
146
|
+
private hoveredIoView: any;
|
|
147
|
+
private hoveredIoSection: "input" | "output" | null = null;
|
|
146
148
|
/** The last tool clicked; used to make only clickCount=2 collapse it. */
|
|
147
149
|
private lastClickedToolCallId: string | undefined;
|
|
148
150
|
private lastClickedPoint: { x: number; y: number } | undefined;
|
|
@@ -167,6 +169,11 @@ export class ConversationTimeline implements Component {
|
|
|
167
169
|
const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
|
|
168
170
|
if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
|
|
169
171
|
if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
|
|
172
|
+
if (this.hoveredIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
|
|
173
|
+
this.hoveredIoView.setHoveredSection(null);
|
|
174
|
+
this.hoveredIoView = undefined;
|
|
175
|
+
this.hoveredIoSection = null;
|
|
176
|
+
}
|
|
170
177
|
if (this.lastClickedToolCallId && !ids.has(this.lastClickedToolCallId)) {
|
|
171
178
|
this.lastClickedToolCallId = undefined;
|
|
172
179
|
this.lastClickedPoint = undefined;
|
|
@@ -182,6 +189,11 @@ export class ConversationTimeline implements Component {
|
|
|
182
189
|
if (!show) {
|
|
183
190
|
this.focusedToolCallId = undefined;
|
|
184
191
|
this.hoveredToolCallId = undefined;
|
|
192
|
+
if (this.hoveredIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
|
|
193
|
+
this.hoveredIoView.setHoveredSection(null);
|
|
194
|
+
}
|
|
195
|
+
this.hoveredIoView = undefined;
|
|
196
|
+
this.hoveredIoSection = null;
|
|
185
197
|
this.lastClickedToolCallId = undefined;
|
|
186
198
|
this.lastClickedPoint = undefined;
|
|
187
199
|
}
|
|
@@ -360,8 +372,33 @@ export class ConversationTimeline implements Component {
|
|
|
360
372
|
event.y >= span.clickStartLine &&
|
|
361
373
|
event.y < span.clickStartLine + (span.clickHeight ?? 1),
|
|
362
374
|
)?.toolCallId;
|
|
363
|
-
|
|
375
|
+
const fullSpan = this.snapshot.toolSpans.find((span) =>
|
|
376
|
+
event.y >= span.startLine && event.y < span.startLine + span.height,
|
|
377
|
+
);
|
|
378
|
+
let nextIoView: any;
|
|
379
|
+
let nextIoSection: "input" | "output" | null = null;
|
|
380
|
+
if (fullSpan) {
|
|
381
|
+
const component = this.toolComponents.get(fullSpan.toolCallId) as any;
|
|
382
|
+
const view = component?.resultRendererComponent;
|
|
383
|
+
const localRow = event.y - fullSpan.startLine;
|
|
384
|
+
if (view && typeof view.sectionAtLine === "function" && typeof view.showMoreLine === "function" && typeof view.setHoveredSection === "function") {
|
|
385
|
+
const section = view.sectionAtLine(localRow);
|
|
386
|
+
if (section && view.showMoreLine(section) === localRow) {
|
|
387
|
+
nextIoView = view;
|
|
388
|
+
nextIoSection = section;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const unchanged = hovered === this.hoveredToolCallId &&
|
|
393
|
+
nextIoView === this.hoveredIoView && nextIoSection === this.hoveredIoSection;
|
|
394
|
+
if (unchanged) return undefined;
|
|
395
|
+
if (this.hoveredIoView && this.hoveredIoView !== nextIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
|
|
396
|
+
this.hoveredIoView.setHoveredSection(null);
|
|
397
|
+
}
|
|
364
398
|
this.hoveredToolCallId = hovered;
|
|
399
|
+
this.hoveredIoView = nextIoView;
|
|
400
|
+
this.hoveredIoSection = nextIoSection;
|
|
401
|
+
if (nextIoView) nextIoView.setHoveredSection(nextIoSection);
|
|
365
402
|
this.invalidate();
|
|
366
403
|
this.options.onChange?.();
|
|
367
404
|
return { handled: true, render: true };
|
|
@@ -18,9 +18,104 @@ 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 { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
|
|
21
22
|
|
|
22
23
|
/** Base lines consumed by the overlay: borders, header, separator and footer. */
|
|
23
24
|
const CHROME_LINES_BASE = 6;
|
|
25
|
+
const FULL_TOOL_PREVIEW_MAX_CHARS = 500_000;
|
|
26
|
+
|
|
27
|
+
class FullToolPreview implements Component {
|
|
28
|
+
private readonly body: Component;
|
|
29
|
+
private scrollOffset = 0;
|
|
30
|
+
private pageSize = 1;
|
|
31
|
+
private totalLines = 1;
|
|
32
|
+
private hoveredClose = false;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly tui: TUI,
|
|
36
|
+
private readonly theme: Theme,
|
|
37
|
+
private readonly title: string,
|
|
38
|
+
body: Component,
|
|
39
|
+
private readonly done: (result: undefined) => void,
|
|
40
|
+
) {
|
|
41
|
+
this.body = body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handleInput(data: string): void {
|
|
45
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
|
46
|
+
this.done(undefined);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
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);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
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;
|
|
59
|
+
if (event.type === "move") {
|
|
60
|
+
if (closeHit === this.hoveredClose) return undefined;
|
|
61
|
+
this.hoveredClose = closeHit;
|
|
62
|
+
return { handled: true, render: true };
|
|
63
|
+
}
|
|
64
|
+
if (closeHit && event.button === "left") {
|
|
65
|
+
if (event.type === "press") return { handled: true, capture: true, render: false };
|
|
66
|
+
if (event.type === "click") {
|
|
67
|
+
this.done(undefined);
|
|
68
|
+
return { handled: true, render: false };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (event.type === "wheel") {
|
|
72
|
+
this.scrollTo(this.scrollOffset + Math.trunc(event.wheelDelta ?? 0));
|
|
73
|
+
return { handled: true, render: true };
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
render(width: number): string[] {
|
|
79
|
+
const safeWidth = Math.max(12, Math.floor(Number.isFinite(width) ? width : 12));
|
|
80
|
+
const inner = Math.max(1, safeWidth - 2);
|
|
81
|
+
const bodyWidth = Math.max(1, inner - 1);
|
|
82
|
+
const terminalRows = Math.max(1, this.tui.terminal.rows);
|
|
83
|
+
const viewport = Math.max(1, Math.min(30, Math.floor(terminalRows * 0.8), terminalRows - 6));
|
|
84
|
+
const wrapped = this.body.render(bodyWidth);
|
|
85
|
+
this.totalLines = wrapped.length;
|
|
86
|
+
this.pageSize = viewport;
|
|
87
|
+
this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.totalLines - this.pageSize));
|
|
88
|
+
const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + this.pageSize);
|
|
89
|
+
const border = (text: string) => this.theme.fg("border", text);
|
|
90
|
+
const pad = (text: string, rowWidth = inner): string => {
|
|
91
|
+
const clipped = truncateToWidth(text, rowWidth, "…", true);
|
|
92
|
+
return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
|
|
93
|
+
};
|
|
94
|
+
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`;
|
|
97
|
+
return [
|
|
98
|
+
border(`╭${"─".repeat(inner)}╮`),
|
|
99
|
+
`${border("│")}${pad(` ${header}`, inner - visibleWidth(close))}${close}${border("│")}`,
|
|
100
|
+
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
101
|
+
...Array.from({ length: viewport }, (_, index) => `${border("│")}${pad(` ${visible[index] ?? ""}`, bodyWidth)}${border("│")}`),
|
|
102
|
+
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
103
|
+
`${border("│")}${pad(this.theme.fg("dim", ` ${status}`))}${border("│")}`,
|
|
104
|
+
border(`╰${"─".repeat(inner)}╯`),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
invalidate(): void {
|
|
109
|
+
this.body.invalidate();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private scrollTo(offset: number): void {
|
|
113
|
+
const next = Math.max(0, Math.min(Math.max(0, this.totalLines - this.pageSize), offset));
|
|
114
|
+
if (next === this.scrollOffset) return;
|
|
115
|
+
this.scrollOffset = next;
|
|
116
|
+
this.tui.requestRender();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
24
119
|
const MIN_VIEWPORT = 3;
|
|
25
120
|
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
26
121
|
const MAX_COPY_CHARS = 500_000;
|
|
@@ -174,6 +269,12 @@ export class ConversationViewer implements Component {
|
|
|
174
269
|
this.jumpToMessage(1);
|
|
175
270
|
} else if (data === "K" || matchesKey(data, Key.shift("k"))) {
|
|
176
271
|
this.jumpToMessage(-1);
|
|
272
|
+
} else if (data === "]") {
|
|
273
|
+
this.focusNearbyTool(1);
|
|
274
|
+
} else if (data === "[") {
|
|
275
|
+
this.focusNearbyTool(-1);
|
|
276
|
+
} else if (data === "w") {
|
|
277
|
+
this.openFocusedToolPreview();
|
|
177
278
|
} else if (data === "g") {
|
|
178
279
|
this.scrollToTop();
|
|
179
280
|
} else if (data === "G" || matchesKey(data, Key.shift("g"))) {
|
|
@@ -361,9 +462,9 @@ export class ConversationViewer implements Component {
|
|
|
361
462
|
if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
|
|
362
463
|
const editHint = this.isLive() ? " O" : "";
|
|
363
464
|
const shortcutHints = [
|
|
364
|
-
`j/k scroll · J/K messages · g/G t M o${editHint} /n N q`,
|
|
365
|
-
`M o${editHint} /n N q`,
|
|
366
|
-
`M o${editHint} /n N q`,
|
|
465
|
+
`j/k scroll · J/K messages · [/] tools · w preview · g/G t M o${editHint} /n N q`,
|
|
466
|
+
`[/] tools · w preview · M o${editHint} /n N q`,
|
|
467
|
+
`[/] tools · w · M o${editHint} /n N q`,
|
|
367
468
|
];
|
|
368
469
|
const scrollPct = contentLines.length <= viewportHeight ? "100%" : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
369
470
|
const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
@@ -521,12 +622,90 @@ export class ConversationViewer implements Component {
|
|
|
521
622
|
const focused = this.timeline.getFocusedToolCallId();
|
|
522
623
|
const refreshed = this.timeline.getSnapshot();
|
|
523
624
|
const index = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focused);
|
|
524
|
-
if (index >= 0)
|
|
625
|
+
if (index >= 0) {
|
|
626
|
+
this.selectedMessageIdx = index;
|
|
627
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
628
|
+
this.autoScroll = false;
|
|
629
|
+
this.scrollTargetIntoView(index);
|
|
630
|
+
}
|
|
631
|
+
this.invalidate();
|
|
632
|
+
this.tui.requestRender();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private focusNearbyTool(direction: 1 | -1): void {
|
|
636
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
637
|
+
const toolIndices = this.cachedMessageBlocks
|
|
638
|
+
.map((block, index) => block.kind === "tool" && block.toolCallId ? index : -1)
|
|
639
|
+
.filter((index): index is number => index >= 0);
|
|
640
|
+
if (toolIndices.length === 0) return;
|
|
641
|
+
|
|
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
|
|
648
|
+
? direction > 0 ? toolIndices[0] : toolIndices[toolIndices.length - 1]
|
|
649
|
+
: direction > 0
|
|
650
|
+
? toolIndices.find((index) => index > current)
|
|
651
|
+
: [...toolIndices].reverse().find((index) => index < current);
|
|
652
|
+
if (target === undefined) return;
|
|
653
|
+
|
|
654
|
+
const block = this.cachedMessageBlocks[target];
|
|
655
|
+
this.selectedMessageIdx = target;
|
|
525
656
|
this.autoScroll = false;
|
|
657
|
+
this.scrollTargetIntoView(target);
|
|
658
|
+
this.timeline.setFocusedToolCallId(block.toolCallId);
|
|
526
659
|
this.invalidate();
|
|
527
660
|
this.tui.requestRender();
|
|
528
661
|
}
|
|
529
662
|
|
|
663
|
+
private scrollTargetIntoView(index: number): void {
|
|
664
|
+
if (index < 0 || index >= this.cachedMessageStarts.length) return;
|
|
665
|
+
const targetLine = this.cachedMessageStarts[index];
|
|
666
|
+
const viewportHeight = this.viewportHeight();
|
|
667
|
+
const maxScroll = Math.max(0, this.cachedLines.length - viewportHeight);
|
|
668
|
+
if (targetLine < this.scrollOffset) this.scrollOffset = targetLine;
|
|
669
|
+
else if (targetLine >= this.scrollOffset + viewportHeight) this.scrollOffset = targetLine - viewportHeight + 1;
|
|
670
|
+
this.scrollOffset = Math.max(0, Math.min(maxScroll, this.scrollOffset));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private openFocusedToolPreview(): void {
|
|
674
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
675
|
+
const block = this.currentBlock();
|
|
676
|
+
const custom = this.operations?.ctx?.ui?.custom;
|
|
677
|
+
if (block?.kind !== "tool" || typeof custom !== "function") return;
|
|
678
|
+
|
|
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
|
+
|
|
684
|
+
void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) => {
|
|
685
|
+
const body = createViewerCcstyleResult(
|
|
686
|
+
toolName,
|
|
687
|
+
result,
|
|
688
|
+
{ expanded: true, isError: result.isError === true },
|
|
689
|
+
overlayTheme,
|
|
690
|
+
{
|
|
691
|
+
args,
|
|
692
|
+
maxChars: FULL_TOOL_PREVIEW_MAX_CHARS,
|
|
693
|
+
diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER },
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
return new FullToolPreview(tui, overlayTheme as Theme, title, body, done);
|
|
697
|
+
}, {
|
|
698
|
+
overlay: true,
|
|
699
|
+
overlayOptions: {
|
|
700
|
+
anchor: "center",
|
|
701
|
+
width: "85%",
|
|
702
|
+
minWidth: 50,
|
|
703
|
+
maxHeight: "80%",
|
|
704
|
+
margin: 2,
|
|
705
|
+
},
|
|
706
|
+
}).catch(() => undefined);
|
|
707
|
+
}
|
|
708
|
+
|
|
530
709
|
private scrollBy(delta: number): void {
|
|
531
710
|
const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
532
711
|
this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
|
|
@@ -66,6 +66,25 @@ 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
|
+
|
|
69
88
|
it("reads tool-hint hover state at render time", () => {
|
|
70
89
|
let hovered = false;
|
|
71
90
|
const component = createViewerCcstyleResult("read", {
|
|
@@ -82,7 +82,7 @@ describe("ConversationViewer", () => {
|
|
|
82
82
|
{ pi: {} as any, ctx: {} as any, readOnly: false },
|
|
83
83
|
);
|
|
84
84
|
const liveFooter = live.render(120).join("\n");
|
|
85
|
-
expect(liveFooter).toContain("j/k scroll · J/K messages · g/G t M o O /n N q");
|
|
85
|
+
expect(liveFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o O /n N q");
|
|
86
86
|
expect(liveFooter).toContain("e steer");
|
|
87
87
|
expect(liveFooter).toContain("x stop");
|
|
88
88
|
live.handleInput("\r");
|
|
@@ -104,7 +104,7 @@ describe("ConversationViewer", () => {
|
|
|
104
104
|
{ pi: {} as any, ctx: {} as any, readOnly: true },
|
|
105
105
|
);
|
|
106
106
|
const historyFooter = history.render(120).join("\n");
|
|
107
|
-
expect(historyFooter).toContain("j/k scroll · J/K messages · g/G t M o /n N q");
|
|
107
|
+
expect(historyFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o /n N q");
|
|
108
108
|
expect(historyFooter).not.toContain("M o O");
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -180,6 +180,69 @@ describe("ConversationViewer", () => {
|
|
|
180
180
|
expect((viewer as any).currentBlock().markdown).toBe("first");
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
it("focuses nearby tools with [ and ] without changing line-scroll keys", () => {
|
|
184
|
+
const viewer = new ConversationViewer(
|
|
185
|
+
tui(),
|
|
186
|
+
createStaticConversationSource([
|
|
187
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "first" } }] },
|
|
188
|
+
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "first output" }] },
|
|
189
|
+
{ role: "assistant", content: [{ type: "text", text: "between" }, { type: "toolCall", name: "read", arguments: { path: "second" } }] },
|
|
190
|
+
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "second output" }] },
|
|
191
|
+
] as any),
|
|
192
|
+
record(),
|
|
193
|
+
undefined,
|
|
194
|
+
theme,
|
|
195
|
+
vi.fn(),
|
|
196
|
+
);
|
|
197
|
+
viewer.render(120);
|
|
198
|
+
const timeline = (viewer as any).timeline;
|
|
199
|
+
const ids = timeline.getSnapshot().interactiveToolIds;
|
|
200
|
+
expect(ids).toHaveLength(2);
|
|
201
|
+
|
|
202
|
+
viewer.handleInput("]");
|
|
203
|
+
expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
|
|
204
|
+
viewer.render(120);
|
|
205
|
+
expect((viewer as any).currentBlock().toolCallId).toBe(ids[0]);
|
|
206
|
+
viewer.handleInput("]");
|
|
207
|
+
expect(timeline.getFocusedToolCallId()).toBe(ids[1]);
|
|
208
|
+
viewer.render(120);
|
|
209
|
+
expect((viewer as any).currentBlock().toolCallId).toBe(ids[1]);
|
|
210
|
+
viewer.handleInput("[");
|
|
211
|
+
expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("opens a complete read-only tool preview with w for the focused tool", () => {
|
|
215
|
+
const ui = tui();
|
|
216
|
+
const custom = vi.fn((factory: any) => {
|
|
217
|
+
const preview = factory(ui, theme, undefined, vi.fn());
|
|
218
|
+
const rendered = preview.render(100).join("\\n");
|
|
219
|
+
expect(rendered).toContain("Input");
|
|
220
|
+
expect(rendered).toContain("Output");
|
|
221
|
+
expect(rendered).toContain("one");
|
|
222
|
+
expect(rendered).toContain("six");
|
|
223
|
+
return Promise.resolve(undefined);
|
|
224
|
+
});
|
|
225
|
+
const viewer = new ConversationViewer(
|
|
226
|
+
ui,
|
|
227
|
+
createStaticConversationSource([
|
|
228
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
229
|
+
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
230
|
+
] as any),
|
|
231
|
+
record(),
|
|
232
|
+
undefined,
|
|
233
|
+
theme,
|
|
234
|
+
vi.fn(),
|
|
235
|
+
undefined,
|
|
236
|
+
undefined,
|
|
237
|
+
undefined,
|
|
238
|
+
{ pi: {} as any, ctx: { ui: { custom } } as any, readOnly: true },
|
|
239
|
+
);
|
|
240
|
+
viewer.render(120);
|
|
241
|
+
viewer.handleInput("]");
|
|
242
|
+
viewer.handleInput("w");
|
|
243
|
+
expect(custom).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ overlay: true }));
|
|
244
|
+
});
|
|
245
|
+
|
|
183
246
|
it("uses a distinct focus rail for tool cards", () => {
|
|
184
247
|
const railTheme = {
|
|
185
248
|
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|