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,1443 @@
1
+ /**
2
+ * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
+ * (`Ran for 8s, bash×2, read×2`),edit/write 独立标题行(`✓ write <path> (+25 -0)`),
4
+ * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)在单个工具卡中恢复
5
+ * compact-thinking/Pi 原生或专用 renderer。edit/write 复用 mode=on 的 rich diff
6
+ * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
7
+ * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
8
+ * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
9
+ *
10
+ * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
11
+ * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
12
+ * abort/error/length 状态行挂在摘要外层,避免被折叠吞掉。
13
+ * 最终 agent 回合摘要由 feature/agent-summary 独占(bash/read/edit/write/other)。
14
+ *
15
+ * 补丁生命周期遵循仓库既有模式:Symbol 所有权、dispose 仅恢复仍由本安装持有的
16
+ * 方法、重入守卫防止 /reload 后残留闭包递归。
17
+ */
18
+ import {
19
+ AssistantMessageComponent,
20
+ ToolExecutionComponent,
21
+ type Theme,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import {
24
+ truncateToWidth,
25
+ visibleWidth,
26
+ Box,
27
+ Spacer,
28
+ } from "@earendil-works/pi-tui";
29
+ import { config, getToolDisplayConfig } from "../../app/config/renderer.ts";
30
+ import { toolLoadingIcon } from "../../tools/tool-loading-icon.ts";
31
+ import { sanitizeToolResultText } from "../../tools/tool-result-sanitize.ts";
32
+ import { refreshTranscriptComponent } from "./transcript-refresh.ts";
33
+ import { getMessageDisplayTheme } from "./tool/message-display.ts";
34
+ import { showMoreHintText } from "./tool/show-more-hint.ts";
35
+ import {
36
+ countEditDiffStats,
37
+ countWriteDiffStats,
38
+ isRichDiffComponent,
39
+ } from "./tool/diff/diff-renderer.ts";
40
+ import { renderRichToolResult } from "./tool/diff/index.ts";
41
+ import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
42
+ import { isToolCallHovered } from "./mouse/hover.ts";
43
+ import {
44
+ insetComponent,
45
+ renderExpandedToolResult,
46
+ scheduleAnimation,
47
+ } from "./tool/result.ts";
48
+ import { oneLine } from "../../tools/format.ts";
49
+ import { paddedBackgroundRow } from "./tool/grouping.ts";
50
+ import { hasVisibleText, stripBackgroundAnsi } from "../../tools/ansi-text.ts";
51
+ import { walkComponentTree } from "../../tools/component-tree.ts";
52
+ import {
53
+ ASSISTANT_REENTRY_DESCRIPTION,
54
+ ASSISTANT_REENTRY_KEY,
55
+ ASSISTANT_SET_EXPANDED_DESCRIPTION,
56
+ ASSISTANT_SET_EXPANDED_KEY,
57
+ ASSISTANT_TOGGLE_ROUND_DESCRIPTION,
58
+ ASSISTANT_TOGGLE_ROUND_KEY,
59
+ COMPACT_MODE_PATCH_KEY,
60
+ COMPACT_THINKING_PATCH_KEY,
61
+ patchRegistry,
62
+ PROTOTYPE_ORIGINAL_KEY,
63
+ } from "../../tools/patch-keys.ts";
64
+
65
+ /** compact 渲染层对 compact-thinking 的只读查询面(不建第二套计时器)。 */
66
+ export type CompactThinkingQuery = {
67
+ getMessageThinkingDurationMs(messageTimestamp: number): number | undefined;
68
+ isMessageThinkingActive?(messageTimestamp: number): boolean;
69
+ getThinkingAnimationFrame?(): number;
70
+ setCompactSummaryActive?(active: boolean): void;
71
+ };
72
+
73
+ const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
74
+
75
+ type CompactThinkingTheme = Pick<Theme, "fg" | "italic" | "bold">;
76
+
77
+ /** 与 compact-thinking 主渲染器共用的静态文字样式。 */
78
+ export function styleCompactThinkingText(
79
+ text: string,
80
+ theme: CompactThinkingTheme | undefined,
81
+ bold = false,
82
+ ): string {
83
+ if (!theme) return text;
84
+ const color = config.dimThinkingText ? "dim" : "thinkingText";
85
+ const colored = typeof theme.fg === "function" ? theme.fg(color, text) : text;
86
+ const weighted =
87
+ bold && typeof theme.bold === "function" ? theme.bold(colored) : colored;
88
+ return typeof theme.italic === "function" ? theme.italic(weighted) : weighted;
89
+ }
90
+
91
+ /** 与 compact-thinking 主渲染器共用的活动思考扫光动画。 */
92
+ export function animateCompactThinkingText(
93
+ text: string,
94
+ theme: CompactThinkingTheme | undefined,
95
+ animationFrame: number,
96
+ boldBase = false,
97
+ ): string {
98
+ if (!theme) return text;
99
+ const characters = Array.from(text);
100
+ if (characters.length === 0) return "";
101
+ const highlightWidth = Math.max(
102
+ 1,
103
+ Math.min(5, Math.ceil(characters.length * 0.28)),
104
+ );
105
+ const start =
106
+ (animationFrame % (characters.length + highlightWidth)) - highlightWidth;
107
+ const end = start + highlightWidth;
108
+ const before = characters.slice(0, Math.max(0, start)).join("");
109
+ const highlighted = characters
110
+ .slice(Math.max(0, start), Math.min(characters.length, end))
111
+ .join("");
112
+ const after = characters.slice(Math.max(0, end)).join("");
113
+ const highlightedColored =
114
+ highlighted && typeof theme.fg === "function"
115
+ ? theme.fg("text", highlighted)
116
+ : highlighted;
117
+ const highlightedWeighted =
118
+ highlightedColored && typeof theme.bold === "function"
119
+ ? theme.bold(highlightedColored)
120
+ : highlightedColored;
121
+ const highlightedText =
122
+ highlightedWeighted && typeof theme.italic === "function"
123
+ ? theme.italic(highlightedWeighted)
124
+ : highlightedWeighted;
125
+
126
+ return (
127
+ styleCompactThinkingText(before, theme, boldBase) +
128
+ highlightedText +
129
+ styleCompactThinkingText(after, theme, boldBase)
130
+ );
131
+ }
132
+
133
+ function formatThoughtDuration(durationMs: number) {
134
+ if (durationMs < 1_000) {
135
+ return `${Math.max(1, Math.round(durationMs))}ms`;
136
+ }
137
+
138
+ const totalSeconds = Math.max(1, Math.round(durationMs / 1_000));
139
+ const minutes = Math.floor(totalSeconds / 60);
140
+ const seconds = totalSeconds % 60;
141
+ if (minutes === 0) return `${seconds}s`;
142
+ return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
143
+ }
144
+
145
+ export { formatThoughtDuration };
146
+
147
+ /** assistant stopReason → 外层状态文案(与 Pi 原生口径对齐)。 */
148
+ export function messageStopStatus(message: any): string | undefined {
149
+ const reason = message?.stopReason;
150
+ if (reason === "aborted") {
151
+ return message.errorMessage &&
152
+ message.errorMessage !== "Request was aborted"
153
+ ? String(message.errorMessage)
154
+ : "Operation aborted";
155
+ }
156
+ if (reason === "error") {
157
+ return `Error: ${message.errorMessage || "Unknown error"}`;
158
+ }
159
+ if (reason === "length") {
160
+ return "Response was truncated before completion.";
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ function roundStopStatus(messages: Iterable<any>): string | undefined {
166
+ for (const message of messages) {
167
+ const status = messageStopStatus(message);
168
+ if (status) return status;
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ /**
174
+ * 逐条 assistant message 的摘要文本(无工具计数时可为空串):
175
+ * 时长 = max(thinking query, durationFloorMs 挂钟);工具首次出现顺序;read 路径去重。
176
+ */
177
+ function buildMessagesSummary(
178
+ messages: Iterable<any>,
179
+ query?: CompactThinkingQuery,
180
+ runningActiveOverride?: boolean,
181
+ durationFloorMs?: number,
182
+ ): string {
183
+ const parts: string[] = [];
184
+ const counts = new Map<string, number>();
185
+ const readPaths = new Set<string>();
186
+ const durationTimestamps = new Set<number>();
187
+ let durationMs = 0;
188
+ let runningActive = false;
189
+
190
+ for (const message of messages) {
191
+ if (
192
+ typeof message?.timestamp === "number" &&
193
+ !durationTimestamps.has(message.timestamp)
194
+ ) {
195
+ durationTimestamps.add(message.timestamp);
196
+ if (query?.isMessageThinkingActive?.(message.timestamp))
197
+ runningActive = true;
198
+ const value = query?.getMessageThinkingDurationMs(message.timestamp);
199
+ if (typeof value === "number" && Number.isFinite(value) && value > 0)
200
+ durationMs += value;
201
+ }
202
+ const content = Array.isArray(message?.content) ? message.content : [];
203
+ for (const item of content) {
204
+ if (item?.type !== "toolCall") continue;
205
+ const rawName = typeof item.name === "string" ? item.name : "tool";
206
+ if (EDIT_WRITE_TOOLS.has(rawName)) continue;
207
+ const name = sanitizeToolResultText(rawName);
208
+ if (rawName.split(".").pop() === "read") {
209
+ const args = item.arguments ?? item.args ?? {};
210
+ const path = args.path ?? args.file_path ?? args.file;
211
+ if (typeof path === "string" && path.length > 0) {
212
+ if (readPaths.has(path)) continue;
213
+ readPaths.add(path);
214
+ }
215
+ }
216
+ counts.set(name, (counts.get(name) ?? 0) + 1);
217
+ }
218
+ }
219
+
220
+ if (
221
+ typeof durationFloorMs === "number" &&
222
+ Number.isFinite(durationFloorMs) &&
223
+ durationFloorMs > durationMs
224
+ ) {
225
+ durationMs = durationFloorMs;
226
+ }
227
+
228
+ runningActive = runningActiveOverride ?? runningActive;
229
+ if (runningActive) {
230
+ parts.push(
231
+ durationMs > 0
232
+ ? `Running... · ${formatThoughtDuration(durationMs)}`
233
+ : "Running...",
234
+ );
235
+ } else if (durationMs > 0)
236
+ parts.push(`Ran for ${formatThoughtDuration(durationMs)}`);
237
+ for (const [name, count] of counts) parts.push(`${name}×${count}`);
238
+ return parts.join(", ");
239
+ }
240
+
241
+ export function buildMessageSummary(
242
+ message: any,
243
+ query?: CompactThinkingQuery,
244
+ durationFloorMs?: number,
245
+ ): string {
246
+ return buildMessagesSummary([message], query, undefined, durationFloorMs);
247
+ }
248
+
249
+ function fallbackTheme(): any {
250
+ return {
251
+ fg: (_color: string, text: string) => text,
252
+ italic: (text: string) => text,
253
+ bold: (text: string) => text,
254
+ };
255
+ }
256
+
257
+ function themeOf(): any {
258
+ return getMessageDisplayTheme() ?? fallbackTheme();
259
+ }
260
+
261
+ /** RGB → HSL(h∈[0,1), l/s∈[0,1])。 */
262
+ function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
263
+ r /= 255;
264
+ g /= 255;
265
+ b /= 255;
266
+ const max = Math.max(r, g, b);
267
+ const min = Math.min(r, g, b);
268
+ const l = (max + min) / 2;
269
+ if (max === min) return [0, 0, l];
270
+ const d = max - min;
271
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
272
+ let h: number;
273
+ switch (max) {
274
+ case r:
275
+ h = (g - b) / d + (g < b ? 6 : 0);
276
+ break;
277
+ case g:
278
+ h = (b - r) / d + 2;
279
+ break;
280
+ default:
281
+ h = (r - g) / d + 4;
282
+ }
283
+ return [h / 6, l, s];
284
+ }
285
+
286
+ /** HSL → RGB(h∈[0,1),l/s∈[0,1])。 */
287
+ function hslToRgb(h: number, l: number, s: number): [number, number, number] {
288
+ if (s === 0) {
289
+ const v = Math.round(l * 255);
290
+ return [v, v, v];
291
+ }
292
+ const hue2rgb = (p: number, q: number, t: number): number => {
293
+ if (t < 0) t += 1;
294
+ if (t > 1) t -= 1;
295
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
296
+ if (t < 1 / 2) return q;
297
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
298
+ return p;
299
+ };
300
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
301
+ const p = 2 * l - q;
302
+ return [
303
+ Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
304
+ Math.round(hue2rgb(p, q, h) * 255),
305
+ Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
306
+ ];
307
+ }
308
+
309
+ /** 内部 tool call card 背景:在 userMessageBg 基础上按 HSL 变暗(l×0.7),
310
+ * 与外卡片形成嵌套层次;非 RGB 色或已足够暗时原样返回。 */
311
+ function darkenBgAnsi(theme: any, slot: string): string {
312
+ const prefix =
313
+ typeof theme?.getBgAnsi === "function" ? String(theme.getBgAnsi(slot)) : "";
314
+ const m = prefix.match(/48;2;(\d+);(\d+);(\d+)/);
315
+ if (!m) return prefix;
316
+ const [h, l, s] = rgbToHsl(Number(m[1]), Number(m[2]), Number(m[3]));
317
+ if (l <= 0.05) return prefix; // 已足够暗,不再变暗
318
+ const [r, g, b] = hslToRgb(h, l * 0.7, s);
319
+ return `\x1b[48;2;${r};${g};${b}m`;
320
+ }
321
+
322
+ /** 工具卡深色行:左右内缩(左 2 右 3 格,含工具卡自身 padding),背景只到内容区,
323
+ * 修复行尾 reset 截断。 */
324
+ function toolCardBgRow(
325
+ theme: any,
326
+ slot: string,
327
+ bgAnsi: string,
328
+ line: string,
329
+ width: number,
330
+ ): string {
331
+ const leftInset = 2;
332
+ const rightInset = 3;
333
+ const contentWidth = Math.max(0, width - leftInset - rightInset);
334
+ // 深色块内左右各 1 格内部 padding;去掉行首原有空格后重新对齐
335
+ const text = stripBackgroundAnsi(line).replace(/^ +/, "");
336
+ const innerPad = 2;
337
+ const clipped = truncateToWidth(
338
+ text,
339
+ Math.max(0, contentWidth - innerPad),
340
+ "",
341
+ );
342
+ const pad = Math.max(0, contentWidth - innerPad - visibleWidth(clipped));
343
+ const body = ` ${clipped}${" ".repeat(pad)} `;
344
+ const stable = body.replace(/\x1b\[(?:0)?m/g, (reset) => reset + bgAnsi);
345
+ const outerBg =
346
+ typeof theme?.getBgAnsi === "function" ? String(theme.getBgAnsi(slot)) : "";
347
+ const inset = (n: number) =>
348
+ outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n);
349
+ return `${inset(leftInset)}${bgAnsi}${stable}\x1b[49m${inset(rightInset)}`;
350
+ }
351
+ /** edit/write 展开卡:保持原样式(userMessageBg Box),不应用工具卡深色/间距改动。 */
352
+ function editWriteExpandedCard(theme: any): any {
353
+ return new Box(
354
+ 1,
355
+ 1,
356
+ typeof theme.bg === "function"
357
+ ? (text: string) => theme.bg("userMessageBg", text)
358
+ : undefined,
359
+ );
360
+ }
361
+
362
+ /** compact 展开卡:外卡片保持 userMessageBg 原色;内部 tool call card
363
+ * 背景更深一层且只覆盖内容区(左右内缩、上下限首尾文本行),形成嵌套层次。
364
+ * hits:非工具子卡(thinking)的行区间,供展开后点击 hint。 */
365
+ function layoutExpandedToolCard(
366
+ theme: any,
367
+ children: any[],
368
+ width: number,
369
+ ): {
370
+ lines: string[];
371
+ hits: Array<{ child: any; start: number; end: number }>;
372
+ } {
373
+ const slot = "userMessageBg";
374
+ const toolBgAnsi = darkenBgAnsi(theme, slot);
375
+ const innerWidth = Math.max(0, width - 2);
376
+ const lines: string[] = [];
377
+ const hits: Array<{ child: any; start: number; end: number }> = [];
378
+ const isThinkingPreview = (child: any) =>
379
+ typeof child?.setHintHovered === "function";
380
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
381
+ // 展开不再显示摘要行:跳过第一个 child 的首行空白,避免白占一行
382
+ let skipLeadingBlank = true;
383
+ for (const child of children) {
384
+ const childLines = child.render(innerWidth);
385
+ // 展开的 thinking 与 tool 一样走嵌套深色卡,避免贴在外卡同色底上看不见。
386
+ const nest =
387
+ child.__ccToolCard ||
388
+ (isThinkingPreview(child) && child.expanded === true);
389
+ if (!nest) {
390
+ let start = 0;
391
+ if (skipLeadingBlank) {
392
+ while (start < childLines.length && !hasVisibleText(childLines[start]))
393
+ start++;
394
+ skipLeadingBlank = false;
395
+ }
396
+ const rangeStart = lines.length;
397
+ for (let i = start; i < childLines.length; i++) {
398
+ lines.push(paddedBackgroundRow(theme, slot, childLines[i], width));
399
+ }
400
+ if (lines.length > rangeStart)
401
+ hits.push({ child, start: rangeStart, end: lines.length });
402
+ continue;
403
+ }
404
+ // 工具卡:前导 1 空行 + 首尾文本行之间的内容区(深色);不保留尾随,
405
+ // 相邻工具卡之间正好 1 空行,底部由卡片 padding 收尾。
406
+ skipLeadingBlank = false;
407
+ let first = -1;
408
+ let last = -1;
409
+ for (let i = 0; i < childLines.length; i++) {
410
+ if (hasVisibleText(childLines[i])) {
411
+ if (first < 0) first = i;
412
+ last = i;
413
+ }
414
+ }
415
+ if (first < 0) {
416
+ for (const line of childLines) {
417
+ lines.push(paddedBackgroundRow(theme, slot, line, width));
418
+ }
419
+ continue;
420
+ }
421
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
422
+ const rangeStart = lines.length;
423
+ // 子卡片内部上下各 1 行深色 padding
424
+ lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
425
+ for (let i = first; i <= last; i++) {
426
+ lines.push(toolCardBgRow(theme, slot, toolBgAnsi, childLines[i], width));
427
+ }
428
+ lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
429
+ if (isThinkingPreview(child))
430
+ hits.push({ child, start: rangeStart, end: lines.length });
431
+ }
432
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
433
+ return { lines, hits };
434
+ }
435
+
436
+ function compactRoundCard(
437
+ cardItems: Array<{ child?: any; tool?: any }>,
438
+ toolRender: (tool: any, width: number) => string[],
439
+ ): any {
440
+ const children: any[] = [];
441
+ for (const item of cardItems) {
442
+ if (item.child) children.push(item.child);
443
+ else if (item.tool) {
444
+ const tool = item.tool;
445
+ children.push({
446
+ __ccToolCard: true,
447
+ render: (innerWidth: number) => toolRender(tool, innerWidth),
448
+ invalidate: () => tool.invalidate?.(),
449
+ });
450
+ }
451
+ }
452
+ return {
453
+ children,
454
+ render(width: number): string[] {
455
+ return ["", ...layoutExpandedToolCard(themeOf(), children, width).lines];
456
+ },
457
+ childAtRow(localRow: number, width: number) {
458
+ if (localRow < 1) return null;
459
+ const row = localRow - 1;
460
+ const { hits } = layoutExpandedToolCard(themeOf(), children, width);
461
+ for (const hit of hits) {
462
+ if (row >= hit.start && row < hit.end) return hit.child;
463
+ }
464
+ return null;
465
+ },
466
+ invalidate() {
467
+ for (const child of children) child.invalidate?.();
468
+ },
469
+ };
470
+ }
471
+
472
+ function isAssistantComponent(value: any): boolean {
473
+ return value instanceof AssistantMessageComponent;
474
+ }
475
+
476
+ function isToolComponent(value: any): boolean {
477
+ return value instanceof ToolExecutionComponent;
478
+ }
479
+
480
+ function detachAssistantExpansion(component: any): void {
481
+ let owned =
482
+ typeof component?.[ASSISTANT_SET_EXPANDED_KEY] === "function" &&
483
+ component.setExpanded === component[ASSISTANT_SET_EXPANDED_KEY];
484
+ if (!owned && component && typeof component === "object") {
485
+ // 首次升级时清理旧模块使用 Symbol() 留下的实例方法。
486
+ owned = Object.getOwnPropertySymbols(component).some(
487
+ (symbol) =>
488
+ symbol.description === ASSISTANT_SET_EXPANDED_DESCRIPTION &&
489
+ component[symbol] === true,
490
+ );
491
+ }
492
+ if (!owned) return;
493
+
494
+ delete component.setExpanded;
495
+ for (const symbol of Object.getOwnPropertySymbols(component)) {
496
+ if (
497
+ symbol.description === ASSISTANT_SET_EXPANDED_DESCRIPTION ||
498
+ symbol.description === ASSISTANT_TOGGLE_ROUND_DESCRIPTION ||
499
+ symbol.description === ASSISTANT_REENTRY_DESCRIPTION
500
+ ) {
501
+ delete component[symbol];
502
+ }
503
+ }
504
+ }
505
+
506
+ /** 供 mouse-interaction 识别可点击的 compact assistant 行(仅 compact 模式下生效)。 */
507
+ export function isCompactAssistantComponent(value: unknown): boolean {
508
+ if (config.mode !== "compact" || !value || typeof value !== "object")
509
+ return false;
510
+ const component = value as any;
511
+ return (
512
+ typeof component[ASSISTANT_SET_EXPANDED_KEY] === "function" &&
513
+ component.setExpanded === component[ASSISTANT_SET_EXPANDED_KEY]
514
+ );
515
+ }
516
+
517
+ export type CompactModeHooks = {
518
+ /** 会话事件后同步:所有权、全局展开状态、已挂载组件。 */
519
+ sync(ctx: any): void;
520
+ /** 重绘所有被跟踪的 assistant/tool 组件(模式切换用)。 */
521
+ refresh(): void;
522
+ /** compact 模式下重新认领 assistant patch(位于 compact-thinking 之上)。 */
523
+ assertOwnership(): void;
524
+ /** 重新渲染包含指定 toolCallId 的 assistant 消息(思考收尾时刷新)。 */
525
+ refreshToolCallMessage(toolCallId: string | undefined): void;
526
+ shutdown(): void;
527
+ };
528
+
529
+ type CompactModeInstallDeps = {
530
+ query?: CompactThinkingQuery;
531
+ writeMetadata: WriteExecutionMetadataStore;
532
+ };
533
+
534
+ type CompactModePatch = {
535
+ active: boolean;
536
+ prototype: any;
537
+ assistantInstalled: (...args: any[]) => any;
538
+ assistantOriginal: (...args: any[]) => any;
539
+ assistantNative: (...args: any[]) => any;
540
+ toolInstalledRender: (width: number) => string[];
541
+ toolInstalledUpdateDisplay: () => void;
542
+ toolOriginalRender: (width: number) => string[];
543
+ toolOriginalUpdateDisplay: () => void;
544
+ assertAssistantOwnership: () => void;
545
+ dispose: () => void;
546
+ };
547
+
548
+ const trackedAssistantComponents = new Set<any>();
549
+ const trackedToolComponents = new Set<any>();
550
+ let hoveredAssistantComponent: any;
551
+
552
+ export function setHoveredCompactAssistant(component: any): boolean {
553
+ if (hoveredAssistantComponent === component) return false;
554
+ hoveredAssistantComponent = component;
555
+ return true;
556
+ }
557
+
558
+ function compactEditWriteLine(
559
+ component: any,
560
+ width: number,
561
+ writeMetadata?: WriteExecutionMetadataStore,
562
+ options: { hint?: boolean; flushLeft?: boolean } = {},
563
+ ): string[] {
564
+ const theme = themeOf();
565
+ const name = String(component.toolName ?? "tool");
566
+ const args = component.args ?? {};
567
+ const path = sanitizeToolResultText(
568
+ typeof args.path === "string" && args.path
569
+ ? args.path
570
+ : typeof args.file_path === "string" && args.file_path
571
+ ? args.file_path
572
+ : "",
573
+ );
574
+ const isError = component.result?.isError === true;
575
+ const isPending = !component.result || component.isPartial === true;
576
+ const icon = isError ? "✗" : isPending ? toolLoadingIcon() : "✓";
577
+ const iconColor = isError ? "error" : isPending ? "accent" : "success";
578
+ let statsText = "";
579
+ let statsStyled = "";
580
+ if (!isError && !isPending) {
581
+ const stats =
582
+ name === "edit"
583
+ ? countEditDiffStats(component.result?.details)
584
+ : name === "write"
585
+ ? countWriteDiffStats(
586
+ typeof args.content === "string" ? args.content : undefined,
587
+ writeMetadata?.get(component.toolCallId)?.previousContent,
588
+ writeMetadata?.get(component.toolCallId)?.fileExistedBeforeWrite,
589
+ )
590
+ : undefined;
591
+ if (stats) {
592
+ statsText = ` (+${stats.added} -${stats.removed})`;
593
+ statsStyled = ` ${theme.fg("dim", "(")}${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}${theme.fg("dim", ")")}`;
594
+ }
595
+ }
596
+ // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
597
+ const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
598
+ const namePart = theme.fg("toolTitle", name);
599
+ const hintText =
600
+ options.hint !== false && component.expanded !== true
601
+ ? ` • ${showMoreHintText()}`
602
+ : "";
603
+ const fixedWidth =
604
+ visibleWidth(iconPart) +
605
+ visibleWidth(namePart) +
606
+ visibleWidth(statsText) +
607
+ visibleWidth(hintText);
608
+ const pathWidth = Math.max(0, width - fixedWidth - (path ? 1 : 0));
609
+ const pathPart = pathWidth > 0 && path ? ` ${oneLine(path, pathWidth)}` : "";
610
+ const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintText ? theme.fg("dim", hintText) : ""}`;
611
+ return ["", truncateToWidth(line, width, "")];
612
+ }
613
+
614
+ /** expanded 写进 rich diff 闭包,折叠/展开分槽缓存,避免每帧 parseDiff。 */
615
+ const compactRichDiffCache = new WeakMap<
616
+ object,
617
+ { result: unknown; collapsed?: unknown; expanded?: unknown }
618
+ >();
619
+
620
+ /**
621
+ * compact edit/write:标题行 + mode=on 同一套 rich diff。
622
+ * 折叠/展开都走 `renderRichToolResult`,limits 不另开一套。
623
+ */
624
+ function compactEditWriteLines(
625
+ component: any,
626
+ width: number,
627
+ writeMetadata?: WriteExecutionMetadataStore,
628
+ ): string[] {
629
+ const theme = themeOf();
630
+ const result = component.result;
631
+ const expanded = component.expanded === true;
632
+ const isError = result?.isError === true;
633
+ const isPending = !result || component.isPartial === true;
634
+ let candidate: unknown;
635
+ if (!isPending && writeMetadata) {
636
+ let entry = compactRichDiffCache.get(component);
637
+ if (!entry || entry.result !== result) {
638
+ entry = { result };
639
+ compactRichDiffCache.set(component, entry);
640
+ }
641
+ const slot = expanded ? "expanded" : "collapsed";
642
+ candidate = entry[slot];
643
+ if (!isRichDiffComponent(candidate)) {
644
+ candidate = renderRichToolResult(
645
+ String(component.toolName ?? ""),
646
+ result,
647
+ {
648
+ expanded,
649
+ isPartial: component.isPartial === true,
650
+ isError,
651
+ isHovered: () => isToolCallHovered(component.toolCallId),
652
+ },
653
+ theme,
654
+ component,
655
+ writeMetadata,
656
+ getToolDisplayConfig,
657
+ );
658
+ if (isRichDiffComponent(candidate)) entry[slot] = candidate;
659
+ }
660
+ }
661
+ const hasRich = isRichDiffComponent(candidate);
662
+ if (hasRich) {
663
+ component.resultRendererComponent = candidate;
664
+ }
665
+
666
+ const title = compactEditWriteLine(component, width, writeMetadata, {
667
+ hint: !hasRich,
668
+ });
669
+ if (!hasRich && !expanded) {
670
+ return title;
671
+ }
672
+
673
+ let detail: any;
674
+ if (hasRich) {
675
+ detail = expanded ? candidate : insetComponent(candidate as any);
676
+ } else {
677
+ const outputText = sanitizeToolResultText(
678
+ Array.isArray(result?.content)
679
+ ? result.content
680
+ .filter((item: any) => item?.type === "text")
681
+ .map((item: any) => String(item.text ?? ""))
682
+ .join("\n")
683
+ : "",
684
+ );
685
+ const state = (component.state ??= {});
686
+ detail = renderExpandedToolResult(
687
+ outputText,
688
+ theme,
689
+ isError,
690
+ state.ccstyleIoView,
691
+ component.args,
692
+ component,
693
+ true, // 外层 Box(1,1) 已 pad
694
+ );
695
+ component.resultRendererComponent = detail;
696
+ }
697
+
698
+ if (!expanded) {
699
+ return [...title, ...detail.render(width)];
700
+ }
701
+
702
+ const box = editWriteExpandedCard(theme);
703
+ box.addChild({
704
+ render(innerWidth: number): string[] {
705
+ return compactEditWriteLine(component, innerWidth, writeMetadata, {
706
+ hint: false,
707
+ flushLeft: true,
708
+ }).slice(1);
709
+ },
710
+ invalidate() {},
711
+ });
712
+ box.addChild(detail);
713
+ // 与原生 ToolExecutionComponent 一样:Spacer(1) + Box,否则贴上一条 tool 少 1 行间距
714
+ return ["", ...box.render(width)];
715
+ }
716
+
717
+ function compactAssistantLineComponent(
718
+ component: any,
719
+ /** 静态串或每次 render 重算(Running 时长需逐步跳动)。 */
720
+ summary: string | (() => string),
721
+ query?: CompactThinkingQuery,
722
+ options: { hint?: boolean; leadingBlank?: boolean; pad?: number } = {},
723
+ ): any {
724
+ const self = component as any;
725
+ return {
726
+ render(width: number): string[] {
727
+ const theme = themeOf();
728
+ const pad = Math.max(0, options.pad ?? (Number(self.outputPad) || 0));
729
+ const available = Math.max(0, width - pad);
730
+ const hintText = options.hint === false ? "" : ` • ${showMoreHintText()}`;
731
+ const summaryWidth = Math.max(0, available - visibleWidth(hintText));
732
+ const resolved = typeof summary === "function" ? summary() : summary;
733
+ const runningActive = resolved.startsWith("Running...");
734
+ const plainText = truncateToWidth(resolved, summaryWidth, "…");
735
+ let text = theme.fg("muted", plainText);
736
+ if (runningActive || plainText.startsWith("Ran for ")) {
737
+ const separator = plainText.indexOf(", ");
738
+ const heading =
739
+ separator < 0 ? plainText : plainText.slice(0, separator);
740
+ const tools = separator < 0 ? "" : plainText.slice(separator);
741
+ if (runningActive) {
742
+ const durationSeparator = heading.indexOf(" · ");
743
+ const label =
744
+ durationSeparator < 0
745
+ ? heading
746
+ : heading.slice(0, durationSeparator);
747
+ const duration =
748
+ durationSeparator < 0 ? "" : heading.slice(durationSeparator);
749
+ text = `${animateCompactThinkingText(
750
+ label,
751
+ theme,
752
+ query?.getThinkingAnimationFrame?.() ?? 0,
753
+ )}${styleCompactThinkingText(duration, theme)}${theme.fg("muted", tools)}`;
754
+ } else {
755
+ text = `${styleCompactThinkingText(heading, theme)}${theme.fg("muted", tools)}`;
756
+ }
757
+ }
758
+ const hintColor =
759
+ hoveredAssistantComponent === component ? "text" : "dim";
760
+ const line = `${text}${hintText ? theme.fg(hintColor, hintText) : ""}`;
761
+ const rendered = `${" ".repeat(pad)}${truncateToWidth(line, available, "")}`;
762
+ return options.leadingBlank === false ? [rendered] : ["", rendered];
763
+ },
764
+ invalidate() {},
765
+ };
766
+ }
767
+
768
+ function compactStopStatusLine(status: string, pad = 0): any {
769
+ return {
770
+ render(width: number): string[] {
771
+ const theme = themeOf();
772
+ const prefix = " ".repeat(Math.max(0, pad));
773
+ const line = `${prefix}${theme.fg("error", status)}`;
774
+ return ["", truncateToWidth(line, width, "")];
775
+ },
776
+ invalidate() {},
777
+ };
778
+ }
779
+
780
+ function compactAssistantLine(
781
+ component: any,
782
+ summary: string | (() => string),
783
+ query?: CompactThinkingQuery,
784
+ ): void {
785
+ // 空静态串跳过;getter 留给 render 判断(Running 可能稍后才有时长)。
786
+ if (typeof summary === "string" && !summary) return;
787
+ component.contentContainer.addChild(
788
+ compactAssistantLineComponent(component, summary, query),
789
+ );
790
+ }
791
+
792
+ function appendStopStatus(component: any, status: string | undefined): void {
793
+ if (!status || !component?.contentContainer?.addChild) return;
794
+ component.contentContainer.addChild(
795
+ compactStopStatusLine(status, Number(component.outputPad) || 0),
796
+ );
797
+ }
798
+
799
+ function ensureAssistantSetExpanded(component: any): void {
800
+ if (
801
+ typeof component[ASSISTANT_SET_EXPANDED_KEY] === "function" &&
802
+ component.setExpanded === component[ASSISTANT_SET_EXPANDED_KEY]
803
+ ) {
804
+ return;
805
+ }
806
+ if (typeof component.setExpanded === "function") {
807
+ detachAssistantExpansion(component);
808
+ if (typeof component.setExpanded === "function") return;
809
+ }
810
+ const installed = function (this: any, expanded: boolean) {
811
+ if (config.mode !== "compact") {
812
+ detachAssistantExpansion(this);
813
+ return;
814
+ }
815
+ const toggleRound = this[ASSISTANT_TOGGLE_ROUND_KEY];
816
+ if (typeof toggleRound === "function") {
817
+ toggleRound(expanded);
818
+ return;
819
+ }
820
+ this.expanded = expanded;
821
+ if (typeof this.updateContent === "function" && this.lastMessage) {
822
+ this.updateContent(this.lastMessage);
823
+ }
824
+ };
825
+ component[ASSISTANT_SET_EXPANDED_KEY] = installed;
826
+ component.setExpanded = installed;
827
+ }
828
+
829
+ function collectMountedComponents(root: any): void {
830
+ if (!root || typeof root !== "object") return;
831
+ const assistants = new Set<any>();
832
+ const tools = new Set<any>();
833
+ walkComponentTree(root, (value: any) => {
834
+ if (isAssistantComponent(value)) {
835
+ assistants.add(value);
836
+ // 仅在 compact 模式给实例装 setExpanded,避免 on/off 下 Ctrl+O/mouse 回归。
837
+ if (config.mode === "compact") ensureAssistantSetExpanded(value);
838
+ } else if (isToolComponent(value)) {
839
+ tools.add(value);
840
+ }
841
+ });
842
+ // 扫到组件才替换跟踪表。面板/custom UI 打开时 root 往往扫不到 transcript,
843
+ // 若此时清空会丢掉 live updateContent/updateDisplay 已登记的实例,
844
+ // /ccstyle 切换就只能靠 /reload 重建。
845
+ if (assistants.size > 0 || tools.size > 0) {
846
+ trackedAssistantComponents.clear();
847
+ trackedToolComponents.clear();
848
+ for (const component of assistants)
849
+ trackedAssistantComponents.add(component);
850
+ for (const component of tools) trackedToolComponents.add(component);
851
+ } else if (config.mode === "compact") {
852
+ for (const component of trackedAssistantComponents)
853
+ ensureAssistantSetExpanded(component);
854
+ }
855
+ }
856
+
857
+ export function installCompactMode(
858
+ deps: CompactModeInstallDeps,
859
+ ): CompactModeHooks {
860
+ const previous = patchRegistry.get<CompactModePatch>(COMPACT_MODE_PATCH_KEY);
861
+ if (previous) previous.dispose();
862
+
863
+ const assistantPrototype = AssistantMessageComponent.prototype as any;
864
+ const toolPrototype = ToolExecutionComponent.prototype as any;
865
+ const patch: CompactModePatch = {
866
+ active: true,
867
+ prototype: assistantPrototype,
868
+ assistantInstalled: undefined as any,
869
+ assistantOriginal: assistantPrototype.updateContent,
870
+ assistantNative: assistantPrototype.updateContent,
871
+ toolInstalledRender: undefined as any,
872
+ toolInstalledUpdateDisplay: undefined as any,
873
+ toolOriginalRender: toolPrototype.render,
874
+ toolOriginalUpdateDisplay: toolPrototype.updateDisplay,
875
+ assertAssistantOwnership: () => {},
876
+ dispose: () => {},
877
+ };
878
+
879
+ const passThroughAssistant = (
880
+ component: any,
881
+ message: any,
882
+ isStreaming?: boolean,
883
+ ): any => {
884
+ const self = component as any;
885
+ if (self[ASSISTANT_REENTRY_KEY] === patch)
886
+ return patch.assistantNative.call(component, message, isStreaming);
887
+ self[ASSISTANT_REENTRY_KEY] = patch;
888
+ try {
889
+ return patch.assistantOriginal.call(component, message, isStreaming);
890
+ } finally {
891
+ delete self[ASSISTANT_REENTRY_KEY];
892
+ }
893
+ };
894
+
895
+ type CompactRound = {
896
+ anchor: any;
897
+ messages: Map<any, any>;
898
+ detachedMessages: any[];
899
+ active: boolean;
900
+ suppressedToolIds: Set<string>;
901
+ /** 回合挂钟起点,保证 Running 时长连续递增。 */
902
+ startedAt: number;
903
+ endedAt?: number;
904
+ };
905
+ let activeRound: CompactRound | undefined;
906
+ let roundByComponent = new WeakMap<object, CompactRound>();
907
+ const expandedRoundToolIds = new Set<string>();
908
+ let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
909
+ let roundTickTimer: ReturnType<typeof setInterval> | undefined;
910
+
911
+ const roundWallMs = (round: CompactRound): number => {
912
+ const end = round.active ? Date.now() : (round.endedAt ?? Date.now());
913
+ return Math.max(1, end - round.startedAt);
914
+ };
915
+
916
+ const summarize = (
917
+ messages: Iterable<any>,
918
+ runningActive?: boolean,
919
+ round?: CompactRound,
920
+ ) =>
921
+ buildMessagesSummary(
922
+ messages,
923
+ deps.query,
924
+ runningActive,
925
+ round ? roundWallMs(round) : undefined,
926
+ );
927
+
928
+ const stopRoundTick = (): void => {
929
+ if (!roundTickTimer) return;
930
+ clearInterval(roundTickTimer);
931
+ roundTickTimer = undefined;
932
+ };
933
+
934
+ // 自备 tick:getter 在 render 重算挂钟;兼驱动 Running 扫光(setCompactSummaryActive)。
935
+ const ensureRoundTick = (): void => {
936
+ if (roundTickTimer) return;
937
+ roundTickTimer = setInterval(() => {
938
+ if (!patch.active || !activeRound?.active) {
939
+ stopRoundTick();
940
+ return;
941
+ }
942
+ try {
943
+ // 非 force:保留 fullscreen 布局缓存和差分绘制。
944
+ uiRef?.requestRender?.();
945
+ } catch {
946
+ /* 无 UI */
947
+ }
948
+ }, 250);
949
+ };
950
+
951
+ /** 结束回合活动态;可选立即重绘。 */
952
+ const endRound = (round: CompactRound, render = false): void => {
953
+ round.active = false;
954
+ if (round.endedAt === undefined) round.endedAt = Date.now();
955
+ if (activeRound === round) {
956
+ activeRound = undefined;
957
+ deps.query?.setCompactSummaryActive?.(false);
958
+ stopRoundTick();
959
+ }
960
+ if (render) renderRound(round);
961
+ };
962
+
963
+ const renderAssistantWithoutThinking = (
964
+ component: any,
965
+ message: any,
966
+ isStreaming?: boolean,
967
+ ): any => {
968
+ const content = Array.isArray(message?.content) ? message.content : [];
969
+ const result = passThroughAssistant(
970
+ component,
971
+ {
972
+ ...message,
973
+ content: content.filter((item: any) => item?.type !== "thinking"),
974
+ },
975
+ isStreaming,
976
+ );
977
+ component.lastMessage = message;
978
+ return result;
979
+ };
980
+
981
+ const roundMessages = (round: CompactRound): any[] => [
982
+ ...round.messages.values(),
983
+ ...round.detachedMessages,
984
+ ];
985
+
986
+ const roundToolCallIds = (round: CompactRound): Set<string> => {
987
+ const ids = new Set<string>();
988
+ for (const message of roundMessages(round)) {
989
+ for (const item of Array.isArray(message?.content)
990
+ ? message.content
991
+ : []) {
992
+ if (item?.type === "toolCall" && typeof item.id === "string")
993
+ ids.add(item.id);
994
+ }
995
+ }
996
+ return ids;
997
+ };
998
+
999
+ const renderRound = (round: CompactRound): void => {
1000
+ const messages = roundMessages(round);
1001
+ const stopStatus = roundStopStatus(messages);
1002
+ if (stopStatus) endRound(round);
1003
+ // Running 时每次 render 重算时长(含挂钟下限);结束后固定。
1004
+ const getSummary = () =>
1005
+ summarize(roundMessages(round), round.active, round);
1006
+ const summary = getSummary();
1007
+ for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
1008
+ round.suppressedToolIds.clear();
1009
+
1010
+ for (const [component, message] of round.messages) {
1011
+ component.lastMessage = message;
1012
+ ensureAssistantSetExpanded(component);
1013
+ component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1014
+ for (const member of round.messages.keys()) member.expanded = expanded;
1015
+ if (!expanded) {
1016
+ for (const id of round.suppressedToolIds)
1017
+ expandedRoundToolIds.delete(id);
1018
+ round.suppressedToolIds.clear();
1019
+ }
1020
+ renderRound(round);
1021
+ };
1022
+ }
1023
+
1024
+ if (round.anchor.expanded === true) {
1025
+ const toolsById = new Map<string, any>();
1026
+ for (const tool of trackedToolComponents) {
1027
+ if (typeof tool?.toolCallId === "string")
1028
+ toolsById.set(tool.toolCallId, tool);
1029
+ }
1030
+ const cardItems: Array<{ child?: any; tool?: any }> = [];
1031
+ const placedToolIds = new Set<string>();
1032
+ const takeAssistantRun = (kids: any[], cursor: { i: number }) => {
1033
+ while (cursor.i < kids.length && kids[cursor.i] instanceof Spacer) {
1034
+ cardItems.push({ child: kids[cursor.i++] });
1035
+ }
1036
+ if (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
1037
+ };
1038
+ const placeTool = (id: string, fallbackName?: string) => {
1039
+ const tool = toolsById.get(id);
1040
+ if (
1041
+ !tool ||
1042
+ EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))
1043
+ )
1044
+ return;
1045
+ if (placedToolIds.has(id)) return;
1046
+ placedToolIds.add(id);
1047
+ cardItems.push({ tool });
1048
+ };
1049
+ for (const [component, message] of round.messages) {
1050
+ passThroughAssistant(component, message);
1051
+ const kids = Array.isArray(component.contentContainer?.children)
1052
+ ? [...component.contentContainer.children]
1053
+ : [];
1054
+ component.contentContainer?.clear?.();
1055
+ const cursor = { i: 0 };
1056
+ let inThinkingRun = false;
1057
+ for (const item of Array.isArray(message?.content)
1058
+ ? message.content
1059
+ : []) {
1060
+ if (item?.type === "thinking") {
1061
+ if (!String(item.thinking ?? "").trim()) continue;
1062
+ if (!inThinkingRun) {
1063
+ takeAssistantRun(kids, cursor);
1064
+ inThinkingRun = true;
1065
+ }
1066
+ continue;
1067
+ }
1068
+ inThinkingRun = false;
1069
+ if (item?.type === "text" && String(item.text ?? "").trim()) {
1070
+ takeAssistantRun(kids, cursor);
1071
+ continue;
1072
+ }
1073
+ if (item?.type === "toolCall" && typeof item.id === "string") {
1074
+ placeTool(item.id, item.name);
1075
+ }
1076
+ }
1077
+ while (cursor.i < kids.length)
1078
+ cardItems.push({ child: kids[cursor.i++] });
1079
+ }
1080
+ for (const message of round.detachedMessages) {
1081
+ for (const item of Array.isArray(message?.content)
1082
+ ? message.content
1083
+ : []) {
1084
+ if (item?.type === "toolCall" && typeof item.id === "string") {
1085
+ placeTool(item.id, item.name);
1086
+ }
1087
+ }
1088
+ }
1089
+ const ids = roundToolCallIds(round);
1090
+ for (const id of ids) {
1091
+ round.suppressedToolIds.add(id);
1092
+ expandedRoundToolIds.add(id);
1093
+ if (!placedToolIds.has(id)) placeTool(id);
1094
+ }
1095
+ // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕。
1096
+ for (const tool of trackedToolComponents) {
1097
+ if (
1098
+ !ids.has(tool.toolCallId) ||
1099
+ EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) ||
1100
+ tool.expanded !== true
1101
+ ) {
1102
+ continue;
1103
+ }
1104
+ if (typeof tool.setExpanded === "function") tool.setExpanded(false);
1105
+ else {
1106
+ tool.expanded = false;
1107
+ tool.updateDisplay?.();
1108
+ }
1109
+ }
1110
+ round.anchor.contentContainer.addChild(
1111
+ compactRoundCard(cardItems, (tool, innerWidth) =>
1112
+ patch.toolOriginalRender.call(tool, innerWidth),
1113
+ ),
1114
+ );
1115
+ // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
1116
+ appendStopStatus(round.anchor, stopStatus);
1117
+ return;
1118
+ }
1119
+
1120
+ for (const [component, message] of round.messages) {
1121
+ if (component === round.anchor) {
1122
+ renderAssistantWithoutThinking(component, message);
1123
+ // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
1124
+ if (summary || round.active)
1125
+ compactAssistantLine(component, getSummary, deps.query);
1126
+ // 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
1127
+ appendStopStatus(component, stopStatus);
1128
+ } else {
1129
+ component.contentContainer?.clear?.();
1130
+ }
1131
+ }
1132
+ };
1133
+
1134
+ const activateRound = (round: CompactRound): void => {
1135
+ round.active = true;
1136
+ if (!round.startedAt) round.startedAt = Date.now();
1137
+ delete round.endedAt;
1138
+ activeRound = round;
1139
+ deps.query?.setCompactSummaryActive?.(true);
1140
+ ensureRoundTick();
1141
+ };
1142
+
1143
+ const finishRound = (round: CompactRound): void => endRound(round, true);
1144
+
1145
+ const resetRounds = (): void => {
1146
+ activeRound = undefined;
1147
+ roundByComponent = new WeakMap();
1148
+ expandedRoundToolIds.clear();
1149
+ deps.query?.setCompactSummaryActive?.(false);
1150
+ stopRoundTick();
1151
+ };
1152
+
1153
+ patch.assistantInstalled = function (
1154
+ this: any,
1155
+ message: any,
1156
+ isStreaming?: boolean,
1157
+ ) {
1158
+ const self = this as any;
1159
+ // 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。
1160
+ if (isStreaming !== undefined) self.isStreaming = isStreaming;
1161
+ if (self[ASSISTANT_REENTRY_KEY] === patch) {
1162
+ return patch.assistantNative.call(this, message, isStreaming);
1163
+ }
1164
+ self.lastMessage = message;
1165
+ trackedAssistantComponents.add(this);
1166
+ if (!patch.active || config.mode !== "compact") {
1167
+ return passThroughAssistant(this, message, isStreaming);
1168
+ }
1169
+ if (
1170
+ !self.contentContainer ||
1171
+ typeof self.contentContainer.clear !== "function"
1172
+ ) {
1173
+ return passThroughAssistant(this, message, isStreaming);
1174
+ }
1175
+
1176
+ const content = Array.isArray(message?.content) ? message.content : [];
1177
+ const hasToolCalls = content.some((item: any) => item?.type === "toolCall");
1178
+ const hasText = content.some(
1179
+ (item: any) =>
1180
+ item?.type === "text" &&
1181
+ typeof item.text === "string" &&
1182
+ item.text.trim(),
1183
+ );
1184
+ self.hasToolCalls = hasToolCalls;
1185
+
1186
+ if (hasToolCalls) {
1187
+ let round = roundByComponent.get(this);
1188
+ if (hasText && (!round || round.anchor !== this)) {
1189
+ if (round) round.messages.delete(this);
1190
+ if (activeRound) finishRound(activeRound);
1191
+ round = {
1192
+ anchor: this,
1193
+ messages: new Map(),
1194
+ detachedMessages: [],
1195
+ active: true,
1196
+ suppressedToolIds: new Set(),
1197
+ startedAt: Date.now(),
1198
+ };
1199
+ roundByComponent.set(this, round);
1200
+ activateRound(round);
1201
+ } else if (!round) {
1202
+ round = activeRound ?? {
1203
+ anchor: this,
1204
+ messages: new Map(),
1205
+ detachedMessages: [],
1206
+ active: true,
1207
+ suppressedToolIds: new Set(),
1208
+ startedAt: Date.now(),
1209
+ };
1210
+ roundByComponent.set(this, round);
1211
+ if (!activeRound) activateRound(round);
1212
+ }
1213
+ round.messages.set(this, message);
1214
+ renderRound(round);
1215
+ return undefined;
1216
+ }
1217
+
1218
+ if (hasText) {
1219
+ const round = roundByComponent.get(this);
1220
+ const previousMessage = round?.messages.get(this);
1221
+ if (
1222
+ round &&
1223
+ previousMessage &&
1224
+ (round.anchor !== this || round.messages.size > 1)
1225
+ ) {
1226
+ // 最终回答开始后,当前组件恢复原生文本;它已完成的 thinking
1227
+ // 留在上一轮摘要中,避免再次生成独立 Thought 行。
1228
+ round.messages.delete(this);
1229
+ round.detachedMessages.push(previousMessage);
1230
+ roundByComponent.delete(this);
1231
+ finishRound(round);
1232
+ return renderAssistantWithoutThinking(this, message);
1233
+ }
1234
+ if (round) {
1235
+ endRound(round);
1236
+ roundByComponent.delete(this);
1237
+ return passThroughAssistant(this, message);
1238
+ }
1239
+ if (activeRound) finishRound(activeRound);
1240
+ return renderAssistantWithoutThinking(this, message);
1241
+ }
1242
+
1243
+ const hasThinking = content.some((item: any) => item?.type === "thinking");
1244
+ if (hasThinking) {
1245
+ let round = roundByComponent.get(this);
1246
+ if (!round) {
1247
+ round = activeRound ?? {
1248
+ anchor: this,
1249
+ messages: new Map(),
1250
+ detachedMessages: [],
1251
+ active: true,
1252
+ suppressedToolIds: new Set(),
1253
+ startedAt: Date.now(),
1254
+ };
1255
+ roundByComponent.set(this, round);
1256
+ if (!activeRound) activateRound(round);
1257
+ }
1258
+ round.messages.set(this, message);
1259
+ renderRound(round);
1260
+ return undefined;
1261
+ }
1262
+
1263
+ // 无可见内容的 abort/error/length:直接外层状态行,并结束进行中的回合。
1264
+ const loneStatus = messageStopStatus(message);
1265
+ if (loneStatus) {
1266
+ if (activeRound) finishRound(activeRound);
1267
+ self.contentContainer.clear();
1268
+ appendStopStatus(this, loneStatus);
1269
+ return undefined;
1270
+ }
1271
+
1272
+ self.contentContainer.clear();
1273
+ return undefined;
1274
+ };
1275
+
1276
+ patch.toolInstalledRender = function (this: any, width: number) {
1277
+ if (!patch.active || config.mode !== "compact") {
1278
+ return patch.toolOriginalRender.call(this, width);
1279
+ }
1280
+ const name = String(this.toolName ?? "");
1281
+ if (EDIT_WRITE_TOOLS.has(name)) {
1282
+ if (this.executionStarted && (!this.result || this.isPartial === true))
1283
+ scheduleAnimation(this);
1284
+ return compactEditWriteLines(this, width, deps.writeMetadata);
1285
+ }
1286
+ // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
1287
+ if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
1288
+ // 普通工具折叠时不显示独立行(摘要行已统计),独立展开走原 renderer。
1289
+ if (this.expanded === true) {
1290
+ return patch.toolOriginalRender.call(this, width);
1291
+ }
1292
+ return [];
1293
+ };
1294
+
1295
+ patch.toolInstalledUpdateDisplay = function (this: any) {
1296
+ if (
1297
+ patch.active &&
1298
+ config.mode === "compact" &&
1299
+ expandedRoundToolIds.has(String(this.toolCallId ?? ""))
1300
+ ) {
1301
+ this.expanded = false;
1302
+ }
1303
+ const result = patch.toolOriginalUpdateDisplay.call(this);
1304
+ if (!patch.active) return result;
1305
+ trackedToolComponents.add(this);
1306
+ return result;
1307
+ };
1308
+
1309
+ patch.assertAssistantOwnership = () => {
1310
+ // compact 必须在外层:从 compact-thinking 包装器认领,或收回 mode=on/off
1311
+ // 时主动释放给 original/native 的所有权。未知外部包装器不抢,避免递归。
1312
+ if (
1313
+ !patch.active ||
1314
+ assistantPrototype.updateContent === patch.assistantInstalled
1315
+ )
1316
+ return;
1317
+ const current = assistantPrototype.updateContent;
1318
+ if ((current as any)[COMPACT_THINKING_PATCH_KEY] === true) {
1319
+ patch.assistantOriginal = current;
1320
+ assistantPrototype.updateContent = patch.assistantInstalled;
1321
+ return;
1322
+ }
1323
+ if (
1324
+ current === patch.assistantOriginal ||
1325
+ current === patch.assistantNative
1326
+ ) {
1327
+ assistantPrototype.updateContent = patch.assistantInstalled;
1328
+ }
1329
+ };
1330
+
1331
+ (patch.assistantInstalled as any)[PROTOTYPE_ORIGINAL_KEY] =
1332
+ patch.assistantNative;
1333
+
1334
+ patch.dispose = () => {
1335
+ if (!patch.active) return;
1336
+ patch.active = false;
1337
+ if (assistantPrototype.updateContent === patch.assistantInstalled) {
1338
+ assistantPrototype.updateContent = patch.assistantOriginal;
1339
+ }
1340
+ if (toolPrototype.render === patch.toolInstalledRender) {
1341
+ toolPrototype.render = patch.toolOriginalRender;
1342
+ }
1343
+ if (toolPrototype.updateDisplay === patch.toolInstalledUpdateDisplay) {
1344
+ toolPrototype.updateDisplay = patch.toolOriginalUpdateDisplay;
1345
+ }
1346
+ patchRegistry.dispose(COMPACT_MODE_PATCH_KEY, patch);
1347
+ for (const component of trackedAssistantComponents)
1348
+ detachAssistantExpansion(component);
1349
+ trackedAssistantComponents.clear();
1350
+ trackedToolComponents.clear();
1351
+ hoveredAssistantComponent = undefined;
1352
+ resetRounds();
1353
+ };
1354
+
1355
+ assistantPrototype.updateContent = patch.assistantInstalled;
1356
+ toolPrototype.render = patch.toolInstalledRender;
1357
+ toolPrototype.updateDisplay = patch.toolInstalledUpdateDisplay;
1358
+ patchRegistry.install(COMPACT_MODE_PATCH_KEY, patch);
1359
+
1360
+ const syncGlobalExpanded = (ctx: any): void => {
1361
+ let globalExpanded = false;
1362
+ try {
1363
+ globalExpanded = ctx?.ui?.getToolsExpanded?.() === true;
1364
+ } catch {
1365
+ // 测试或无 UI 上下文时保持折叠。
1366
+ }
1367
+ for (const component of trackedAssistantComponents)
1368
+ component.expanded = globalExpanded;
1369
+ for (const component of trackedToolComponents)
1370
+ component.expanded = globalExpanded;
1371
+ };
1372
+
1373
+ return {
1374
+ sync(ctx: any) {
1375
+ if (!patch.active) return;
1376
+ uiRef = ctx?.ui;
1377
+ resetRounds();
1378
+ // 始终保持补丁在链上:mode=on/off 走 passThrough,仍写入 lastMessage
1379
+ // 与 tracked 集合,这样 /ccstyle 切回 compact 时不必 /reload。
1380
+ patch.assertAssistantOwnership();
1381
+ if (config.mode === "compact") syncGlobalExpanded(ctx);
1382
+ else hoveredAssistantComponent = undefined;
1383
+ refreshTrackedComponents();
1384
+ },
1385
+ refresh() {
1386
+ if (!patch.active) return;
1387
+ resetRounds();
1388
+ patch.assertAssistantOwnership();
1389
+ if (config.mode !== "compact") hoveredAssistantComponent = undefined;
1390
+ refreshTrackedComponents();
1391
+ },
1392
+ assertOwnership() {
1393
+ if (!patch.active) return;
1394
+ patch.assertAssistantOwnership();
1395
+ },
1396
+ refreshToolCallMessage(toolCallId: string | undefined) {
1397
+ if (!patch.active || typeof toolCallId !== "string" || !toolCallId)
1398
+ return;
1399
+ for (const component of [...trackedAssistantComponents]) {
1400
+ const message = component.lastMessage;
1401
+ const contains =
1402
+ Array.isArray(message?.content) &&
1403
+ message.content.some(
1404
+ (item: any) => item?.type === "toolCall" && item.id === toolCallId,
1405
+ );
1406
+ if (!contains) continue;
1407
+ try {
1408
+ component.updateContent?.(message);
1409
+ } catch {
1410
+ trackedAssistantComponents.delete(component);
1411
+ }
1412
+ }
1413
+ },
1414
+ shutdown() {
1415
+ patch.dispose();
1416
+ },
1417
+ };
1418
+ }
1419
+
1420
+ function refreshTrackedComponents(): void {
1421
+ for (const component of [...trackedAssistantComponents]) {
1422
+ try {
1423
+ if (config.mode !== "compact") detachAssistantExpansion(component);
1424
+ refreshTranscriptComponent(component);
1425
+ } catch {
1426
+ trackedAssistantComponents.delete(component);
1427
+ }
1428
+ }
1429
+ for (const component of [...trackedToolComponents]) {
1430
+ try {
1431
+ // 共享实现负责 updateDisplay;invalidate 属于 compact-mode 的跟踪语义。
1432
+ refreshTranscriptComponent(component);
1433
+ component.invalidate?.();
1434
+ } catch {
1435
+ trackedToolComponents.delete(component);
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ /** 供 renderer/index.ts 在 session 事件后收集 /reload 重建的组件。 */
1441
+ export function refreshCompactModeComponents(root: any): void {
1442
+ collectMountedComponents(root);
1443
+ }