@herbertgao/pi-extensions 2026.10.5 → 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 (46) hide show
  1. package/README.md +2 -2
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +5 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +22 -0
  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.ts +10 -6
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +15 -3
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +2 -23
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +105 -55
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +23 -471
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +6 -3
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +16 -83
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +1 -1
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +2 -2
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +1 -1
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +53 -5
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +1 -1
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ccstyle-tool.ts +467 -0
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +1 -1
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +1 -24
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +1 -1
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-limits.ts +1 -1
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-text.ts +2 -1
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +1 -1
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +1 -1
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +53 -34
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/message-display.ts +1 -1
  33. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +41 -1
  34. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -862
  35. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-io.ts +735 -0
  36. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-state.ts +138 -0
  37. package/node_modules/@herbertgao/pi-cc-extensions/package.json +5 -4
  38. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
  39. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +2 -2
  40. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  41. package/node_modules/@herbertgao/sol-pi/README.md +3 -3
  42. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
  43. package/node_modules/@herbertgao/sol-pi/agents-install.md +3 -3
  44. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +1 -1
  45. package/package.json +2 -2
  46. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/show-more-hint.ts +0 -48
@@ -1,10 +1,6 @@
1
- import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { inspect } from "node:util";
3
3
  import { config } from "../../config/config.ts";
4
- import { createCodeBlockHighlighter } from "./diff/diff-highlight.ts";
5
- import { showMoreHintText } from "./show-more-hint.ts";
6
- import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
7
- import { getToolMouseTui } from "../mouse/scroll.ts";
8
4
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
9
5
  import { oneLine } from "../../utils/format.ts";
10
6
 
@@ -97,10 +93,6 @@ export function collapsedResultSummary(options: {
97
93
  return `${outputLines} ${outputLines === 1 ? "line" : "lines"} ${action}`;
98
94
  }
99
95
 
100
- function hasExpandableResult(text: string): boolean {
101
- return countLines(text) > 1;
102
- }
103
-
104
96
  /**
105
97
  * 工具耗时:不到 1 秒报毫秒,分钟级起带上分/时。
106
98
  * 只报到秒的话,几分钟的长命令会显示成 `300.0s`。
@@ -115,632 +107,10 @@ export function formatToolDuration(ms: number): string {
115
107
  if (minutes < 60) return `${minutes}m ${remainder}s`;
116
108
  return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainder}s`;
117
109
  }
118
-
119
- const activeAnimationContexts = new Set<any>();
120
- let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
121
-
122
- function clearAnimation(context: any) {
123
- if (context?.state) context.state.ccstyleAnimationLight = false;
124
- if (!context?.state?.ccstyleAnimationScheduled) return;
125
- context.state.ccstyleAnimationScheduled = false;
126
- activeAnimationContexts.delete(context);
127
- if (activeAnimationContexts.size === 0 && sharedAnimationTimer) {
128
- clearTimeout(sharedAnimationTimer);
129
- sharedAnimationTimer = null;
130
- }
131
- }
132
-
133
- export function clearAllAnimations() {
134
- for (const ctx of activeAnimationContexts) {
135
- ctx.state.ccstyleAnimationScheduled = false;
136
- ctx.state.ccstyleAnimationLight = false;
137
- }
138
- activeAnimationContexts.clear();
139
- if (sharedAnimationTimer) {
140
- clearTimeout(sharedAnimationTimer);
141
- sharedAnimationTimer = null;
142
- }
143
- }
144
-
145
- export function scheduleAnimation(
146
- context: any,
147
- options: { light?: boolean; intervalMs?: number } = {},
148
- ) {
149
- const state = (context.state ??= {});
150
- // light:调用方自己在 render() 内重取 loading 帧,定时器只需请求重绘;
151
- // 否则定时器走 context.invalidate()(compact 摘要等靠它重建)。
152
- // 每次调用都按本次 options 写入,避免上次 light 残留后非 light 调用仍跳过 invalidate。
153
- state.ccstyleAnimationLight = Boolean(options.light);
154
- if (state.ccstyleAnimationScheduled) return;
155
- state.ccstyleAnimationScheduled = true;
156
- activeAnimationContexts.add(context);
157
- if (!sharedAnimationTimer) {
158
- sharedAnimationTimer = setTimeout(() => {
159
- sharedAnimationTimer = null;
160
- const contexts = Array.from(activeAnimationContexts);
161
- activeAnimationContexts.clear();
162
- const tui = getToolMouseTui();
163
- const canRequestRender = typeof tui?.requestRender === "function";
164
- let lightFrames = false;
165
- for (const ctx of contexts) {
166
- ctx.state.ccstyleAnimationScheduled = false;
167
- if (ctx.state.ccstyleAnimationLight && canRequestRender) lightFrames = true;
168
- else ctx.invalidate?.();
169
- }
170
- if (lightFrames) tui.requestRender();
171
- }, options.intervalMs ?? TOOL_LOADING_INTERVAL_MS);
172
- }
173
- }
174
-
175
- /**
176
- * 本回合流式消息里出现过的工具调用。Pi 要等整条 assistant 消息结束才派发执行,
177
- * 这段窗口工具行已经在等启动,spinner 该逐帧转。
178
- * 恢复或翻历史重建的旧行不在这个集合里,保持静止。
179
- */
180
- const liveToolCalls = new Set<string>();
181
-
182
- /** 收下当前流式 assistant 消息里的工具调用 id。 */
183
- export function noteStreamingToolCalls(message: any): void {
184
- const content = Array.isArray(message?.content) ? message.content : [];
185
- for (const block of content) {
186
- const id = block?.type === "toolCall" ? block.id : undefined;
187
- if (typeof id === "string" && id) liveToolCalls.add(id);
188
- }
189
- }
190
-
191
- /** 工具收尾后移出等待窗口。 */
192
- export function retireLiveToolCall(toolCallId: unknown): void {
193
- if (typeof toolCallId === "string") liveToolCalls.delete(toolCallId);
194
- }
195
-
196
- export function clearLiveToolCalls(): void {
197
- liveToolCalls.clear();
198
- }
199
-
200
- /** 工具行是否处于等待窗口(流式声明中或等待派发)。 */
201
- export function isLiveToolCall(toolCallId: unknown): boolean {
202
- return typeof toolCallId === "string" && liveToolCalls.has(toolCallId);
203
- }
204
-
205
- export function pendingIcon(_name: string): string {
206
- return toolLoadingIcon();
207
- }
208
-
209
- type ToolVisualState = "pending" | "success" | "error";
210
-
211
- export function settledIcon(name: string, state: ToolVisualState | undefined): string {
212
- if (state === "success") return "✓";
213
- if (state === "error") return "✗";
214
- // toolIcon(name) 曾恒返回 "●",已内联;name 保留以稳定导出签名。
215
- return "●";
216
- }
217
-
218
- export function setToolVisualState(context: any, visualState: ToolVisualState) {
219
- const state = (context.state ??= {});
220
- if (visualState !== "pending") clearAnimation(context);
221
- if (state.ccstyleToolVisualState === visualState) return;
222
- state.ccstyleToolVisualState = visualState;
223
- // Do not invalidate synchronously from renderResult. Pi is already rendering
224
- // this tool row; recursively scheduling another render here can retain both
225
- // the finalized result component and its previous secondary/partial component,
226
- // which displays the result summary twice. The current render pass also
227
- // refreshes renderCall, so the settled icon still updates immediately.
228
- }
229
-
230
- function getToolVisualState(context: any): ToolVisualState | undefined {
231
- return context?.state?.ccstyleToolVisualState as ToolVisualState | undefined;
232
- }
233
-
234
- export function resolveToolVisualState(context: any): ToolVisualState | undefined {
235
- const visualState = getToolVisualState(context);
236
- if (visualState || context?.isPartial !== false) return visualState;
237
- const settledState: ToolVisualState = context?.isError ? "error" : "success";
238
- setToolVisualState(context, settledState);
239
- return settledState;
240
- }
241
-
242
- export function toolIconColor(context: any): "accent" | "error" | "success" | "muted" {
243
- const visualState = getToolVisualState(context);
244
- if (context?.isError || visualState === "error") return "error";
245
- if (visualState === "success") return "success";
246
- if (context?.isPartial || context?.executionStarted || visualState === "pending") return "accent";
247
- return "muted";
248
- }
249
-
250
- export function isToolExpanded(options: any, context: any): boolean {
251
- const local = context?.state?.ccstyleToolExpanded;
252
- return typeof local === "boolean" ? local : Boolean(options?.expanded ?? context?.expanded);
253
- }
254
-
255
- /** Keep the guide aligned when long result lines wrap at the viewport edge. */
256
- export class ExpandedToolResultText {
257
- private text: string;
258
- private prefix: string;
259
- private normalizedText: string;
260
- private cachedWidth: number | undefined;
261
- private cachedLines: string[] | undefined;
262
-
263
- constructor(text: string, prefix: string) {
264
- this.text = text;
265
- this.prefix = prefix;
266
- this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
267
- }
268
-
269
- setText(text: string): void {
270
- if (this.text === text) return;
271
- this.text = text;
272
- this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
273
- this.invalidate();
274
- }
275
-
276
- setPrefix(prefix: string): void {
277
- if (this.prefix === prefix) return;
278
- this.prefix = prefix;
279
- this.invalidate();
280
- }
281
-
282
- render(width: number): string[] {
283
- if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
284
-
285
- const prefixWidth = visibleWidth(this.prefix);
286
- const contentWidth = Math.max(1, width - prefixWidth);
287
- const lines = wrapTextWithAnsi(this.normalizedText, contentWidth).map((line) =>
288
- truncateToWidth(this.prefix + line, width, ""),
289
- );
290
- this.cachedWidth = width;
291
- this.cachedLines = lines;
292
- return lines;
293
- }
294
-
295
- invalidate(): void {
296
- this.cachedWidth = undefined;
297
- this.cachedLines = undefined;
298
- }
299
- }
300
-
301
110
  /** 截断体末行 `… N more lines` 旁的展开提示,点击打开全量预览。 */
302
111
  export const SHOW_MORE_LABEL = "· click to show more";
303
112
 
304
- export type ToolIoSection = "input" | "output";
305
-
306
- // Module-local token: /reload creates a fresh token, so stale view instances are
307
- // replaced instead of retaining their old render implementation.
308
- const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view");
309
-
310
- /**
311
- * Expanded tool body with clear Input / Output sections (Grok Build–style).
312
- *
313
- * Visual frame(flushLeft=true:树线的前导空格交给外层 Box(1,1),├/└ 与标题行的勾同列):
314
- * ├ Input click to show more
315
- * │ path: src/a.ts
316
- * │
317
- * └ Output click to show more
318
- * result line…
319
- *
320
- * Reused across re-renders via context.lastComponent when possible.
321
- */
322
- export class ExpandedToolIoView {
323
- readonly [EXPANDED_TOOL_IO_VIEW_GENERATION] = true;
324
- private inputBody: string;
325
- private outputBody: string;
326
- private isError: boolean;
327
- private theme: any;
328
- private maxOutputLines: number;
329
- private maxInputLines: number;
330
- private cachedWidth: number | undefined;
331
- private cachedLines: string[] | undefined;
332
- private hoveredSection: ToolIoSection | null = null;
333
- /** Which sections currently show the show-more affordance (after last render). */
334
- private truncated: { input: boolean; output: boolean } = { input: false, output: false };
335
- /** 0-based header line indexes that carry show-more after last render. */
336
- private showMoreHeaderRows: { input?: number; output?: number } = {};
337
- /** Input 里的代码块(codemode 的 `code`):着色行覆盖默认 dim/muted 样式。 */
338
- private inputCode?:
339
- | {
340
- startLine: number;
341
- language: string;
342
- standalone: boolean;
343
- code: string;
344
- theme: unknown;
345
- rows: () => string[];
346
- }
347
- | undefined;
348
-
349
- /** flushLeft:贴左渲染(外层 Box 负责 1 格 pad);默认 false 保留 1 格前导空格。 */
350
- private flushLeft: boolean;
351
- /** 尾部附注(shell 的耗时)。挂到 Output 的 show-more 行上,没有那一行时单独成行。 */
352
- private footer: string | undefined;
353
-
354
- constructor(
355
- theme: any,
356
- inputBody: string,
357
- outputBody: string,
358
- isError: boolean,
359
- maxOutputLines = config.expandedOutputMaxLines,
360
- maxInputLines = config.expandedInputMaxLines,
361
- flushLeft = false,
362
- ) {
363
- this.theme = theme;
364
- this.inputBody = inputBody;
365
- this.outputBody = outputBody;
366
- this.isError = isError;
367
- this.maxOutputLines = Math.max(1, maxOutputLines);
368
- this.maxInputLines = Math.max(1, maxInputLines);
369
- this.flushLeft = flushLeft;
370
- }
371
-
372
- setContent(
373
- inputBody: string,
374
- outputBody: string,
375
- isError: boolean,
376
- maxOutputLines?: number,
377
- maxInputLines?: number,
378
- flushLeft?: boolean,
379
- ): void {
380
- const nextOut =
381
- maxOutputLines !== undefined ? Math.max(1, maxOutputLines) : this.maxOutputLines;
382
- const nextIn = maxInputLines !== undefined ? Math.max(1, maxInputLines) : this.maxInputLines;
383
- const nextFlush = flushLeft !== undefined ? flushLeft : this.flushLeft;
384
- if (
385
- this.inputBody === inputBody &&
386
- this.outputBody === outputBody &&
387
- this.isError === isError &&
388
- this.maxOutputLines === nextOut &&
389
- this.maxInputLines === nextIn &&
390
- this.flushLeft === nextFlush
391
- ) {
392
- return;
393
- }
394
- this.inputBody = inputBody;
395
- this.outputBody = outputBody;
396
- this.isError = isError;
397
- this.maxOutputLines = nextOut;
398
- this.maxInputLines = nextIn;
399
- this.flushLeft = nextFlush;
400
- this.invalidate();
401
- }
402
-
403
- getInputBody(): string {
404
- return this.inputBody;
405
- }
406
-
407
- /**
408
- * 登记 Input 里的代码块:同步 cli-highlight 打底,shiki 异步升级,落地后
409
- * 经 invalidate 重画。同一份代码与主题只建一次(视图按宽度缓存行)。
410
- */
411
- setInputCode(block: InputCodeBlock | undefined): void {
412
- if (!block) {
413
- if (this.inputCode) {
414
- this.inputCode = undefined;
415
- this.invalidate();
416
- }
417
- return;
418
- }
419
- if (this.inputCode?.code === block.code && this.inputCode.theme === this.theme) {
420
- this.inputCode.startLine = block.startLine;
421
- this.inputCode.standalone = block.standalone;
422
- return;
423
- }
424
- this.inputCode = {
425
- startLine: block.startLine,
426
- language: block.language,
427
- standalone: block.standalone,
428
- code: block.code,
429
- theme: this.theme,
430
- rows: createCodeBlockHighlighter(block.code, block.language, this.theme, () => {
431
- // 视图自己的行缓存 + 外层工具卡(清 paint 缓存并 requestRender)
432
- this.invalidate();
433
- invalidateIoView(this);
434
- }),
435
- };
436
- this.invalidate();
437
- }
438
-
439
- /**
440
- * 整段 Input 就是代码块时给出语言与代码,供全量预览包一层围栏(弹框按 Markdown
441
- * 渲染,围栏代码块由 pi 的 highlightCode 着色)。混了别的字段时返回 undefined。
442
- */
443
- getInputCodeFence(): { language: string; code: string } | undefined {
444
- const block = this.inputCode;
445
- return block?.standalone ? { language: block.language, code: block.code } : undefined;
446
- }
447
-
448
- /** 代码块里某一源行的着色内容(已剥掉行首缩进);不在块内时为 undefined。 */
449
- private inputCodeLine(sourceIndex: number): string | undefined {
450
- const block = this.inputCode;
451
- if (!block) return undefined;
452
- const offset = sourceIndex - block.startLine;
453
- if (offset < 0) return undefined;
454
- const rows = block.rows();
455
- return offset < rows.length ? rows[offset] : undefined;
456
- }
457
-
458
- getOutputBody(): string {
459
- return this.outputBody.trim() ? this.outputBody : "Done";
460
- }
461
-
462
- setHoveredSection(section: ToolIoSection | null): void {
463
- if (this.hoveredSection === section) return;
464
- this.hoveredSection = section;
465
- this.invalidate();
466
- }
467
-
468
- /** 尾部附注(耗时等)。挂在 show-more 行上;无 show-more 行时单独成行。 */
469
- setFooter(text: string | undefined): void {
470
- if (this.footer === text) return;
471
- this.footer = text;
472
- this.invalidate();
473
- }
474
-
475
- getHoveredSection(): ToolIoSection | null {
476
- return this.hoveredSection;
477
- }
478
-
479
- /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
480
- matchShowMoreLine(plainLine: string): ToolIoSection | null {
481
- const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
482
- if (!line.includes(` · ${showMoreHintText()}`) || !/\d+ more lines/.test(line)) return null;
483
- if (this.truncated.input && line.includes("│")) return "input";
484
- if (this.truncated.output) return "output";
485
- return null;
486
- }
487
-
488
- /** Precise header rows marked for show-more hit testing (last render). */
489
- showMoreHeaderLineIndexes(): ReadonlyArray<{ section: ToolIoSection; line: number }> {
490
- const out: Array<{ section: ToolIoSection; line: number }> = [];
491
- if (this.showMoreHeaderRows.input !== undefined) {
492
- out.push({ section: "input", line: this.showMoreHeaderRows.input });
493
- }
494
- if (this.showMoreHeaderRows.output !== undefined) {
495
- out.push({ section: "output", line: this.showMoreHeaderRows.output });
496
- }
497
- return out;
498
- }
499
-
500
- /** Column range (1-based, visible cells) of show-more on a rendered header, if present. */
501
- showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
502
- const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
503
- const label = ` · ${showMoreHintText()}`;
504
- const idx = line.lastIndexOf(label);
505
- if (idx < 0) return null;
506
- const before = line.slice(0, idx);
507
- const startCol = visibleWidth(before) + 1;
508
- const endCol = startCol + visibleWidth(label) - 1;
509
- return { startCol, endCol };
510
- }
511
-
512
- render(width: number): string[] {
513
- if (this.cachedLines !== undefined && this.cachedWidth === width) {
514
- return withIoViewMarkers(this, this.cachedLines);
515
- }
516
-
517
- const theme = this.theme;
518
- const safeWidth = Math.max(1, Math.floor(width));
519
- // flushLeft:贴左(外层 Box 负责 1 格 pad);否则保留 1 格前导空格
520
- const lead = this.flushLeft ? "" : " ";
521
- const rail = `${lead}│ `;
522
- const railWidth = visibleWidth(rail);
523
- const bodyWidth = toolViewportWidth(safeWidth);
524
- const contentWidth = Math.max(1, bodyWidth - railWidth);
525
- const bodyColor = this.isError ? "error" : "toolOutput";
526
- const lines: string[] = [];
527
- this.truncated = { input: false, output: false };
528
- this.showMoreHeaderRows = {};
529
-
530
- const pushHeader = (corner: "├" | "└", label: string) => {
531
- const mark = theme.fg("dim", `${lead}${corner} `);
532
- const title = theme.fg(
533
- "accent",
534
- typeof theme.bold === "function" ? theme.bold(label) : label,
535
- );
536
- lines.push(truncateToWidth(mark + title, safeWidth, ""));
537
- };
538
-
539
- const pushRailLine = (styledContent: string, continued = true) => {
540
- // 续行 rail;Output 正文相对 └ 缩进 2 格(+ 可选 lead)
541
- const prefix = continued ? rail : `${lead} `;
542
- lines.push(truncateToWidth(theme.fg("dim", prefix) + styledContent, safeWidth, ""));
543
- };
544
-
545
- const pushBlankRail = () => {
546
- lines.push(truncateToWidth(theme.fg("dim", `${lead}│`), safeWidth, ""));
547
- };
548
-
549
- const pushBody = (
550
- body: string,
551
- opts: {
552
- input?: boolean;
553
- limit: number;
554
- continued?: boolean;
555
- section: ToolIoSection;
556
- /** 挂在 show-more 行上的附注(shell 耗时),插在展开提示前。 */
557
- footerNote?: string;
558
- },
559
- ): boolean /* truncated */ => {
560
- const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
561
- if (!raw.trim()) {
562
- pushRailLine(theme.fg("dim", "(empty)"), opts.continued);
563
- return false;
564
- }
565
- const sourceLines = raw.split("\n");
566
- const wrapped: string[] = [];
567
- for (const [index, source] of sourceLines.entries()) {
568
- if (opts.input) {
569
- // 代码块的行用着色版本(已剥缩进),其余走默认 dim/muted
570
- wrapped.push(...wrapInputLine(source, contentWidth, theme, this.inputCodeLine(index)));
571
- continue;
572
- }
573
- const styled = theme.fg(bodyColor, source);
574
- const parts = wrapTextWithAnsi(styled, contentWidth);
575
- if (parts.length === 0) wrapped.push(styled);
576
- else wrapped.push(...parts);
577
- }
578
- // Prefer source-line count so plain multi-line dumps always cap, even when
579
- // theme/wrap measurements disagree slightly.
580
- const truncated = wrapped.length > opts.limit || sourceLines.length > opts.limit;
581
- const visible = truncated ? wrapped.slice(0, Math.min(opts.limit, wrapped.length)) : wrapped;
582
- for (const line of visible) pushRailLine(line, opts.continued);
583
- if (truncated) {
584
- const hidden = Math.max(0, wrapped.length - visible.length);
585
- if (hidden > 0) {
586
- // 省略号已经表达了「还有」,计数前不再加 +。
587
- const note = opts.footerNote ? theme.fg("dim", ` · ${opts.footerNote}`) : "";
588
- // hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
589
- const more =
590
- theme.fg("dim", " ·") +
591
- theme.fg(
592
- this.hoveredSection === opts.section ? "text" : "dim",
593
- ` ${showMoreHintText()}`,
594
- );
595
- pushRailLine(theme.fg("dim", `… ${hidden} more lines`) + note + more, opts.continued);
596
- this.showMoreHeaderRows[opts.section] = lines.length - 1;
597
- }
598
- }
599
- return truncated;
600
- };
601
-
602
- const hasInput = this.inputBody.trim().length > 0;
603
- const outputText = this.getOutputBody();
604
-
605
- // Decide show-more from the same truncation rules as pushBody.
606
- const inputWouldTruncate =
607
- hasInput &&
608
- bodyExceedsLineLimit(
609
- this.inputBody,
610
- this.maxInputLines,
611
- contentWidth,
612
- true,
613
- theme,
614
- bodyColor,
615
- (inputIndex: number) => this.inputCodeLine(inputIndex),
616
- );
617
- const outputWouldTruncate = bodyExceedsLineLimit(
618
- outputText,
619
- this.maxOutputLines,
620
- contentWidth,
621
- false,
622
- theme,
623
- bodyColor,
624
- );
625
-
626
- if (hasInput) {
627
- this.truncated.input = inputWouldTruncate;
628
- pushHeader("├", "Input");
629
- pushBody(this.inputBody, {
630
- input: true,
631
- limit: this.maxInputLines,
632
- continued: true,
633
- section: "input",
634
- });
635
- pushBlankRail();
636
- this.truncated.output = outputWouldTruncate;
637
- pushHeader("└", "Output");
638
- pushBody(outputText, {
639
- limit: this.maxOutputLines,
640
- continued: false,
641
- section: "output",
642
- footerNote: this.footer,
643
- });
644
- } else {
645
- this.truncated.output = outputWouldTruncate;
646
- pushHeader("└", "Output");
647
- pushBody(outputText, {
648
- limit: this.maxOutputLines,
649
- continued: false,
650
- section: "output",
651
- footerNote: this.footer,
652
- });
653
- }
654
-
655
- // 耗时挂在 Output 的 show-more 行上(与折叠态一样,提示留在行尾);
656
- // 没截断就没有那一行,才单独成行。放在最后追加,前面记下的行号不受影响。
657
- if (this.footer && this.showMoreHeaderRows.output === undefined) {
658
- pushRailLine(theme.fg("muted", this.footer), false);
659
- }
660
-
661
- this.cachedWidth = width;
662
- this.cachedLines = lines;
663
- return withIoViewMarkers(this, lines);
664
- }
665
-
666
- invalidate(): void {
667
- this.cachedWidth = undefined;
668
- this.cachedLines = undefined;
669
- }
670
- }
671
-
672
- export function isExpandedToolIoView(value: unknown): value is ExpandedToolIoView {
673
- return Boolean(
674
- value &&
675
- typeof value === "object" &&
676
- (value as ExpandedToolIoView)[EXPANDED_TOOL_IO_VIEW_GENERATION] === true &&
677
- typeof (value as ExpandedToolIoView).getInputBody === "function" &&
678
- typeof (value as ExpandedToolIoView).getOutputBody === "function" &&
679
- typeof (value as ExpandedToolIoView).setHoveredSection === "function" &&
680
- typeof (value as ExpandedToolIoView).render === "function",
681
- );
682
- }
683
-
684
- /** Input 行着色:`key: value` 拆成 dim 键 + muted 值,其余整行 muted。 */
685
- function styleInputSourceLine(line: string, theme: any): string {
686
- const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
687
- if (!match) return theme.fg("muted", line);
688
- const [, key, sep, rest] = match;
689
- return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
690
- }
691
-
692
- /**
693
- * 折一行 Input 源码。不带 styledBody 时与改动前完全一致(其他工具不受影响);
694
- * 代码块(styledBody = 已着色且已剥缩进的行)续行跟着源码前导缩进:顶到 rail
695
- * 会被读成新的一行,所以缩进先剥掉、折行宽度相应扣减,再给每一行加回去。
696
- * 行数判断(bodyExceedsLineLimit)与实际渲染共用这一套,展开提示才对得上。
697
- */
698
- function wrapInputLine(
699
- source: string,
700
- contentWidth: number,
701
- theme: any,
702
- styledBody?: string,
703
- ): string[] {
704
- if (styledBody === undefined) {
705
- const styled = styleInputSourceLine(source, theme);
706
- const parts = wrapTextWithAnsi(styled, contentWidth);
707
- return parts.length ? parts : [styled];
708
- }
709
- const lead = /^[ \t]*/.exec(source)?.[0] ?? "";
710
- const leadWidth = visibleWidth(lead);
711
- const parts = wrapTextWithAnsi(styledBody, Math.max(1, contentWidth - leadWidth));
712
- const rows = parts.length ? parts : [styledBody];
713
- if (!leadWidth) return rows;
714
- const prefix = theme.fg("muted", lead);
715
- return rows.map((row) => prefix + row);
716
- }
717
-
718
- /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
719
- function bodyExceedsLineLimit(
720
- body: string,
721
- limit: number,
722
- contentWidth: number,
723
- asInput: boolean,
724
- theme: any,
725
- bodyColor = "toolOutput",
726
- /** 代码块的源行取着色内容,行数判断与实际渲染保持同一套规则。 */
727
- codeLine?: (sourceIndex: number) => string | undefined,
728
- ): boolean {
729
- const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
730
- if (!raw.trim()) return false;
731
- const sourceLines = raw.split("\n");
732
- if (sourceLines.length > limit) return true;
733
- let total = 0;
734
- for (const [index, source] of sourceLines.entries()) {
735
- const parts = asInput
736
- ? wrapInputLine(source, contentWidth, theme, codeLine?.(index))
737
- : wrapTextWithAnsi(theme.fg(bodyColor, source), contentWidth);
738
- total += Math.max(1, parts.length);
739
- if (total > limit) return true;
740
- }
741
- return false;
742
- }
743
-
113
+ /** 折叠结果行:` ↳ <body>`。 */
744
114
  export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
745
115
  return ` ↳ ${body}${collapsedHint}`;
746
116
  }
@@ -790,233 +160,3 @@ export function middleTruncateToWidth(text: string, width: number): string {
790
160
  }
791
161
  return `${left}…${right}`;
792
162
  }
793
-
794
- /**
795
- * 展开卡 Input 的代码块:源行起始下标(相对 text)+ 语言 + 剥掉行首缩进的代码。
796
- * standalone = 整段 Input 就是这块代码(没有 `code:` 标签),全量预览可以整段包围栏。
797
- */
798
- export type InputCodeBlock = {
799
- startLine: number;
800
- language: string;
801
- code: string;
802
- standalone: boolean;
803
- };
804
-
805
- export type ToolInputBody = { text: string; codeBlock?: InputCodeBlock };
806
-
807
- /** 按字段名判定可着色的入参:codemode / mcpscript 的 `code` 都是 JS 脚本。 */
808
- const INPUT_CODE_LANGUAGES: Record<string, string> = { code: "javascript" };
809
-
810
- /** Pretty-print tool call args for the expanded Input section. */
811
- export function formatToolInputBody(args: unknown, maxChars = 8_000): ToolInputBody {
812
- if (args === undefined || args === null) return { text: "" };
813
- if (typeof args !== "object") {
814
- const text = sanitizeToolResultText(String(args));
815
- return { text: text.length > maxChars ? `${text.slice(0, maxChars)}…` : text };
816
- }
817
- if (Array.isArray(args)) {
818
- try {
819
- const json = JSON.stringify(args, null, 2);
820
- return { text: json.length > maxChars ? `${json.slice(0, maxChars)}…` : json };
821
- } catch {
822
- return { text: sanitizeToolResultText(String(args)) };
823
- }
824
- }
825
-
826
- const entries = Object.entries(args as Record<string, unknown>).filter(
827
- ([, value]) => value !== undefined,
828
- );
829
- if (entries.length === 0) return { text: "" };
830
-
831
- // Stable, human-first field order for common tools.
832
- const preferred = [
833
- "path",
834
- "file_path",
835
- "command",
836
- "query",
837
- "pattern",
838
- "url",
839
- "name",
840
- "message",
841
- "content",
842
- "old_string",
843
- "new_string",
844
- ];
845
- entries.sort(([left], [right]) => {
846
- const li = preferred.indexOf(left);
847
- const ri = preferred.indexOf(right);
848
- if (li === -1 && ri === -1) return left.localeCompare(right);
849
- if (li === -1) return 1;
850
- if (ri === -1) return -1;
851
- return li - ri;
852
- });
853
-
854
- const lines: string[] = [];
855
- let codeBlock: InputCodeBlock | undefined;
856
- for (const [rawKey, value] of entries) {
857
- const key = sanitizeToolResultText(rawKey);
858
- if (typeof value === "string") {
859
- const safeValue = sanitizeToolResultText(value);
860
- if (safeValue.includes("\n")) {
861
- const expanded = safeValue.replace(/\t/g, " ");
862
- const language = INPUT_CODE_LANGUAGES[key];
863
- // 只有一个 code 字段时(codemode 等)整块 Input 就是脚本,`code:` 标签是噪音
864
- const standaloneCode = Boolean(language) && entries.length === 1;
865
- if (!standaloneCode) lines.push(`${key}:`);
866
- if (language) {
867
- // 缩进由 wrapInputLine 负责,着色只喂剥掉行首缩进的正文
868
- codeBlock = {
869
- startLine: lines.length,
870
- language,
871
- code: expanded
872
- .split("\n")
873
- .map((line) => line.replace(/^[ \t]*/, ""))
874
- .join("\n"),
875
- standalone: standaloneCode,
876
- };
877
- }
878
- for (const line of expanded.split("\n")) {
879
- lines.push(` ${line}`);
880
- }
881
- } else {
882
- lines.push(`${key}: ${safeValue}`);
883
- }
884
- continue;
885
- }
886
- if (typeof value === "number" || typeof value === "boolean" || value === null) {
887
- lines.push(`${key}: ${String(value)}`);
888
- continue;
889
- }
890
- try {
891
- const json = JSON.stringify(value, null, 2);
892
- if (json.includes("\n")) {
893
- lines.push(`${key}:`);
894
- for (const line of json.split("\n")) lines.push(` ${line}`);
895
- } else {
896
- lines.push(`${key}: ${json}`);
897
- }
898
- } catch {
899
- lines.push(`${key}: [unserializable]`);
900
- }
901
- }
902
- const text = lines.join("\n");
903
- // 截断后的行号与着色行不再对齐,这时只给纯文本
904
- if (text.length > maxChars) return { text: `${text.slice(0, maxChars)}…` };
905
- return codeBlock ? { text, codeBlock } : { text };
906
- }
907
-
908
- export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
909
- return formatToolInputBody(args, maxChars).text;
910
- }
911
-
912
- export function hasExpandableDetail(outputText: string, args: unknown): boolean {
913
- if (hasExpandableResult(outputText)) return true;
914
- return formatToolInputArgs(args).trim().length > 0;
915
- }
916
-
917
- export function renderExpandedToolResult(
918
- body: string,
919
- theme: any,
920
- isError: boolean,
921
- lastComponent?: unknown,
922
- args?: unknown,
923
- context?: any,
924
- /** 贴左渲染:树线的前导空格交给外层 Box(1,1) */
925
- flushLeft = false,
926
- ): ExpandedToolIoView | ExpandedToolResultText | Text {
927
- const { text: inputBody, codeBlock } = formatToolInputBody(args);
928
- const outputBody = body;
929
- const maxOutputLines = config.expandedOutputMaxLines;
930
- const maxInputLines = config.expandedInputMaxLines;
931
-
932
- // Prefer structured Input/Output when we have args or non-empty output.
933
- if (inputBody.trim() || outputBody.trim()) {
934
- let view: ExpandedToolIoView;
935
- if (isExpandedToolIoView(lastComponent)) {
936
- lastComponent.setContent(
937
- inputBody,
938
- outputBody,
939
- isError,
940
- maxOutputLines,
941
- maxInputLines,
942
- flushLeft,
943
- );
944
- view = lastComponent;
945
- } else {
946
- view = new ExpandedToolIoView(
947
- theme,
948
- inputBody,
949
- outputBody,
950
- isError,
951
- maxOutputLines,
952
- maxInputLines,
953
- flushLeft,
954
- );
955
- }
956
- view.setInputCode(codeBlock);
957
- if (context) rememberIoView(context, view);
958
- return view;
959
- }
960
-
961
- if (context?.state) context.state.ccstyleIoView = undefined;
962
- const color = isError ? "error" : "muted";
963
- return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
964
- }
965
-
966
- const toolViewMarker = (id: number, section: ToolIoSection) =>
967
- `_cc:v${id}:${section === "input" ? "i" : "o"}`;
968
-
969
- /** Per-frame ExpandedToolIoView ids for unambiguous show-more hit testing. */
970
- export type IoViewFrameState = {
971
- viewIds: Map<ExpandedToolIoView, number>;
972
- idToView: Map<number, ExpandedToolIoView>;
973
- nextId: number;
974
- };
975
- let activeIoViewFrame: IoViewFrameState | null = null;
976
-
977
- function frameViewId(view: ExpandedToolIoView): number | null {
978
- if (!activeIoViewFrame) return null;
979
- let id = activeIoViewFrame.viewIds.get(view);
980
- if (id === undefined) {
981
- id = activeIoViewFrame.nextId++;
982
- activeIoViewFrame.viewIds.set(view, id);
983
- activeIoViewFrame.idToView.set(id, view);
984
- }
985
- return id;
986
- }
987
-
988
- /** cachedLines stay clean; only the returned paint copy carries show-more view markers. */
989
- function withIoViewMarkers(view: ExpandedToolIoView, lines: string[]): string[] {
990
- const id = frameViewId(view);
991
- if (id === null) return lines;
992
- // Mark by exact header row from render — never scan body text for Input/Output labels.
993
- const marked = lines.slice();
994
- for (const { section, line } of view.showMoreHeaderLineIndexes()) {
995
- if (line < 0 || line >= marked.length) continue;
996
- marked[line] = `${marked[line]}${toolViewMarker(id, section)}`;
997
- }
998
- return marked;
999
- }
1000
- const ioViewInvalidators = new WeakMap<ExpandedToolIoView, () => void>();
1001
- function rememberIoView(context: any, view: ExpandedToolIoView): void {
1002
- if (!context || typeof context !== "object") return;
1003
- if (typeof context.invalidate === "function") {
1004
- ioViewInvalidators.set(view, () => context.invalidate());
1005
- }
1006
- if (!context.state || typeof context.state !== "object") context.state = {};
1007
- context.state.ccstyleIoView = view;
1008
- }
1009
-
1010
- /** hover 状态变更后刷新上一个/下一个视图(context.invalidate 缓存于 ioViewInvalidators)。 */
1011
- export function invalidateIoView(view: ExpandedToolIoView): void {
1012
- ioViewInvalidators.get(view)?.();
1013
- }
1014
-
1015
- /** 鼠标帧渲染期间读写当前 IoView 帧 id 表(mouse-interaction 的 doRender 包装使用)。 */
1016
- export function getActiveIoViewFrame(): IoViewFrameState | null {
1017
- return activeIoViewFrame;
1018
- }
1019
-
1020
- export function setActiveIoViewFrame(frame: IoViewFrameState | null): void {
1021
- activeIoViewFrame = frame;
1022
- }