@herbertgao/pi-extensions 2026.10.4 → 2026.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +13 -9
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +13 -9
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +72 -16
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +262 -48
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/core.ts +2 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +1 -1
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +131 -28
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +2 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/subagent.ts +3 -0
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +96 -59
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +204 -0
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -86
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +212 -74
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +50 -362
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +35 -3
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +16 -83
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +1 -1
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +28 -26
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +1 -1
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +450 -0
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ccstyle-tool.ts +467 -0
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +5 -9
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +1 -24
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-limits.ts +1 -1
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-text.ts +2 -1
  33. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +36 -10
  34. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +1 -1
  35. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +140 -51
  36. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/message-display.ts +1 -1
  37. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +44 -4
  38. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +34 -803
  39. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-io.ts +735 -0
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-state.ts +138 -0
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  43. package/node_modules/@herbertgao/pi-cc-extensions/package.json +5 -4
  44. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +2 -2
  45. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +3 -3
  46. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  47. package/node_modules/@herbertgao/sol-pi/README.md +3 -3
  48. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
  49. package/node_modules/@herbertgao/sol-pi/agents-install.md +3 -3
  50. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +1 -1
  51. package/package.json +2 -2
  52. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/show-more-hint.ts +0 -48
@@ -0,0 +1,735 @@
1
+ import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import { config } from "../../config/config.ts";
3
+ import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
4
+ import {
5
+ SHOW_MORE_LABEL,
6
+ headTruncateToWidth,
7
+ middleTruncateToWidth,
8
+ renderCollapsedToolResult,
9
+ toolViewportWidth,
10
+ } from "./result.ts";
11
+ import { showMoreHintText } from "./names.ts";
12
+ import { TOOL_LOADING_INTERVAL_MS } from "../../utils/tool-loading-icon.ts";
13
+ import { createCodeBlockHighlighter } from "./diff/diff-highlight.ts";
14
+
15
+ export class ExpandedToolResultText {
16
+ private text: string;
17
+ private prefix: string;
18
+ private normalizedText: string;
19
+ private cachedWidth: number | undefined;
20
+ private cachedLines: string[] | undefined;
21
+
22
+ constructor(text: string, prefix: string) {
23
+ this.text = text;
24
+ this.prefix = prefix;
25
+ this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
26
+ }
27
+
28
+ setText(text: string): void {
29
+ if (this.text === text) return;
30
+ this.text = text;
31
+ this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
32
+ this.invalidate();
33
+ }
34
+
35
+ setPrefix(prefix: string): void {
36
+ if (this.prefix === prefix) return;
37
+ this.prefix = prefix;
38
+ this.invalidate();
39
+ }
40
+
41
+ render(width: number): string[] {
42
+ if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
43
+
44
+ const prefixWidth = visibleWidth(this.prefix);
45
+ const contentWidth = Math.max(1, width - prefixWidth);
46
+ const lines = wrapTextWithAnsi(this.normalizedText, contentWidth).map((line) =>
47
+ truncateToWidth(this.prefix + line, width, ""),
48
+ );
49
+ this.cachedWidth = width;
50
+ this.cachedLines = lines;
51
+ return lines;
52
+ }
53
+
54
+ invalidate(): void {
55
+ this.cachedWidth = undefined;
56
+ this.cachedLines = undefined;
57
+ }
58
+ }
59
+
60
+ /** 截断体末行 `… N more lines` 旁的展开提示,点击打开全量预览。 */
61
+
62
+ export type ToolIoSection = "input" | "output";
63
+
64
+ // Module-local token: /reload creates a fresh token, so stale view instances are
65
+ // replaced instead of retaining their old render implementation.
66
+ const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view");
67
+
68
+ /**
69
+ * Expanded tool body with clear Input / Output sections (Grok Build–style).
70
+ *
71
+ * Visual frame(flushLeft=true:树线的前导空格交给外层 Box(1,1),├/└ 与标题行的勾同列):
72
+ * ├ Input click to show more
73
+ * │ path: src/a.ts
74
+ * │
75
+ * └ Output click to show more
76
+ * result line…
77
+ *
78
+ * Reused across re-renders via context.lastComponent when possible.
79
+ */
80
+ export class ExpandedToolIoView {
81
+ readonly [EXPANDED_TOOL_IO_VIEW_GENERATION] = true;
82
+ private inputBody: string;
83
+ private outputBody: string;
84
+ private isError: boolean;
85
+ private theme: any;
86
+ private maxOutputLines: number;
87
+ private maxInputLines: number;
88
+ private cachedWidth: number | undefined;
89
+ private cachedLines: string[] | undefined;
90
+ private hoveredSection: ToolIoSection | null = null;
91
+ /** Which sections currently show the show-more affordance (after last render). */
92
+ private truncated: { input: boolean; output: boolean } = { input: false, output: false };
93
+ /** 0-based header line indexes that carry show-more after last render. */
94
+ private showMoreHeaderRows: { input?: number; output?: number } = {};
95
+ /** Input 里的代码块(codemode 的 `code`):着色行覆盖默认 dim/muted 样式。 */
96
+ private inputCode?:
97
+ | {
98
+ startLine: number;
99
+ language: string;
100
+ standalone: boolean;
101
+ code: string;
102
+ theme: unknown;
103
+ rows: () => string[];
104
+ }
105
+ | undefined;
106
+
107
+ /** flushLeft:贴左渲染(外层 Box 负责 1 格 pad);默认 false 保留 1 格前导空格。 */
108
+ private flushLeft: boolean;
109
+ /** 尾部附注(shell 的耗时)。挂到 Output 的 show-more 行上,没有那一行时单独成行。 */
110
+ private footer: string | undefined;
111
+
112
+ constructor(
113
+ theme: any,
114
+ inputBody: string,
115
+ outputBody: string,
116
+ isError: boolean,
117
+ maxOutputLines = config.expandedOutputMaxLines,
118
+ maxInputLines = config.expandedInputMaxLines,
119
+ flushLeft = false,
120
+ ) {
121
+ this.theme = theme;
122
+ this.inputBody = inputBody;
123
+ this.outputBody = outputBody;
124
+ this.isError = isError;
125
+ this.maxOutputLines = Math.max(1, maxOutputLines);
126
+ this.maxInputLines = Math.max(1, maxInputLines);
127
+ this.flushLeft = flushLeft;
128
+ }
129
+
130
+ setContent(
131
+ inputBody: string,
132
+ outputBody: string,
133
+ isError: boolean,
134
+ maxOutputLines?: number,
135
+ maxInputLines?: number,
136
+ flushLeft?: boolean,
137
+ ): void {
138
+ const nextOut =
139
+ maxOutputLines !== undefined ? Math.max(1, maxOutputLines) : this.maxOutputLines;
140
+ const nextIn = maxInputLines !== undefined ? Math.max(1, maxInputLines) : this.maxInputLines;
141
+ const nextFlush = flushLeft !== undefined ? flushLeft : this.flushLeft;
142
+ if (
143
+ this.inputBody === inputBody &&
144
+ this.outputBody === outputBody &&
145
+ this.isError === isError &&
146
+ this.maxOutputLines === nextOut &&
147
+ this.maxInputLines === nextIn &&
148
+ this.flushLeft === nextFlush
149
+ ) {
150
+ return;
151
+ }
152
+ this.inputBody = inputBody;
153
+ this.outputBody = outputBody;
154
+ this.isError = isError;
155
+ this.maxOutputLines = nextOut;
156
+ this.maxInputLines = nextIn;
157
+ this.flushLeft = nextFlush;
158
+ this.invalidate();
159
+ }
160
+
161
+ getInputBody(): string {
162
+ return this.inputBody;
163
+ }
164
+
165
+ /**
166
+ * 登记 Input 里的代码块:同步 cli-highlight 打底,shiki 异步升级,落地后
167
+ * 经 invalidate 重画。同一份代码与主题只建一次(视图按宽度缓存行)。
168
+ */
169
+ setInputCode(block: InputCodeBlock | undefined): void {
170
+ if (!block) {
171
+ if (this.inputCode) {
172
+ this.inputCode = undefined;
173
+ this.invalidate();
174
+ }
175
+ return;
176
+ }
177
+ if (this.inputCode?.code === block.code && this.inputCode.theme === this.theme) {
178
+ this.inputCode.startLine = block.startLine;
179
+ this.inputCode.standalone = block.standalone;
180
+ return;
181
+ }
182
+ this.inputCode = {
183
+ startLine: block.startLine,
184
+ language: block.language,
185
+ standalone: block.standalone,
186
+ code: block.code,
187
+ theme: this.theme,
188
+ rows: createCodeBlockHighlighter(block.code, block.language, this.theme, () => {
189
+ // 视图自己的行缓存 + 外层工具卡(清 paint 缓存并 requestRender)
190
+ this.invalidate();
191
+ invalidateIoView(this);
192
+ }),
193
+ };
194
+ this.invalidate();
195
+ }
196
+
197
+ /**
198
+ * 整段 Input 就是代码块时给出语言与代码,供全量预览包一层围栏(弹框按 Markdown
199
+ * 渲染,围栏代码块由 pi 的 highlightCode 着色)。混了别的字段时返回 undefined。
200
+ */
201
+ getInputCodeFence(): { language: string; code: string } | undefined {
202
+ const block = this.inputCode;
203
+ return block?.standalone ? { language: block.language, code: block.code } : undefined;
204
+ }
205
+
206
+ /** 代码块里某一源行的着色内容(已剥掉行首缩进);不在块内时为 undefined。 */
207
+ private inputCodeLine(sourceIndex: number): string | undefined {
208
+ const block = this.inputCode;
209
+ if (!block) return undefined;
210
+ const offset = sourceIndex - block.startLine;
211
+ if (offset < 0) return undefined;
212
+ const rows = block.rows();
213
+ return offset < rows.length ? rows[offset] : undefined;
214
+ }
215
+
216
+ getOutputBody(): string {
217
+ return this.outputBody.trim() ? this.outputBody : "Done";
218
+ }
219
+
220
+ setHoveredSection(section: ToolIoSection | null): void {
221
+ if (this.hoveredSection === section) return;
222
+ this.hoveredSection = section;
223
+ this.invalidate();
224
+ }
225
+
226
+ /** 尾部附注(耗时等)。挂在 show-more 行上;无 show-more 行时单独成行。 */
227
+ setFooter(text: string | undefined): void {
228
+ if (this.footer === text) return;
229
+ this.footer = text;
230
+ this.invalidate();
231
+ }
232
+
233
+ getHoveredSection(): ToolIoSection | null {
234
+ return this.hoveredSection;
235
+ }
236
+
237
+ /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
238
+ matchShowMoreLine(plainLine: string): ToolIoSection | null {
239
+ const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
240
+ if (!line.includes(` · ${showMoreHintText()}`) || !/\d+ more lines/.test(line)) return null;
241
+ if (this.truncated.input && line.includes("│")) return "input";
242
+ if (this.truncated.output) return "output";
243
+ return null;
244
+ }
245
+
246
+ /** Precise header rows marked for show-more hit testing (last render). */
247
+ showMoreHeaderLineIndexes(): ReadonlyArray<{ section: ToolIoSection; line: number }> {
248
+ const out: Array<{ section: ToolIoSection; line: number }> = [];
249
+ if (this.showMoreHeaderRows.input !== undefined) {
250
+ out.push({ section: "input", line: this.showMoreHeaderRows.input });
251
+ }
252
+ if (this.showMoreHeaderRows.output !== undefined) {
253
+ out.push({ section: "output", line: this.showMoreHeaderRows.output });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /** Column range (1-based, visible cells) of show-more on a rendered header, if present. */
259
+ showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
260
+ const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
261
+ const label = ` · ${showMoreHintText()}`;
262
+ const idx = line.lastIndexOf(label);
263
+ if (idx < 0) return null;
264
+ const before = line.slice(0, idx);
265
+ const startCol = visibleWidth(before) + 1;
266
+ const endCol = startCol + visibleWidth(label) - 1;
267
+ return { startCol, endCol };
268
+ }
269
+
270
+ render(width: number): string[] {
271
+ if (this.cachedLines !== undefined && this.cachedWidth === width) {
272
+ return withIoViewMarkers(this, this.cachedLines);
273
+ }
274
+
275
+ const theme = this.theme;
276
+ const safeWidth = Math.max(1, Math.floor(width));
277
+ // flushLeft:贴左(外层 Box 负责 1 格 pad);否则保留 1 格前导空格
278
+ const lead = this.flushLeft ? "" : " ";
279
+ const rail = `${lead}│ `;
280
+ const railWidth = visibleWidth(rail);
281
+ const bodyWidth = toolViewportWidth(safeWidth);
282
+ const contentWidth = Math.max(1, bodyWidth - railWidth);
283
+ const bodyColor = this.isError ? "error" : "toolOutput";
284
+ const lines: string[] = [];
285
+ this.truncated = { input: false, output: false };
286
+ this.showMoreHeaderRows = {};
287
+
288
+ const pushHeader = (corner: "├" | "└", label: string) => {
289
+ const mark = theme.fg("dim", `${lead}${corner} `);
290
+ const title = theme.fg(
291
+ "accent",
292
+ typeof theme.bold === "function" ? theme.bold(label) : label,
293
+ );
294
+ lines.push(truncateToWidth(mark + title, safeWidth, ""));
295
+ };
296
+
297
+ const pushRailLine = (styledContent: string, continued = true) => {
298
+ // 续行 rail;Output 正文相对 └ 缩进 2 格(+ 可选 lead)
299
+ const prefix = continued ? rail : `${lead} `;
300
+ lines.push(truncateToWidth(theme.fg("dim", prefix) + styledContent, safeWidth, ""));
301
+ };
302
+
303
+ const pushBlankRail = () => {
304
+ lines.push(truncateToWidth(theme.fg("dim", `${lead}│`), safeWidth, ""));
305
+ };
306
+
307
+ const pushBody = (
308
+ body: string,
309
+ opts: {
310
+ input?: boolean;
311
+ limit: number;
312
+ continued?: boolean;
313
+ section: ToolIoSection;
314
+ /** 挂在 show-more 行上的附注(shell 耗时),插在展开提示前。 */
315
+ footerNote?: string;
316
+ },
317
+ ): boolean /* truncated */ => {
318
+ const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
319
+ if (!raw.trim()) {
320
+ pushRailLine(theme.fg("dim", "(empty)"), opts.continued);
321
+ return false;
322
+ }
323
+ const sourceLines = raw.split("\n");
324
+ const wrapped: string[] = [];
325
+ for (const [index, source] of sourceLines.entries()) {
326
+ if (opts.input) {
327
+ // 代码块的行用着色版本(已剥缩进),其余走默认 dim/muted
328
+ wrapped.push(...wrapInputLine(source, contentWidth, theme, this.inputCodeLine(index)));
329
+ continue;
330
+ }
331
+ const styled = theme.fg(bodyColor, source);
332
+ const parts = wrapTextWithAnsi(styled, contentWidth);
333
+ if (parts.length === 0) wrapped.push(styled);
334
+ else wrapped.push(...parts);
335
+ }
336
+ // Prefer source-line count so plain multi-line dumps always cap, even when
337
+ // theme/wrap measurements disagree slightly.
338
+ const truncated = wrapped.length > opts.limit || sourceLines.length > opts.limit;
339
+ const visible = truncated ? wrapped.slice(0, Math.min(opts.limit, wrapped.length)) : wrapped;
340
+ for (const line of visible) pushRailLine(line, opts.continued);
341
+ if (truncated) {
342
+ const hidden = Math.max(0, wrapped.length - visible.length);
343
+ if (hidden > 0) {
344
+ // 省略号已经表达了「还有」,计数前不再加 +。
345
+ const note = opts.footerNote ? theme.fg("dim", ` · ${opts.footerNote}`) : "";
346
+ // hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
347
+ const more =
348
+ theme.fg("dim", " ·") +
349
+ theme.fg(
350
+ this.hoveredSection === opts.section ? "text" : "dim",
351
+ ` ${showMoreHintText()}`,
352
+ );
353
+ pushRailLine(theme.fg("dim", `… ${hidden} more lines`) + note + more, opts.continued);
354
+ this.showMoreHeaderRows[opts.section] = lines.length - 1;
355
+ }
356
+ }
357
+ return truncated;
358
+ };
359
+
360
+ const hasInput = this.inputBody.trim().length > 0;
361
+ const outputText = this.getOutputBody();
362
+
363
+ // Decide show-more from the same truncation rules as pushBody.
364
+ const inputWouldTruncate =
365
+ hasInput &&
366
+ bodyExceedsLineLimit(
367
+ this.inputBody,
368
+ this.maxInputLines,
369
+ contentWidth,
370
+ true,
371
+ theme,
372
+ bodyColor,
373
+ (inputIndex: number) => this.inputCodeLine(inputIndex),
374
+ );
375
+ const outputWouldTruncate = bodyExceedsLineLimit(
376
+ outputText,
377
+ this.maxOutputLines,
378
+ contentWidth,
379
+ false,
380
+ theme,
381
+ bodyColor,
382
+ );
383
+
384
+ if (hasInput) {
385
+ this.truncated.input = inputWouldTruncate;
386
+ pushHeader("├", "Input");
387
+ pushBody(this.inputBody, {
388
+ input: true,
389
+ limit: this.maxInputLines,
390
+ continued: true,
391
+ section: "input",
392
+ });
393
+ pushBlankRail();
394
+ this.truncated.output = outputWouldTruncate;
395
+ pushHeader("└", "Output");
396
+ pushBody(outputText, {
397
+ limit: this.maxOutputLines,
398
+ continued: false,
399
+ section: "output",
400
+ footerNote: this.footer,
401
+ });
402
+ } else {
403
+ this.truncated.output = outputWouldTruncate;
404
+ pushHeader("└", "Output");
405
+ pushBody(outputText, {
406
+ limit: this.maxOutputLines,
407
+ continued: false,
408
+ section: "output",
409
+ footerNote: this.footer,
410
+ });
411
+ }
412
+
413
+ // 耗时挂在 Output 的 show-more 行上(与折叠态一样,提示留在行尾);
414
+ // 没截断就没有那一行,才单独成行。放在最后追加,前面记下的行号不受影响。
415
+ if (this.footer && this.showMoreHeaderRows.output === undefined) {
416
+ pushRailLine(theme.fg("muted", this.footer), false);
417
+ }
418
+
419
+ this.cachedWidth = width;
420
+ this.cachedLines = lines;
421
+ return withIoViewMarkers(this, lines);
422
+ }
423
+
424
+ invalidate(): void {
425
+ this.cachedWidth = undefined;
426
+ this.cachedLines = undefined;
427
+ }
428
+ }
429
+
430
+ export function isExpandedToolIoView(value: unknown): value is ExpandedToolIoView {
431
+ return Boolean(
432
+ value &&
433
+ typeof value === "object" &&
434
+ (value as ExpandedToolIoView)[EXPANDED_TOOL_IO_VIEW_GENERATION] === true &&
435
+ typeof (value as ExpandedToolIoView).getInputBody === "function" &&
436
+ typeof (value as ExpandedToolIoView).getOutputBody === "function" &&
437
+ typeof (value as ExpandedToolIoView).setHoveredSection === "function" &&
438
+ typeof (value as ExpandedToolIoView).render === "function",
439
+ );
440
+ }
441
+
442
+ /** Input 行着色:`key: value` 拆成 dim 键 + muted 值,其余整行 muted。 */
443
+ function styleInputSourceLine(line: string, theme: any): string {
444
+ const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
445
+ if (!match) return theme.fg("muted", line);
446
+ const [, key, sep, rest] = match;
447
+ return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
448
+ }
449
+
450
+ /**
451
+ * 折一行 Input 源码。不带 styledBody 时与改动前完全一致(其他工具不受影响);
452
+ * 代码块(styledBody = 已着色且已剥缩进的行)续行跟着源码前导缩进:顶到 rail
453
+ * 会被读成新的一行,所以缩进先剥掉、折行宽度相应扣减,再给每一行加回去。
454
+ * 行数判断(bodyExceedsLineLimit)与实际渲染共用这一套,展开提示才对得上。
455
+ */
456
+ function wrapInputLine(
457
+ source: string,
458
+ contentWidth: number,
459
+ theme: any,
460
+ styledBody?: string,
461
+ ): string[] {
462
+ if (styledBody === undefined) {
463
+ const styled = styleInputSourceLine(source, theme);
464
+ const parts = wrapTextWithAnsi(styled, contentWidth);
465
+ return parts.length ? parts : [styled];
466
+ }
467
+ const lead = /^[ \t]*/.exec(source)?.[0] ?? "";
468
+ const leadWidth = visibleWidth(lead);
469
+ const parts = wrapTextWithAnsi(styledBody, Math.max(1, contentWidth - leadWidth));
470
+ const rows = parts.length ? parts : [styledBody];
471
+ if (!leadWidth) return rows;
472
+ const prefix = theme.fg("muted", lead);
473
+ return rows.map((row) => prefix + row);
474
+ }
475
+
476
+ /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
477
+ function bodyExceedsLineLimit(
478
+ body: string,
479
+ limit: number,
480
+ contentWidth: number,
481
+ asInput: boolean,
482
+ theme: any,
483
+ bodyColor = "toolOutput",
484
+ /** 代码块的源行取着色内容,行数判断与实际渲染保持同一套规则。 */
485
+ codeLine?: (sourceIndex: number) => string | undefined,
486
+ ): boolean {
487
+ const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
488
+ if (!raw.trim()) return false;
489
+ const sourceLines = raw.split("\n");
490
+ if (sourceLines.length > limit) return true;
491
+ let total = 0;
492
+ for (const [index, source] of sourceLines.entries()) {
493
+ const parts = asInput
494
+ ? wrapInputLine(source, contentWidth, theme, codeLine?.(index))
495
+ : wrapTextWithAnsi(theme.fg(bodyColor, source), contentWidth);
496
+ total += Math.max(1, parts.length);
497
+ if (total > limit) return true;
498
+ }
499
+ return false;
500
+ }
501
+
502
+ export type InputCodeBlock = {
503
+ startLine: number;
504
+ language: string;
505
+ code: string;
506
+ standalone: boolean;
507
+ };
508
+
509
+ export type ToolInputBody = { text: string; codeBlock?: InputCodeBlock };
510
+
511
+ /** 按字段名判定可着色的入参:codemode / mcpscript 的 `code` 都是 JS 脚本。 */
512
+ const INPUT_CODE_LANGUAGES: Record<string, string> = { code: "javascript" };
513
+
514
+ /** Pretty-print tool call args for the expanded Input section. */
515
+ export function formatToolInputBody(args: unknown, maxChars = 8_000): ToolInputBody {
516
+ if (args === undefined || args === null) return { text: "" };
517
+ if (typeof args !== "object") {
518
+ const text = sanitizeToolResultText(String(args));
519
+ return { text: text.length > maxChars ? `${text.slice(0, maxChars)}…` : text };
520
+ }
521
+ if (Array.isArray(args)) {
522
+ try {
523
+ const json = JSON.stringify(args, null, 2);
524
+ return { text: json.length > maxChars ? `${json.slice(0, maxChars)}…` : json };
525
+ } catch {
526
+ return { text: sanitizeToolResultText(String(args)) };
527
+ }
528
+ }
529
+
530
+ const entries = Object.entries(args as Record<string, unknown>).filter(
531
+ ([, value]) => value !== undefined,
532
+ );
533
+ if (entries.length === 0) return { text: "" };
534
+
535
+ // Stable, human-first field order for common tools.
536
+ const preferred = [
537
+ "path",
538
+ "file_path",
539
+ "command",
540
+ "query",
541
+ "pattern",
542
+ "url",
543
+ "name",
544
+ "message",
545
+ "content",
546
+ "old_string",
547
+ "new_string",
548
+ ];
549
+ entries.sort(([left], [right]) => {
550
+ const li = preferred.indexOf(left);
551
+ const ri = preferred.indexOf(right);
552
+ if (li === -1 && ri === -1) return left.localeCompare(right);
553
+ if (li === -1) return 1;
554
+ if (ri === -1) return -1;
555
+ return li - ri;
556
+ });
557
+
558
+ const lines: string[] = [];
559
+ let codeBlock: InputCodeBlock | undefined;
560
+ for (const [rawKey, value] of entries) {
561
+ const key = sanitizeToolResultText(rawKey);
562
+ if (typeof value === "string") {
563
+ const safeValue = sanitizeToolResultText(value);
564
+ if (safeValue.includes("\n")) {
565
+ const expanded = safeValue.replace(/\t/g, " ");
566
+ const language = INPUT_CODE_LANGUAGES[key];
567
+ // 只有一个 code 字段时(codemode 等)整块 Input 就是脚本,`code:` 标签是噪音
568
+ const standaloneCode = Boolean(language) && entries.length === 1;
569
+ if (!standaloneCode) lines.push(`${key}:`);
570
+ if (language) {
571
+ // 缩进由 wrapInputLine 负责,着色只喂剥掉行首缩进的正文
572
+ codeBlock = {
573
+ startLine: lines.length,
574
+ language,
575
+ code: expanded
576
+ .split("\n")
577
+ .map((line) => line.replace(/^[ \t]*/, ""))
578
+ .join("\n"),
579
+ standalone: standaloneCode,
580
+ };
581
+ }
582
+ for (const line of expanded.split("\n")) {
583
+ lines.push(` ${line}`);
584
+ }
585
+ } else {
586
+ lines.push(`${key}: ${safeValue}`);
587
+ }
588
+ continue;
589
+ }
590
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
591
+ lines.push(`${key}: ${String(value)}`);
592
+ continue;
593
+ }
594
+ try {
595
+ const json = JSON.stringify(value, null, 2);
596
+ if (json.includes("\n")) {
597
+ lines.push(`${key}:`);
598
+ for (const line of json.split("\n")) lines.push(` ${line}`);
599
+ } else {
600
+ lines.push(`${key}: ${json}`);
601
+ }
602
+ } catch {
603
+ lines.push(`${key}: [unserializable]`);
604
+ }
605
+ }
606
+ const text = lines.join("\n");
607
+ // 截断后的行号与着色行不再对齐,这时只给纯文本
608
+ if (text.length > maxChars) return { text: `${text.slice(0, maxChars)}…` };
609
+ return codeBlock ? { text, codeBlock } : { text };
610
+ }
611
+
612
+ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
613
+ return formatToolInputBody(args, maxChars).text;
614
+ }
615
+
616
+ function hasExpandableResult(text: string): boolean {
617
+ return (
618
+ text
619
+ .trim()
620
+ .split("\n")
621
+ .filter((line) => line.trim().length > 0).length > 1
622
+ );
623
+ }
624
+
625
+ export function hasExpandableDetail(outputText: string, args: unknown): boolean {
626
+ if (hasExpandableResult(outputText)) return true;
627
+ return formatToolInputArgs(args).trim().length > 0;
628
+ }
629
+
630
+ export function renderExpandedToolResult(
631
+ body: string,
632
+ theme: any,
633
+ isError: boolean,
634
+ lastComponent?: unknown,
635
+ args?: unknown,
636
+ context?: any,
637
+ /** 贴左渲染:树线的前导空格交给外层 Box(1,1) */
638
+ flushLeft = false,
639
+ ): ExpandedToolIoView | ExpandedToolResultText | Text {
640
+ const { text: inputBody, codeBlock } = formatToolInputBody(args);
641
+ const outputBody = body;
642
+ const maxOutputLines = config.expandedOutputMaxLines;
643
+ const maxInputLines = config.expandedInputMaxLines;
644
+
645
+ // Prefer structured Input/Output when we have args or non-empty output.
646
+ if (inputBody.trim() || outputBody.trim()) {
647
+ let view: ExpandedToolIoView;
648
+ if (isExpandedToolIoView(lastComponent)) {
649
+ lastComponent.setContent(
650
+ inputBody,
651
+ outputBody,
652
+ isError,
653
+ maxOutputLines,
654
+ maxInputLines,
655
+ flushLeft,
656
+ );
657
+ view = lastComponent;
658
+ } else {
659
+ view = new ExpandedToolIoView(
660
+ theme,
661
+ inputBody,
662
+ outputBody,
663
+ isError,
664
+ maxOutputLines,
665
+ maxInputLines,
666
+ flushLeft,
667
+ );
668
+ }
669
+ view.setInputCode(codeBlock);
670
+ if (context) rememberIoView(context, view);
671
+ return view;
672
+ }
673
+
674
+ if (context?.state) context.state.ccstyleIoView = undefined;
675
+ const color = isError ? "error" : "muted";
676
+ return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
677
+ }
678
+
679
+ const toolViewMarker = (id: number, section: ToolIoSection) =>
680
+ `_cc:v${id}:${section === "input" ? "i" : "o"}`;
681
+
682
+ /** Per-frame ExpandedToolIoView ids for unambiguous show-more hit testing. */
683
+ export type IoViewFrameState = {
684
+ viewIds: Map<ExpandedToolIoView, number>;
685
+ idToView: Map<number, ExpandedToolIoView>;
686
+ nextId: number;
687
+ };
688
+ let activeIoViewFrame: IoViewFrameState | null = null;
689
+
690
+ function frameViewId(view: ExpandedToolIoView): number | null {
691
+ if (!activeIoViewFrame) return null;
692
+ let id = activeIoViewFrame.viewIds.get(view);
693
+ if (id === undefined) {
694
+ id = activeIoViewFrame.nextId++;
695
+ activeIoViewFrame.viewIds.set(view, id);
696
+ activeIoViewFrame.idToView.set(id, view);
697
+ }
698
+ return id;
699
+ }
700
+
701
+ /** cachedLines stay clean; only the returned paint copy carries show-more view markers. */
702
+ function withIoViewMarkers(view: ExpandedToolIoView, lines: string[]): string[] {
703
+ const id = frameViewId(view);
704
+ if (id === null) return lines;
705
+ // Mark by exact header row from render — never scan body text for Input/Output labels.
706
+ const marked = lines.slice();
707
+ for (const { section, line } of view.showMoreHeaderLineIndexes()) {
708
+ if (line < 0 || line >= marked.length) continue;
709
+ marked[line] = `${marked[line]}${toolViewMarker(id, section)}`;
710
+ }
711
+ return marked;
712
+ }
713
+ const ioViewInvalidators = new WeakMap<ExpandedToolIoView, () => void>();
714
+ function rememberIoView(context: any, view: ExpandedToolIoView): void {
715
+ if (!context || typeof context !== "object") return;
716
+ if (typeof context.invalidate === "function") {
717
+ ioViewInvalidators.set(view, () => context.invalidate());
718
+ }
719
+ if (!context.state || typeof context.state !== "object") context.state = {};
720
+ context.state.ccstyleIoView = view;
721
+ }
722
+
723
+ /** hover 状态变更后刷新上一个/下一个视图(context.invalidate 缓存于 ioViewInvalidators)。 */
724
+ export function invalidateIoView(view: ExpandedToolIoView): void {
725
+ ioViewInvalidators.get(view)?.();
726
+ }
727
+
728
+ /** 鼠标帧渲染期间读写当前 IoView 帧 id 表(mouse-interaction 的 doRender 包装使用)。 */
729
+ export function getActiveIoViewFrame(): IoViewFrameState | null {
730
+ return activeIoViewFrame;
731
+ }
732
+
733
+ export function setActiveIoViewFrame(frame: IoViewFrameState | null): void {
734
+ activeIoViewFrame = frame;
735
+ }