pi-cc-extensions 0.8.48 → 0.8.49
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/README.en.md +17 -36
- package/README.md +13 -12
- package/assets/readme/welcome.webp +0 -0
- package/extensions/config/config.ts +245 -0
- package/extensions/config/panel.ts +469 -0
- package/extensions/{compact-thinking.ts → feature/compact-thinking.ts} +43 -6
- package/extensions/{markdown-enhance.ts → feature/markdown-enhance.ts} +88 -62
- package/extensions/{pi-startup-header.ts → feature/pi-startup-header.ts} +129 -56
- package/extensions/index.ts +9 -9
- package/extensions/renderer/index.ts +890 -0
- package/extensions/renderer/mouse-interaction.ts +1344 -0
- package/extensions/renderer/show-more-hint.ts +40 -0
- package/extensions/{tool-diff → renderer/tool-diff}/diff-renderer.ts +3 -2
- package/extensions/{tool-diff → renderer/tool-diff}/line-width-safety.ts +2 -1
- package/extensions/{tool-grouping.ts → renderer/tool-grouping.ts} +71 -10
- package/extensions/renderer/tool-result.ts +717 -0
- package/package.json +1 -1
- package/themes/cc-dark.json +2 -2
- package/themes/cc-light.json +2 -2
- package/extensions/claude-code-style.ts +0 -3348
- /package/extensions/{agent-autocomplete.ts → feature/agent-autocomplete.ts} +0 -0
- /package/extensions/{context.ts → feature/context.ts} +0 -0
- /package/extensions/{pi-aliases.ts → feature/pi-aliases.ts} +0 -0
- /package/extensions/{session-reference-core.ts → feature/session-reference-core.ts} +0 -0
- /package/extensions/{session-reference.ts → feature/session-reference.ts} +0 -0
- /package/extensions/{working-message.ts → feature/working-message.ts} +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/ATTRIBUTION.md +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/ansi-utils.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/diff-presentation.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/index.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/render-utils.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/shiki-highlight.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/types.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/write-display-utils.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/write-execution.ts +0 -0
- /package/extensions/{fullscreen-detect.ts → utils/fullscreen-detect.ts} +0 -0
- /package/extensions/{tool-loading-icon.ts → utils/tool-loading-icon.ts} +0 -0
- /package/extensions/{tool-result-sanitize.ts → utils/tool-result-sanitize.ts} +0 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import { inspect } from "node:util";
|
|
3
|
+
import { config } from "../config/config.ts";
|
|
4
|
+
import { showMoreHintText } from "./show-more-hint.ts";
|
|
5
|
+
import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../utils/tool-loading-icon.ts";
|
|
6
|
+
import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
|
|
7
|
+
|
|
8
|
+
const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
|
|
9
|
+
|
|
10
|
+
export function toolViewportWidth(width: number): number {
|
|
11
|
+
return Math.max(1, Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function oneLine(value: unknown, max = 72): string {
|
|
15
|
+
const text = String(value ?? "")
|
|
16
|
+
.replace(/\s+/g, " ")
|
|
17
|
+
.trim();
|
|
18
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rawTextFromResult(result: any): string {
|
|
22
|
+
return Array.isArray(result?.content)
|
|
23
|
+
? result.content
|
|
24
|
+
.filter((item: any) => item?.type === "text")
|
|
25
|
+
.map((item: any) => String(item.text ?? ""))
|
|
26
|
+
.join("\n")
|
|
27
|
+
: "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function detailsFromResult(result: any): string {
|
|
31
|
+
if (result?.details === undefined) return "";
|
|
32
|
+
const details =
|
|
33
|
+
typeof result.details === "string"
|
|
34
|
+
? result.details
|
|
35
|
+
: inspect(result.details, { depth: 8, breakLength: 100, compact: false });
|
|
36
|
+
return sanitizeToolResultText(details, 16_384);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function textFromResult(result: any, expanded = false): string {
|
|
40
|
+
// Compact previews only need short text; bound sanitize work.
|
|
41
|
+
const content = sanitizeToolResultText(rawTextFromResult(result), 16_384);
|
|
42
|
+
const details = detailsFromResult(result);
|
|
43
|
+
if (!content) return details;
|
|
44
|
+
if (!expanded || !details || details === content) return content;
|
|
45
|
+
return `${content}\nDetails:\n${details}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function outputLineCount(result: any): number {
|
|
49
|
+
const text = rawTextFromResult(result).replace(/\r\n?/g, "\n").replace(/\n+$/, "");
|
|
50
|
+
return text ? text.split("\n").length : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function countLines(text: string): number {
|
|
54
|
+
return text
|
|
55
|
+
.trim()
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter((line) => line.trim().length > 0).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasExpandableResult(text: string): boolean {
|
|
61
|
+
return countLines(text) > 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toolIcon(_name: string): string {
|
|
65
|
+
return "●";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const activeAnimationContexts = new Set<any>();
|
|
69
|
+
let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
|
|
71
|
+
function clearAnimation(context: any) {
|
|
72
|
+
if (!context?.state?.ccstyleAnimationScheduled) return;
|
|
73
|
+
context.state.ccstyleAnimationScheduled = false;
|
|
74
|
+
activeAnimationContexts.delete(context);
|
|
75
|
+
if (activeAnimationContexts.size === 0 && sharedAnimationTimer) {
|
|
76
|
+
clearTimeout(sharedAnimationTimer);
|
|
77
|
+
sharedAnimationTimer = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function clearAllAnimations() {
|
|
82
|
+
for (const ctx of activeAnimationContexts) {
|
|
83
|
+
ctx.state.ccstyleAnimationScheduled = false;
|
|
84
|
+
}
|
|
85
|
+
activeAnimationContexts.clear();
|
|
86
|
+
if (sharedAnimationTimer) {
|
|
87
|
+
clearTimeout(sharedAnimationTimer);
|
|
88
|
+
sharedAnimationTimer = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function scheduleAnimation(context: any, intervalMs = TOOL_LOADING_INTERVAL_MS) {
|
|
93
|
+
const state = (context.state ??= {});
|
|
94
|
+
if (state.ccstyleAnimationScheduled) return;
|
|
95
|
+
state.ccstyleAnimationScheduled = true;
|
|
96
|
+
activeAnimationContexts.add(context);
|
|
97
|
+
if (!sharedAnimationTimer) {
|
|
98
|
+
sharedAnimationTimer = setTimeout(() => {
|
|
99
|
+
sharedAnimationTimer = null;
|
|
100
|
+
const contexts = Array.from(activeAnimationContexts);
|
|
101
|
+
activeAnimationContexts.clear();
|
|
102
|
+
for (const ctx of contexts) {
|
|
103
|
+
ctx.state.ccstyleAnimationScheduled = false;
|
|
104
|
+
ctx.invalidate?.();
|
|
105
|
+
}
|
|
106
|
+
}, intervalMs);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function pendingIcon(_name: string): string {
|
|
111
|
+
return toolLoadingIcon();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type ToolVisualState = "pending" | "success" | "error";
|
|
115
|
+
|
|
116
|
+
export function settledIcon(name: string, state: ToolVisualState | undefined): string {
|
|
117
|
+
if (state === "success") return "✓";
|
|
118
|
+
if (state === "error") return "✗";
|
|
119
|
+
return toolIcon(name);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setToolVisualState(context: any, visualState: ToolVisualState) {
|
|
123
|
+
const state = (context.state ??= {});
|
|
124
|
+
if (visualState !== "pending") clearAnimation(context);
|
|
125
|
+
if (state.ccstyleToolVisualState === visualState) return;
|
|
126
|
+
state.ccstyleToolVisualState = visualState;
|
|
127
|
+
// Do not invalidate synchronously from renderResult. Pi is already rendering
|
|
128
|
+
// this tool row; recursively scheduling another render here can retain both
|
|
129
|
+
// the finalized result component and its previous secondary/partial component,
|
|
130
|
+
// which displays the result summary twice. The current render pass also
|
|
131
|
+
// refreshes renderCall, so the settled icon still updates immediately.
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getToolVisualState(context: any): ToolVisualState | undefined {
|
|
135
|
+
return context?.state?.ccstyleToolVisualState as ToolVisualState | undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function resolveToolVisualState(context: any): ToolVisualState | undefined {
|
|
139
|
+
const visualState = getToolVisualState(context);
|
|
140
|
+
if (visualState || context?.isPartial !== false) return visualState;
|
|
141
|
+
const settledState: ToolVisualState = context?.isError ? "error" : "success";
|
|
142
|
+
setToolVisualState(context, settledState);
|
|
143
|
+
return settledState;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function toolIconColor(context: any): "accent" | "error" | "success" | "muted" {
|
|
147
|
+
const visualState = getToolVisualState(context);
|
|
148
|
+
if (context?.isError || visualState === "error") return "error";
|
|
149
|
+
if (visualState === "success") return "success";
|
|
150
|
+
if (context?.isPartial || context?.executionStarted || visualState === "pending") return "accent";
|
|
151
|
+
return "muted";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isToolExpanded(options: any, context: any): boolean {
|
|
155
|
+
const local = context?.state?.ccstyleToolExpanded;
|
|
156
|
+
return typeof local === "boolean" ? local : Boolean(options?.expanded ?? context?.expanded);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Keep the guide aligned when long result lines wrap at the viewport edge. */
|
|
160
|
+
export class ExpandedToolResultText {
|
|
161
|
+
private text: string;
|
|
162
|
+
private prefix: string;
|
|
163
|
+
private normalizedText: string;
|
|
164
|
+
private cachedWidth: number | undefined;
|
|
165
|
+
private cachedLines: string[] | undefined;
|
|
166
|
+
|
|
167
|
+
constructor(text: string, prefix: string) {
|
|
168
|
+
this.text = text;
|
|
169
|
+
this.prefix = prefix;
|
|
170
|
+
this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setText(text: string): void {
|
|
174
|
+
if (this.text === text) return;
|
|
175
|
+
this.text = text;
|
|
176
|
+
this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
|
|
177
|
+
this.invalidate();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setPrefix(prefix: string): void {
|
|
181
|
+
if (this.prefix === prefix) return;
|
|
182
|
+
this.prefix = prefix;
|
|
183
|
+
this.invalidate();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
render(width: number): string[] {
|
|
187
|
+
if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
|
|
188
|
+
|
|
189
|
+
const prefixWidth = visibleWidth(this.prefix);
|
|
190
|
+
const contentWidth = Math.max(1, width - prefixWidth);
|
|
191
|
+
const lines = wrapTextWithAnsi(this.normalizedText, contentWidth).map((line) =>
|
|
192
|
+
truncateToWidth(this.prefix + line, width, ""),
|
|
193
|
+
);
|
|
194
|
+
this.cachedWidth = width;
|
|
195
|
+
this.cachedLines = lines;
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
invalidate(): void {
|
|
200
|
+
this.cachedWidth = undefined;
|
|
201
|
+
this.cachedLines = undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Affordance next to truncated Input/Output headers — click opens full preview. */
|
|
206
|
+
export const SHOW_MORE_LABEL = "• click to show more";
|
|
207
|
+
|
|
208
|
+
export type ToolIoSection = "input" | "output";
|
|
209
|
+
|
|
210
|
+
// Module-local token: /reload creates a fresh token, so stale view instances are
|
|
211
|
+
// replaced instead of retaining their old render implementation.
|
|
212
|
+
const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view");
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Expanded tool body with clear Input / Output sections (Grok Build–style).
|
|
216
|
+
*
|
|
217
|
+
* Visual frame:
|
|
218
|
+
* ├ Input click to show more
|
|
219
|
+
* │ path: src/a.ts
|
|
220
|
+
* │
|
|
221
|
+
* └ Output click to show more
|
|
222
|
+
* result line…
|
|
223
|
+
*
|
|
224
|
+
* Reused across re-renders via context.lastComponent when possible.
|
|
225
|
+
*/
|
|
226
|
+
export class ExpandedToolIoView {
|
|
227
|
+
readonly [EXPANDED_TOOL_IO_VIEW_GENERATION] = true;
|
|
228
|
+
private inputBody: string;
|
|
229
|
+
private outputBody: string;
|
|
230
|
+
private isError: boolean;
|
|
231
|
+
private theme: any;
|
|
232
|
+
private maxOutputLines: number;
|
|
233
|
+
private maxInputLines: number;
|
|
234
|
+
private cachedWidth: number | undefined;
|
|
235
|
+
private cachedLines: string[] | undefined;
|
|
236
|
+
private hoveredSection: ToolIoSection | null = null;
|
|
237
|
+
/** Which sections currently show the show-more affordance (after last render). */
|
|
238
|
+
private truncated: { input: boolean; output: boolean } = { input: false, output: false };
|
|
239
|
+
/** 0-based header line indexes that carry show-more after last render. */
|
|
240
|
+
private showMoreHeaderRows: { input?: number; output?: number } = {};
|
|
241
|
+
|
|
242
|
+
constructor(
|
|
243
|
+
theme: any,
|
|
244
|
+
inputBody: string,
|
|
245
|
+
outputBody: string,
|
|
246
|
+
isError: boolean,
|
|
247
|
+
maxOutputLines = config.expandedPreviewMaxLines,
|
|
248
|
+
maxInputLines = config.expandedPreviewMaxLines,
|
|
249
|
+
) {
|
|
250
|
+
this.theme = theme;
|
|
251
|
+
this.inputBody = inputBody;
|
|
252
|
+
this.outputBody = outputBody;
|
|
253
|
+
this.isError = isError;
|
|
254
|
+
this.maxOutputLines = Math.max(1, maxOutputLines);
|
|
255
|
+
this.maxInputLines = Math.max(1, maxInputLines);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setContent(
|
|
259
|
+
inputBody: string,
|
|
260
|
+
outputBody: string,
|
|
261
|
+
isError: boolean,
|
|
262
|
+
maxOutputLines?: number,
|
|
263
|
+
maxInputLines?: number,
|
|
264
|
+
): void {
|
|
265
|
+
const nextOut =
|
|
266
|
+
maxOutputLines !== undefined ? Math.max(1, maxOutputLines) : this.maxOutputLines;
|
|
267
|
+
const nextIn = maxInputLines !== undefined ? Math.max(1, maxInputLines) : this.maxInputLines;
|
|
268
|
+
if (
|
|
269
|
+
this.inputBody === inputBody &&
|
|
270
|
+
this.outputBody === outputBody &&
|
|
271
|
+
this.isError === isError &&
|
|
272
|
+
this.maxOutputLines === nextOut &&
|
|
273
|
+
this.maxInputLines === nextIn
|
|
274
|
+
) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.inputBody = inputBody;
|
|
278
|
+
this.outputBody = outputBody;
|
|
279
|
+
this.isError = isError;
|
|
280
|
+
this.maxOutputLines = nextOut;
|
|
281
|
+
this.maxInputLines = nextIn;
|
|
282
|
+
this.invalidate();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
getInputBody(): string {
|
|
286
|
+
return this.inputBody;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getOutputBody(): string {
|
|
290
|
+
return this.outputBody.trim() ? this.outputBody : "Done";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
setHoveredSection(section: ToolIoSection | null): void {
|
|
294
|
+
if (this.hoveredSection === section) return;
|
|
295
|
+
this.hoveredSection = section;
|
|
296
|
+
this.invalidate();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** True when the plain header line is a truncated section with show-more. */
|
|
300
|
+
matchShowMoreLine(plainLine: string): ToolIoSection | null {
|
|
301
|
+
const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
|
|
302
|
+
if (!line.includes(` • ${showMoreHintText()}`)) return null;
|
|
303
|
+
if (/\bInput\b/.test(line) && this.truncated.input) return "input";
|
|
304
|
+
if (/\bOutput\b/.test(line) && this.truncated.output) return "output";
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Precise header rows marked for show-more hit testing (last render). */
|
|
309
|
+
showMoreHeaderLineIndexes(): ReadonlyArray<{ section: ToolIoSection; line: number }> {
|
|
310
|
+
const out: Array<{ section: ToolIoSection; line: number }> = [];
|
|
311
|
+
if (this.showMoreHeaderRows.input !== undefined) {
|
|
312
|
+
out.push({ section: "input", line: this.showMoreHeaderRows.input });
|
|
313
|
+
}
|
|
314
|
+
if (this.showMoreHeaderRows.output !== undefined) {
|
|
315
|
+
out.push({ section: "output", line: this.showMoreHeaderRows.output });
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Column range (1-based, visible cells) of show-more on a rendered header, if present. */
|
|
321
|
+
showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
|
|
322
|
+
const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
|
|
323
|
+
const label = ` • ${showMoreHintText()}`;
|
|
324
|
+
const idx = line.lastIndexOf(label);
|
|
325
|
+
if (idx < 0) return null;
|
|
326
|
+
const before = line.slice(0, idx);
|
|
327
|
+
const startCol = visibleWidth(before) + 1;
|
|
328
|
+
const endCol = startCol + visibleWidth(label) - 1;
|
|
329
|
+
return { startCol, endCol };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
render(width: number): string[] {
|
|
333
|
+
if (this.cachedLines !== undefined && this.cachedWidth === width) {
|
|
334
|
+
return withIoViewMarkers(this, this.cachedLines);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const theme = this.theme;
|
|
338
|
+
const safeWidth = Math.max(1, Math.floor(width));
|
|
339
|
+
const rail = " │ ";
|
|
340
|
+
const railWidth = visibleWidth(rail);
|
|
341
|
+
const bodyWidth = toolViewportWidth(safeWidth);
|
|
342
|
+
const contentWidth = Math.max(1, bodyWidth - railWidth);
|
|
343
|
+
const bodyColor = this.isError ? "error" : "toolOutput";
|
|
344
|
+
const lines: string[] = [];
|
|
345
|
+
this.truncated = { input: false, output: false };
|
|
346
|
+
this.showMoreHeaderRows = {};
|
|
347
|
+
|
|
348
|
+
const pushHeader = (
|
|
349
|
+
corner: "├" | "└",
|
|
350
|
+
label: string,
|
|
351
|
+
section: ToolIoSection,
|
|
352
|
+
showMore: boolean,
|
|
353
|
+
) => {
|
|
354
|
+
const mark = theme.fg("dim", ` ${corner} `);
|
|
355
|
+
const title = theme.fg(
|
|
356
|
+
"accent",
|
|
357
|
+
typeof theme.bold === "function" ? theme.bold(label) : label,
|
|
358
|
+
);
|
|
359
|
+
// hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
|
|
360
|
+
const more = showMore
|
|
361
|
+
? theme.fg("dim", " •") +
|
|
362
|
+
theme.fg(this.hoveredSection === section ? "text" : "dim", ` ${showMoreHintText()}`)
|
|
363
|
+
: "";
|
|
364
|
+
if (showMore) this.showMoreHeaderRows[section] = lines.length;
|
|
365
|
+
lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const pushRailLine = (styledContent: string, continued = true) => {
|
|
369
|
+
const prefix = continued ? rail : " ";
|
|
370
|
+
lines.push(truncateToWidth(theme.fg("dim", prefix) + styledContent, safeWidth, ""));
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const pushBlankRail = () => {
|
|
374
|
+
lines.push(truncateToWidth(theme.fg("dim", " │"), safeWidth, ""));
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** Style `key: value` input rows — dim keys, readable values. */
|
|
378
|
+
const styleInputLine = (rawLine: string): string => {
|
|
379
|
+
const match = rawLine.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
|
|
380
|
+
if (!match) return theme.fg("muted", rawLine);
|
|
381
|
+
const [, key, sep, rest] = match;
|
|
382
|
+
return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const pushBody = (
|
|
386
|
+
body: string,
|
|
387
|
+
opts: { input?: boolean; limit: number; continued?: boolean },
|
|
388
|
+
): boolean /* truncated */ => {
|
|
389
|
+
const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
|
|
390
|
+
if (!raw.trim()) {
|
|
391
|
+
pushRailLine(theme.fg("dim", "(empty)"), opts.continued);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
const sourceLines = raw.split("\n");
|
|
395
|
+
const wrapped: string[] = [];
|
|
396
|
+
for (const source of sourceLines) {
|
|
397
|
+
const styled = opts.input ? styleInputLine(source) : theme.fg(bodyColor, source);
|
|
398
|
+
const parts = wrapTextWithAnsi(styled, contentWidth);
|
|
399
|
+
if (parts.length === 0) wrapped.push(styled);
|
|
400
|
+
else wrapped.push(...parts);
|
|
401
|
+
}
|
|
402
|
+
// Prefer source-line count so plain multi-line dumps always cap, even when
|
|
403
|
+
// theme/wrap measurements disagree slightly.
|
|
404
|
+
const truncated = wrapped.length > opts.limit || sourceLines.length > opts.limit;
|
|
405
|
+
const visible = truncated ? wrapped.slice(0, Math.min(opts.limit, wrapped.length)) : wrapped;
|
|
406
|
+
for (const line of visible) pushRailLine(line, opts.continued);
|
|
407
|
+
if (truncated) {
|
|
408
|
+
const hidden = Math.max(0, wrapped.length - visible.length);
|
|
409
|
+
if (hidden > 0) {
|
|
410
|
+
pushRailLine(theme.fg("dim", `… +${hidden} more lines`), opts.continued);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return truncated;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const hasInput = this.inputBody.trim().length > 0;
|
|
417
|
+
const outputText = this.getOutputBody();
|
|
418
|
+
|
|
419
|
+
// Decide show-more from the same truncation rules as pushBody.
|
|
420
|
+
const inputWouldTruncate =
|
|
421
|
+
hasInput &&
|
|
422
|
+
bodyExceedsLineLimit(this.inputBody, this.maxInputLines, contentWidth, true, theme);
|
|
423
|
+
const outputWouldTruncate = bodyExceedsLineLimit(
|
|
424
|
+
outputText,
|
|
425
|
+
this.maxOutputLines,
|
|
426
|
+
contentWidth,
|
|
427
|
+
false,
|
|
428
|
+
theme,
|
|
429
|
+
bodyColor,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
if (hasInput) {
|
|
433
|
+
this.truncated.input = inputWouldTruncate;
|
|
434
|
+
pushHeader("├", "Input", "input", inputWouldTruncate);
|
|
435
|
+
pushBody(this.inputBody, {
|
|
436
|
+
input: true,
|
|
437
|
+
limit: this.maxInputLines,
|
|
438
|
+
continued: true,
|
|
439
|
+
});
|
|
440
|
+
pushBlankRail();
|
|
441
|
+
this.truncated.output = outputWouldTruncate;
|
|
442
|
+
pushHeader("└", "Output", "output", outputWouldTruncate);
|
|
443
|
+
pushBody(outputText, { limit: this.maxOutputLines, continued: false });
|
|
444
|
+
} else {
|
|
445
|
+
this.truncated.output = outputWouldTruncate;
|
|
446
|
+
pushHeader("└", "Output", "output", outputWouldTruncate);
|
|
447
|
+
pushBody(outputText, { limit: this.maxOutputLines, continued: false });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
this.cachedWidth = width;
|
|
451
|
+
this.cachedLines = lines;
|
|
452
|
+
return withIoViewMarkers(this, lines);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
invalidate(): void {
|
|
456
|
+
this.cachedWidth = undefined;
|
|
457
|
+
this.cachedLines = undefined;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function isExpandedToolIoView(value: unknown): value is ExpandedToolIoView {
|
|
462
|
+
return Boolean(
|
|
463
|
+
value &&
|
|
464
|
+
typeof value === "object" &&
|
|
465
|
+
(value as ExpandedToolIoView)[EXPANDED_TOOL_IO_VIEW_GENERATION] === true &&
|
|
466
|
+
typeof (value as ExpandedToolIoView).getInputBody === "function" &&
|
|
467
|
+
typeof (value as ExpandedToolIoView).getOutputBody === "function" &&
|
|
468
|
+
typeof (value as ExpandedToolIoView).setHoveredSection === "function" &&
|
|
469
|
+
typeof (value as ExpandedToolIoView).render === "function",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** True when body needs truncation at the given line limit (source lines or wrapped rows). */
|
|
474
|
+
function bodyExceedsLineLimit(
|
|
475
|
+
body: string,
|
|
476
|
+
limit: number,
|
|
477
|
+
contentWidth: number,
|
|
478
|
+
asInput: boolean,
|
|
479
|
+
theme: any,
|
|
480
|
+
bodyColor = "toolOutput",
|
|
481
|
+
): boolean {
|
|
482
|
+
const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
|
|
483
|
+
if (!raw.trim()) return false;
|
|
484
|
+
const sourceLines = raw.split("\n");
|
|
485
|
+
if (sourceLines.length > limit) return true;
|
|
486
|
+
let total = 0;
|
|
487
|
+
for (const source of sourceLines) {
|
|
488
|
+
let styled: string;
|
|
489
|
+
if (asInput) {
|
|
490
|
+
const match = source.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
|
|
491
|
+
styled = match
|
|
492
|
+
? theme.fg("dim", match[1] + match[2]) + theme.fg("muted", match[3] ?? "")
|
|
493
|
+
: theme.fg("muted", source);
|
|
494
|
+
} else {
|
|
495
|
+
styled = theme.fg(bodyColor, source);
|
|
496
|
+
}
|
|
497
|
+
const parts = wrapTextWithAnsi(styled, contentWidth);
|
|
498
|
+
total += Math.max(1, parts.length);
|
|
499
|
+
if (total > limit) return true;
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
|
|
505
|
+
return ` ↳ ${body}${collapsedHint}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function renderCollapsedToolResultToWidth(
|
|
509
|
+
body: string,
|
|
510
|
+
collapsedHint: string,
|
|
511
|
+
width: number,
|
|
512
|
+
prefix = " ↳ ",
|
|
513
|
+
): string {
|
|
514
|
+
const previewWidth = toolViewportWidth(width);
|
|
515
|
+
const bodyWidth = Math.max(1, previewWidth - visibleWidth(prefix) - visibleWidth(collapsedHint));
|
|
516
|
+
return truncateToWidth(
|
|
517
|
+
prefix + middleTruncateToWidth(body, bodyWidth) + collapsedHint,
|
|
518
|
+
previewWidth,
|
|
519
|
+
"",
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 截断到宽度时保留首尾内容(渲染折叠行与工具标题用)。 */
|
|
524
|
+
export function middleTruncateToWidth(text: string, width: number): string {
|
|
525
|
+
if (visibleWidth(text) <= width) return text;
|
|
526
|
+
if (width <= 1) return "…";
|
|
527
|
+
const chars = Array.from(text);
|
|
528
|
+
const leftWidth = Math.ceil((width - 1) / 2);
|
|
529
|
+
let left = "";
|
|
530
|
+
let right = "";
|
|
531
|
+
for (const char of chars) {
|
|
532
|
+
if (visibleWidth(left + char) > leftWidth) break;
|
|
533
|
+
left += char;
|
|
534
|
+
}
|
|
535
|
+
for (const char of chars.reverse()) {
|
|
536
|
+
if (visibleWidth(left + "…" + char + right) > width) break;
|
|
537
|
+
right = char + right;
|
|
538
|
+
}
|
|
539
|
+
return `${left}…${right}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Pretty-print tool call args for the expanded Input section. */
|
|
543
|
+
export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
|
|
544
|
+
if (args === undefined || args === null) return "";
|
|
545
|
+
if (typeof args !== "object") {
|
|
546
|
+
const text = String(args);
|
|
547
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
548
|
+
}
|
|
549
|
+
if (Array.isArray(args)) {
|
|
550
|
+
try {
|
|
551
|
+
const json = JSON.stringify(args, null, 2);
|
|
552
|
+
return json.length > maxChars ? `${json.slice(0, maxChars)}…` : json;
|
|
553
|
+
} catch {
|
|
554
|
+
return String(args);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const entries = Object.entries(args as Record<string, unknown>).filter(
|
|
559
|
+
([, value]) => value !== undefined,
|
|
560
|
+
);
|
|
561
|
+
if (entries.length === 0) return "";
|
|
562
|
+
|
|
563
|
+
// Stable, human-first field order for common tools.
|
|
564
|
+
const preferred = [
|
|
565
|
+
"path",
|
|
566
|
+
"file_path",
|
|
567
|
+
"command",
|
|
568
|
+
"query",
|
|
569
|
+
"pattern",
|
|
570
|
+
"url",
|
|
571
|
+
"name",
|
|
572
|
+
"message",
|
|
573
|
+
"content",
|
|
574
|
+
"old_string",
|
|
575
|
+
"new_string",
|
|
576
|
+
];
|
|
577
|
+
entries.sort(([left], [right]) => {
|
|
578
|
+
const li = preferred.indexOf(left);
|
|
579
|
+
const ri = preferred.indexOf(right);
|
|
580
|
+
if (li === -1 && ri === -1) return left.localeCompare(right);
|
|
581
|
+
if (li === -1) return 1;
|
|
582
|
+
if (ri === -1) return -1;
|
|
583
|
+
return li - ri;
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const lines: string[] = [];
|
|
587
|
+
for (const [key, value] of entries) {
|
|
588
|
+
if (typeof value === "string") {
|
|
589
|
+
if (value.includes("\n")) {
|
|
590
|
+
lines.push(`${key}:`);
|
|
591
|
+
for (const line of value.replace(/\t/g, " ").split("\n")) {
|
|
592
|
+
lines.push(` ${line}`);
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
lines.push(`${key}: ${value}`);
|
|
596
|
+
}
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
|
600
|
+
lines.push(`${key}: ${String(value)}`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const json = JSON.stringify(value, null, 2);
|
|
605
|
+
if (json.includes("\n")) {
|
|
606
|
+
lines.push(`${key}:`);
|
|
607
|
+
for (const line of json.split("\n")) lines.push(` ${line}`);
|
|
608
|
+
} else {
|
|
609
|
+
lines.push(`${key}: ${json}`);
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
lines.push(`${key}: [unserializable]`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const text = lines.join("\n");
|
|
616
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function hasExpandableDetail(outputText: string, args: unknown): boolean {
|
|
620
|
+
if (hasExpandableResult(outputText)) return true;
|
|
621
|
+
return formatToolInputArgs(args).trim().length > 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function renderExpandedToolResult(
|
|
625
|
+
body: string,
|
|
626
|
+
theme: any,
|
|
627
|
+
isError: boolean,
|
|
628
|
+
lastComponent?: unknown,
|
|
629
|
+
args?: unknown,
|
|
630
|
+
context?: any,
|
|
631
|
+
): ExpandedToolIoView | ExpandedToolResultText | Text {
|
|
632
|
+
const inputBody = formatToolInputArgs(args);
|
|
633
|
+
const outputBody = body;
|
|
634
|
+
const maxLines = config.expandedPreviewMaxLines;
|
|
635
|
+
|
|
636
|
+
// Prefer structured Input/Output when we have args or non-empty output.
|
|
637
|
+
if (inputBody.trim() || outputBody.trim()) {
|
|
638
|
+
let view: ExpandedToolIoView;
|
|
639
|
+
if (isExpandedToolIoView(lastComponent)) {
|
|
640
|
+
lastComponent.setContent(inputBody, outputBody, isError, maxLines, maxLines);
|
|
641
|
+
view = lastComponent;
|
|
642
|
+
} else {
|
|
643
|
+
view = new ExpandedToolIoView(theme, inputBody, outputBody, isError, maxLines, maxLines);
|
|
644
|
+
}
|
|
645
|
+
if (context) rememberIoView(context, view);
|
|
646
|
+
return view;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (context?.state) context.state.ccstyleIoView = undefined;
|
|
650
|
+
const color = isError ? "error" : "muted";
|
|
651
|
+
return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function formatExpandHint(lineCount: number): string {
|
|
655
|
+
return ` (${lineCount} more line${lineCount === 1 ? "" : "s"} / click)`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function expandHint(theme: any, lineCount: number, hovered = false): string {
|
|
659
|
+
// Keep interaction guidance neutral; it should not inherit success/error
|
|
660
|
+
// coloring from the tool result surrounding it.
|
|
661
|
+
return theme.fg(hovered ? "text" : "muted", formatExpandHint(lineCount));
|
|
662
|
+
}
|
|
663
|
+
const toolViewMarker = (id: number, section: ToolIoSection) =>
|
|
664
|
+
`_cc:v${id}:${section === "input" ? "i" : "o"}`;
|
|
665
|
+
|
|
666
|
+
/** Per-frame ExpandedToolIoView ids for unambiguous show-more hit testing. */
|
|
667
|
+
export type IoViewFrameState = {
|
|
668
|
+
viewIds: Map<ExpandedToolIoView, number>;
|
|
669
|
+
idToView: Map<number, ExpandedToolIoView>;
|
|
670
|
+
nextId: number;
|
|
671
|
+
};
|
|
672
|
+
let activeIoViewFrame: IoViewFrameState | null = null;
|
|
673
|
+
|
|
674
|
+
function frameViewId(view: ExpandedToolIoView): number | null {
|
|
675
|
+
if (!activeIoViewFrame) return null;
|
|
676
|
+
let id = activeIoViewFrame.viewIds.get(view);
|
|
677
|
+
if (id === undefined) {
|
|
678
|
+
id = activeIoViewFrame.nextId++;
|
|
679
|
+
activeIoViewFrame.viewIds.set(view, id);
|
|
680
|
+
activeIoViewFrame.idToView.set(id, view);
|
|
681
|
+
}
|
|
682
|
+
return id;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** cachedLines stay clean; only the returned paint copy carries show-more view markers. */
|
|
686
|
+
function withIoViewMarkers(view: ExpandedToolIoView, lines: string[]): string[] {
|
|
687
|
+
const id = frameViewId(view);
|
|
688
|
+
if (id === null) return lines;
|
|
689
|
+
// Mark by exact header row from render — never scan body text for Input/Output labels.
|
|
690
|
+
const marked = lines.slice();
|
|
691
|
+
for (const { section, line } of view.showMoreHeaderLineIndexes()) {
|
|
692
|
+
if (line < 0 || line >= marked.length) continue;
|
|
693
|
+
marked[line] = `${marked[line]}${toolViewMarker(id, section)}`;
|
|
694
|
+
}
|
|
695
|
+
return marked;
|
|
696
|
+
}
|
|
697
|
+
const ioViewInvalidators = new WeakMap<ExpandedToolIoView, () => void>();
|
|
698
|
+
function rememberIoView(context: any, view: ExpandedToolIoView): void {
|
|
699
|
+
if (!context || typeof context !== "object") return;
|
|
700
|
+
if (typeof context.invalidate === "function") ioViewInvalidators.set(view, context.invalidate);
|
|
701
|
+
if (!context.state || typeof context.state !== "object") context.state = {};
|
|
702
|
+
context.state.ccstyleIoView = view;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** hover 状态变更后刷新上一个/下一个视图(context.invalidate 缓存于 ioViewInvalidators)。 */
|
|
706
|
+
export function invalidateIoView(view: ExpandedToolIoView): void {
|
|
707
|
+
ioViewInvalidators.get(view)?.();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 鼠标帧渲染期间读写当前 IoView 帧 id 表(mouse-interaction 的 doRender 包装使用)。 */
|
|
711
|
+
export function getActiveIoViewFrame(): IoViewFrameState | null {
|
|
712
|
+
return activeIoViewFrame;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function setActiveIoViewFrame(frame: IoViewFrameState | null): void {
|
|
716
|
+
activeIoViewFrame = frame;
|
|
717
|
+
}
|