pi-one-ui 0.1.1

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