pi-cc-extensions 0.8.53 → 0.8.55

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 (42) hide show
  1. package/README.en.md +12 -17
  2. package/README.md +18 -17
  3. package/assets/readme/hero.en.svg +101 -0
  4. package/extensions/config/config.ts +25 -17
  5. package/extensions/config/panel.ts +100 -4
  6. package/extensions/{utils/agent-summary.ts → feature/agent-summary/core.ts} +39 -47
  7. package/extensions/feature/{agent-summary.ts → agent-summary/index.ts} +2 -6
  8. package/extensions/feature/compact-thinking.ts +8 -3
  9. package/extensions/feature/{session-reference.ts → reference/index.ts} +1 -1
  10. package/extensions/feature/{agent-autocomplete.ts → reference/subagent.ts} +0 -1
  11. package/extensions/feature/{pi-startup-header.ts → shell/startup-header.ts} +2 -42
  12. package/extensions/feature/{working-message.ts → shell/working-message.ts} +5 -11
  13. package/extensions/index.ts +28 -38
  14. package/extensions/renderer/compact-mode.ts +394 -101
  15. package/extensions/renderer/default-mode.ts +747 -0
  16. package/extensions/renderer/index.ts +58 -775
  17. package/extensions/renderer/{mouse-hover.ts → mouse/hover.ts} +8 -6
  18. package/extensions/renderer/{mouse-interaction.ts → mouse/interaction.ts} +37 -36
  19. package/extensions/renderer/{mouse-layout.ts → mouse/layout.ts} +5 -21
  20. package/extensions/renderer/{mouse-packets.ts → mouse/packets.ts} +1 -1
  21. package/extensions/renderer/{mouse-scroll.ts → mouse/scroll.ts} +49 -36
  22. package/extensions/renderer/{tool-diff → tool/diff}/diff-renderer.ts +10 -45
  23. package/extensions/renderer/{tool-diff → tool/diff}/line-width-safety.ts +1 -1
  24. package/extensions/renderer/tool/diff/write-display-utils.ts +12 -0
  25. package/extensions/renderer/{tool-grouping.ts → tool/grouping.ts} +20 -16
  26. package/extensions/renderer/{tool-result.ts → tool/result.ts} +35 -27
  27. package/extensions/utils/ansi-color.ts +44 -0
  28. package/extensions/utils/format.ts +21 -0
  29. package/extensions/utils/fullscreen-detect.ts +0 -27
  30. package/package.json +4 -2
  31. package/extensions/renderer/tool-diff/write-display-utils.ts +0 -29
  32. /package/extensions/feature/{session-reference-core.ts → reference/session.ts} +0 -0
  33. /package/extensions/feature/{pi-aliases.ts → shell/aliases.ts} +0 -0
  34. /package/extensions/{feature → renderer}/markdown-enhance.ts +0 -0
  35. /package/extensions/renderer/{tool-diff → tool/diff}/ATTRIBUTION.md +0 -0
  36. /package/extensions/renderer/{tool-diff → tool/diff}/ansi-utils.ts +0 -0
  37. /package/extensions/renderer/{tool-diff → tool/diff}/diff-presentation.ts +0 -0
  38. /package/extensions/renderer/{tool-diff → tool/diff}/index.ts +0 -0
  39. /package/extensions/renderer/{tool-diff → tool/diff}/render-utils.ts +0 -0
  40. /package/extensions/renderer/{tool-diff → tool/diff}/shiki-highlight.ts +0 -0
  41. /package/extensions/renderer/{tool-diff → tool/diff}/types.ts +0 -0
  42. /package/extensions/renderer/{tool-diff → tool/diff}/write-execution.ts +0 -0
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
- * (`Thought for 8s, bash×2, read×2`),edit/write 独立单行(`✓ write <path> (+25 -0)`),
3
+ * (`Ran for 8s, bash×2, read×2`),edit/write 独立单行(`✓ write <path> (+25 -0)`),
4
4
  * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)在单个工具卡中恢复
5
5
  * compact-thinking/Pi 原生或专用 renderer。edit/write 折叠时显示统计,展开时显示 rich diff。
6
+ * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
7
+ * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
6
8
  *
7
- * 统计口径与 utils/agent-summary.ts 对齐:read 按非空路径去重、bash 按调用计数、
8
- * 其他工具按首次出现顺序计数;edit/write 不进入摘要。思考时长复用
9
- * compact-thinking 的 completedDurations/activeThinking 与持久化 entry(只读查询),
10
- * 不建立第二套计时器。最终 agent summary 仍由 feature/agent-summary.ts 独占。
9
+ * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
10
+ * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
11
+ * abort/error/length 状态行挂在摘要外层,避免被折叠吞掉。
12
+ * 最终 agent 回合摘要由 feature/agent-summary 独占(bash/read/edit/write/other)。
11
13
  *
12
14
  * 补丁生命周期遵循仓库既有模式:Symbol 所有权、dispose 仅恢复仍由本安装持有的
13
15
  * 方法、重入守卫防止 /reload 后残留闭包递归。
@@ -17,8 +19,7 @@ import {
17
19
  ToolExecutionComponent,
18
20
  type Theme,
19
21
  } from "@earendil-works/pi-coding-agent";
20
- import * as PiTui from "@earendil-works/pi-tui";
21
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
+ import { truncateToWidth, visibleWidth, Box } from "@earendil-works/pi-tui";
22
23
  import { config, getToolDisplayConfig } from "../config/config.ts";
23
24
  import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
24
25
  import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
@@ -29,15 +30,12 @@ import {
29
30
  countEditDiffStats,
30
31
  countWriteDiffStats,
31
32
  isRichDiffComponent,
32
- } from "./tool-diff/diff-renderer.ts";
33
- import { renderRichToolResult } from "./tool-diff/index.ts";
34
- import type { WriteExecutionMetadataStore } from "./tool-diff/write-execution.ts";
35
- import {
36
- insetComponent,
37
- oneLine,
38
- renderExpandedToolResult,
39
- scheduleAnimation,
40
- } from "./tool-result.ts";
33
+ } from "./tool/diff/diff-renderer.ts";
34
+ import { renderRichToolResult } from "./tool/diff/index.ts";
35
+ import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
36
+ import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
37
+ import { oneLine } from "../utils/format.ts";
38
+ import { paddedBackgroundRow, stripBackgroundAnsi } from "./tool/grouping.ts";
41
39
 
42
40
  /** compact 渲染层对 compact-thinking 的只读查询面(不建第二套计时器)。 */
43
41
  export type CompactThinkingQuery = {
@@ -57,7 +55,6 @@ const ASSISTANT_SET_EXPANDED_KEY = Symbol.for(ASSISTANT_SET_EXPANDED_DESCRIPTION
57
55
  const ASSISTANT_TOGGLE_ROUND_KEY = Symbol.for(ASSISTANT_TOGGLE_ROUND_DESCRIPTION);
58
56
  const ASSISTANT_REENTRY_KEY = Symbol.for(ASSISTANT_REENTRY_DESCRIPTION);
59
57
 
60
- const Box = (PiTui as any).Box;
61
58
  const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
62
59
 
63
60
  type CompactThinkingTheme = Pick<Theme, "fg" | "italic" | "bold">;
@@ -124,26 +121,52 @@ function formatThoughtDuration(durationMs: number) {
124
121
 
125
122
  export { formatThoughtDuration };
126
123
 
124
+ /** assistant stopReason → 外层状态文案(与 Pi 原生口径对齐)。 */
125
+ export function messageStopStatus(message: any): string | undefined {
126
+ const reason = message?.stopReason;
127
+ if (reason === "aborted") {
128
+ return message.errorMessage && message.errorMessage !== "Request was aborted"
129
+ ? String(message.errorMessage)
130
+ : "Operation aborted";
131
+ }
132
+ if (reason === "error") {
133
+ return `Error: ${message.errorMessage || "Unknown error"}`;
134
+ }
135
+ if (reason === "length") {
136
+ return "Response was truncated before completion.";
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ function roundStopStatus(messages: Iterable<any>): string | undefined {
142
+ for (const message of messages) {
143
+ const status = messageStopStatus(message);
144
+ if (status) return status;
145
+ }
146
+ return undefined;
147
+ }
148
+
127
149
  /**
128
150
  * 逐条 assistant message 的摘要文本(无工具计数时可为空串):
129
- * 思考时长在前,工具按消息内首次出现顺序排列;read 按非空路径去重。
151
+ * 时长 = max(thinking query, durationFloorMs 挂钟);工具首次出现顺序;read 路径去重。
130
152
  */
131
153
  function buildMessagesSummary(
132
154
  messages: Iterable<any>,
133
155
  query?: CompactThinkingQuery,
134
- thinkingActiveOverride?: boolean,
156
+ runningActiveOverride?: boolean,
157
+ durationFloorMs?: number,
135
158
  ): string {
136
159
  const parts: string[] = [];
137
160
  const counts = new Map<string, number>();
138
161
  const readPaths = new Set<string>();
139
162
  const durationTimestamps = new Set<number>();
140
163
  let durationMs = 0;
141
- let thinkingActive = false;
164
+ let runningActive = false;
142
165
 
143
166
  for (const message of messages) {
144
167
  if (typeof message?.timestamp === "number" && !durationTimestamps.has(message.timestamp)) {
145
168
  durationTimestamps.add(message.timestamp);
146
- if (query?.isMessageThinkingActive?.(message.timestamp)) thinkingActive = true;
169
+ if (query?.isMessageThinkingActive?.(message.timestamp)) runningActive = true;
147
170
  const value = query?.getMessageThinkingDurationMs(message.timestamp);
148
171
  if (typeof value === "number" && Number.isFinite(value) && value > 0) durationMs += value;
149
172
  }
@@ -165,18 +188,28 @@ function buildMessagesSummary(
165
188
  }
166
189
  }
167
190
 
168
- thinkingActive = thinkingActiveOverride ?? thinkingActive;
169
- if (thinkingActive) {
170
- parts.push(
171
- durationMs > 0 ? `Thinking... · ${formatThoughtDuration(durationMs)}` : "Thinking...",
172
- );
173
- } else if (durationMs > 0) parts.push(`Thought for ${formatThoughtDuration(durationMs)}`);
191
+ if (
192
+ typeof durationFloorMs === "number" &&
193
+ Number.isFinite(durationFloorMs) &&
194
+ durationFloorMs > durationMs
195
+ ) {
196
+ durationMs = durationFloorMs;
197
+ }
198
+
199
+ runningActive = runningActiveOverride ?? runningActive;
200
+ if (runningActive) {
201
+ parts.push(durationMs > 0 ? `Running... · ${formatThoughtDuration(durationMs)}` : "Running...");
202
+ } else if (durationMs > 0) parts.push(`Ran for ${formatThoughtDuration(durationMs)}`);
174
203
  for (const [name, count] of counts) parts.push(`${name}×${count}`);
175
204
  return parts.join(", ");
176
205
  }
177
206
 
178
- export function buildMessageSummary(message: any, query?: CompactThinkingQuery): string {
179
- return buildMessagesSummary([message], query);
207
+ export function buildMessageSummary(
208
+ message: any,
209
+ query?: CompactThinkingQuery,
210
+ durationFloorMs?: number,
211
+ ): string {
212
+ return buildMessagesSummary([message], query, undefined, durationFloorMs);
180
213
  }
181
214
 
182
215
  function fallbackTheme(): any {
@@ -191,7 +224,101 @@ function themeOf(): any {
191
224
  return getMessageDisplayTheme() ?? fallbackTheme();
192
225
  }
193
226
 
194
- function expandedToolCard(theme: any): any {
227
+ /** RGB → HSL(h∈[0,1), l/s∈[0,1])。 */
228
+ function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
229
+ r /= 255;
230
+ g /= 255;
231
+ b /= 255;
232
+ const max = Math.max(r, g, b);
233
+ const min = Math.min(r, g, b);
234
+ const l = (max + min) / 2;
235
+ if (max === min) return [0, 0, l];
236
+ const d = max - min;
237
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
238
+ let h: number;
239
+ switch (max) {
240
+ case r:
241
+ h = (g - b) / d + (g < b ? 6 : 0);
242
+ break;
243
+ case g:
244
+ h = (b - r) / d + 2;
245
+ break;
246
+ default:
247
+ h = (r - g) / d + 4;
248
+ }
249
+ return [h / 6, l, s];
250
+ }
251
+
252
+ /** HSL → RGB(h∈[0,1),l/s∈[0,1])。 */
253
+ function hslToRgb(h: number, l: number, s: number): [number, number, number] {
254
+ if (s === 0) {
255
+ const v = Math.round(l * 255);
256
+ return [v, v, v];
257
+ }
258
+ const hue2rgb = (p: number, q: number, t: number): number => {
259
+ if (t < 0) t += 1;
260
+ if (t > 1) t -= 1;
261
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
262
+ if (t < 1 / 2) return q;
263
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
264
+ return p;
265
+ };
266
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
267
+ const p = 2 * l - q;
268
+ return [
269
+ Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
270
+ Math.round(hue2rgb(p, q, h) * 255),
271
+ Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
272
+ ];
273
+ }
274
+
275
+ /** 内部 tool call card 背景:在 userMessageBg 基础上按 HSL 变暗(l×0.7),
276
+ * 与外卡片形成嵌套层次;非 RGB 色或已足够暗时原样返回。 */
277
+ function darkenBgAnsi(theme: any, slot: string): string {
278
+ const prefix = typeof theme?.getBgAnsi === "function" ? String(theme.getBgAnsi(slot)) : "";
279
+ const m = prefix.match(/48;2;(\d+);(\d+);(\d+)/);
280
+ if (!m) return prefix;
281
+ const [h, l, s] = rgbToHsl(Number(m[1]), Number(m[2]), Number(m[3]));
282
+ if (l <= 0.05) return prefix; // 已足够暗,不再变暗
283
+ const [r, g, b] = hslToRgb(h, l * 0.7, s);
284
+ return `\x1b[48;2;${r};${g};${b}m`;
285
+ }
286
+
287
+ /** 去掉行内所有 CSI/OSC 序列后是否有可见文本(判断工具卡首尾内容行)。 */
288
+ function hasVisibleText(line: string): boolean {
289
+ return (
290
+ line
291
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
292
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
293
+ .trim().length > 0
294
+ );
295
+ }
296
+
297
+ /** 工具卡深色行:左右内缩(左 2 右 3 格,含工具卡自身 padding),背景只到内容区,
298
+ * 修复行尾 reset 截断。 */
299
+ function toolCardBgRow(
300
+ theme: any,
301
+ slot: string,
302
+ bgAnsi: string,
303
+ line: string,
304
+ width: number,
305
+ ): string {
306
+ const leftInset = 2;
307
+ const rightInset = 3;
308
+ const contentWidth = Math.max(0, width - leftInset - rightInset);
309
+ // 深色块内左右各 1 格内部 padding;去掉行首原有空格后重新对齐
310
+ const text = stripBackgroundAnsi(line).replace(/^ +/, "");
311
+ const innerPad = 2;
312
+ const clipped = truncateToWidth(text, Math.max(0, contentWidth - innerPad), "");
313
+ const pad = Math.max(0, contentWidth - innerPad - visibleWidth(clipped));
314
+ const body = ` ${clipped}${" ".repeat(pad)} `;
315
+ const stable = body.replace(/\x1b\[(?:0)?m/g, (reset) => reset + bgAnsi);
316
+ const outerBg = typeof theme?.getBgAnsi === "function" ? String(theme.getBgAnsi(slot)) : "";
317
+ const inset = (n) => (outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n));
318
+ return `${inset(leftInset)}${bgAnsi}${stable}\x1b[49m${inset(rightInset)}`;
319
+ }
320
+ /** edit/write 展开卡:保持原样式(userMessageBg Box),不应用工具卡深色/间距改动。 */
321
+ function editWriteExpandedCard(theme: any): any {
195
322
  return new Box(
196
323
  1,
197
324
  1,
@@ -199,6 +326,73 @@ function expandedToolCard(theme: any): any {
199
326
  );
200
327
  }
201
328
 
329
+ /** compact 展开卡:外卡片保持 userMessageBg 原色;内部 tool call card(addToolChild)
330
+ * 背景更深一层且只覆盖内容区(左右内缩、上下限首尾文本行),形成嵌套层次。
331
+ * 逐行重建背景同时修复嵌套 Box 的右侧截断。 */
332
+ function expandedToolCard(theme: any): any {
333
+ const slot = "userMessageBg";
334
+ const toolBgAnsi = darkenBgAnsi(theme, slot);
335
+ const children: any[] = [];
336
+ return {
337
+ children,
338
+ addChild(child: any) {
339
+ children.push(child);
340
+ },
341
+ addToolChild(child: any) {
342
+ child.__ccToolCard = true;
343
+ children.push(child);
344
+ },
345
+ render(width: number): string[] {
346
+ const innerWidth = Math.max(0, width - 2);
347
+ const lines: string[] = [];
348
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
349
+ // 展开不再显示摘要行:跳过第一个 child 的首行空白,避免白占一行
350
+ let skipLeadingBlank = true;
351
+ for (const child of children) {
352
+ const childLines = child.render(innerWidth);
353
+ if (!child.__ccToolCard) {
354
+ let start = 0;
355
+ if (skipLeadingBlank) {
356
+ while (start < childLines.length && !hasVisibleText(childLines[start])) start++;
357
+ skipLeadingBlank = false;
358
+ }
359
+ for (let i = start; i < childLines.length; i++) {
360
+ lines.push(paddedBackgroundRow(theme, slot, childLines[i], width));
361
+ }
362
+ continue;
363
+ }
364
+ // 工具卡:前导 1 空行 + 首尾文本行之间的内容区(深色);不保留尾随,
365
+ // 相邻工具卡之间正好 1 空行,底部由卡片 padding 收尾。
366
+ skipLeadingBlank = false;
367
+ let first = -1;
368
+ let last = -1;
369
+ for (let i = 0; i < childLines.length; i++) {
370
+ if (hasVisibleText(childLines[i])) {
371
+ if (first < 0) first = i;
372
+ last = i;
373
+ }
374
+ }
375
+ if (first < 0) {
376
+ for (const line of childLines) {
377
+ lines.push(paddedBackgroundRow(theme, slot, line, width));
378
+ }
379
+ continue;
380
+ }
381
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
382
+ // 子卡片内部上下各 1 行深色 padding
383
+ lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
384
+ for (let i = first; i <= last; i++) {
385
+ lines.push(toolCardBgRow(theme, slot, toolBgAnsi, childLines[i], width));
386
+ }
387
+ lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
388
+ }
389
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
390
+ return lines;
391
+ },
392
+ invalidate() {},
393
+ };
394
+ }
395
+
202
396
  function isAssistantComponent(value: any): boolean {
203
397
  return value instanceof AssistantMessageComponent;
204
398
  }
@@ -249,7 +443,7 @@ export type CompactModeHooks = {
249
443
  refresh(): void;
250
444
  /** compact 模式下重新认领 assistant patch(位于 compact-thinking 之上)。 */
251
445
  assertOwnership(): void;
252
- /** 重新渲染包含指定 toolCallId 的 assistant 消息(思考收尾时刷新时长)。 */
446
+ /** 重新渲染包含指定 toolCallId 的 assistant 消息(思考收尾时刷新)。 */
253
447
  refreshToolCallMessage(toolCallId: string | undefined): void;
254
448
  shutdown(): void;
255
449
  };
@@ -383,7 +577,7 @@ function compactEditWriteExpandedLines(
383
577
  component.resultRendererComponent = detail;
384
578
  }
385
579
 
386
- const box = expandedToolCard(theme);
580
+ const box = editWriteExpandedCard(theme);
387
581
  box.addChild({
388
582
  render(innerWidth: number): string[] {
389
583
  return compactEditWriteLine(component, innerWidth, writeMetadata).slice(1);
@@ -396,7 +590,8 @@ function compactEditWriteExpandedLines(
396
590
 
397
591
  function compactAssistantLineComponent(
398
592
  component: any,
399
- summary: string,
593
+ /** 静态串或每次 render 重算(Running 时长需逐步跳动)。 */
594
+ summary: string | (() => string),
400
595
  query?: CompactThinkingQuery,
401
596
  options: { hint?: boolean; leadingBlank?: boolean; pad?: number } = {},
402
597
  ): any {
@@ -408,17 +603,15 @@ function compactAssistantLineComponent(
408
603
  const available = Math.max(0, width - pad);
409
604
  const hintText = options.hint === false ? "" : ` • ${showMoreHintText()}`;
410
605
  const summaryWidth = Math.max(0, available - visibleWidth(hintText));
411
- const thinkingActive = summary.startsWith("Thinking...");
412
- const displaySummary = thinkingActive
413
- ? `${self.hiddenThinkingLabel || "Thinking..."}${summary.slice("Thinking...".length)}`
414
- : summary;
415
- const plainText = truncateToWidth(displaySummary, summaryWidth, "…");
606
+ const resolved = typeof summary === "function" ? summary() : summary;
607
+ const runningActive = resolved.startsWith("Running...");
608
+ const plainText = truncateToWidth(resolved, summaryWidth, "…");
416
609
  let text = theme.fg("muted", plainText);
417
- if (thinkingActive || plainText.startsWith("Thought for ")) {
610
+ if (runningActive || plainText.startsWith("Ran for ")) {
418
611
  const separator = plainText.indexOf(", ");
419
612
  const heading = separator < 0 ? plainText : plainText.slice(0, separator);
420
613
  const tools = separator < 0 ? "" : plainText.slice(separator);
421
- if (thinkingActive) {
614
+ if (runningActive) {
422
615
  const durationSeparator = heading.indexOf(" · ");
423
616
  const label = durationSeparator < 0 ? heading : heading.slice(0, durationSeparator);
424
617
  const duration = durationSeparator < 0 ? "" : heading.slice(durationSeparator);
@@ -440,11 +633,35 @@ function compactAssistantLineComponent(
440
633
  };
441
634
  }
442
635
 
443
- function compactAssistantLine(component: any, summary: string, query?: CompactThinkingQuery): void {
444
- if (!summary) return;
636
+ function compactStopStatusLine(status: string, pad = 0): any {
637
+ return {
638
+ render(width: number): string[] {
639
+ const theme = themeOf();
640
+ const prefix = " ".repeat(Math.max(0, pad));
641
+ const line = `${prefix}${theme.fg("error", status)}`;
642
+ return ["", truncateToWidth(line, width, "")];
643
+ },
644
+ invalidate() {},
645
+ };
646
+ }
647
+
648
+ function compactAssistantLine(
649
+ component: any,
650
+ summary: string | (() => string),
651
+ query?: CompactThinkingQuery,
652
+ ): void {
653
+ // 空静态串跳过;getter 留给 render 判断(Running 可能稍后才有时长)。
654
+ if (typeof summary === "string" && !summary) return;
445
655
  component.contentContainer.addChild(compactAssistantLineComponent(component, summary, query));
446
656
  }
447
657
 
658
+ function appendStopStatus(component: any, status: string | undefined): void {
659
+ if (!status || !component?.contentContainer?.addChild) return;
660
+ component.contentContainer.addChild(
661
+ compactStopStatusLine(status, Number(component.outputPad) || 0),
662
+ );
663
+ }
664
+
448
665
  function ensureAssistantSetExpanded(component: any): void {
449
666
  if (
450
667
  typeof component[ASSISTANT_SET_EXPANDED_KEY] === "function" &&
@@ -508,10 +725,17 @@ function collectMountedComponents(root: any): void {
508
725
  }
509
726
  };
510
727
  visit(root);
511
- trackedAssistantComponents.clear();
512
- trackedToolComponents.clear();
513
- for (const component of assistants) trackedAssistantComponents.add(component);
514
- for (const component of tools) trackedToolComponents.add(component);
728
+ // 扫到组件才替换跟踪表。面板/custom UI 打开时 root 往往扫不到 transcript,
729
+ // 若此时清空会丢掉 live updateContent/updateDisplay 已登记的实例,
730
+ // /ccstyle 切换就只能靠 /reload 重建。
731
+ if (assistants.size > 0 || tools.size > 0) {
732
+ trackedAssistantComponents.clear();
733
+ trackedToolComponents.clear();
734
+ for (const component of assistants) trackedAssistantComponents.add(component);
735
+ for (const component of tools) trackedToolComponents.add(component);
736
+ } else if (config.mode === "compact") {
737
+ for (const component of trackedAssistantComponents) ensureAssistantSetExpanded(component);
738
+ }
515
739
  }
516
740
 
517
741
  export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHooks {
@@ -535,13 +759,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
535
759
  dispose: () => {},
536
760
  };
537
761
 
538
- const passThroughAssistant = (component: any, message: any): any => {
762
+ const passThroughAssistant = (component: any, message: any, isStreaming?: boolean): any => {
539
763
  const self = component as any;
540
764
  if (self[ASSISTANT_REENTRY_KEY] === patch)
541
- return patch.assistantNative.call(component, message);
765
+ return patch.assistantNative.call(component, message, isStreaming);
542
766
  self[ASSISTANT_REENTRY_KEY] = patch;
543
767
  try {
544
- return patch.assistantOriginal.call(component, message);
768
+ return patch.assistantOriginal.call(component, message, isStreaming);
545
769
  } finally {
546
770
  delete self[ASSISTANT_REENTRY_KEY];
547
771
  }
@@ -553,17 +777,77 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
553
777
  detachedMessages: any[];
554
778
  active: boolean;
555
779
  suppressedToolIds: Set<string>;
780
+ /** 回合挂钟起点,保证 Running 时长连续递增。 */
781
+ startedAt: number;
782
+ endedAt?: number;
556
783
  };
557
784
  let activeRound: CompactRound | undefined;
558
785
  let roundByComponent = new WeakMap<object, CompactRound>();
559
786
  const expandedRoundToolIds = new Set<string>();
787
+ let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
788
+ let roundTickTimer: ReturnType<typeof setInterval> | undefined;
560
789
 
561
- const renderAssistantWithoutThinking = (component: any, message: any): any => {
790
+ const roundWallMs = (round: CompactRound): number => {
791
+ const end = round.active ? Date.now() : (round.endedAt ?? Date.now());
792
+ return Math.max(1, end - round.startedAt);
793
+ };
794
+
795
+ const summarize = (messages: Iterable<any>, runningActive?: boolean, round?: CompactRound) =>
796
+ buildMessagesSummary(
797
+ messages,
798
+ deps.query,
799
+ runningActive,
800
+ round ? roundWallMs(round) : undefined,
801
+ );
802
+
803
+ const stopRoundTick = (): void => {
804
+ if (!roundTickTimer) return;
805
+ clearInterval(roundTickTimer);
806
+ roundTickTimer = undefined;
807
+ };
808
+
809
+ // 自备 tick:getter 在 render 重算挂钟;兼驱动 Running 扫光(setCompactSummaryActive)。
810
+ const ensureRoundTick = (): void => {
811
+ if (roundTickTimer) return;
812
+ roundTickTimer = setInterval(() => {
813
+ if (!patch.active || !activeRound?.active) {
814
+ stopRoundTick();
815
+ return;
816
+ }
817
+ try {
818
+ uiRef?.requestRender?.(true);
819
+ } catch {
820
+ /* 无 UI */
821
+ }
822
+ }, 250);
823
+ };
824
+
825
+ /** 结束回合活动态;可选立即重绘。 */
826
+ const endRound = (round: CompactRound, render = false): void => {
827
+ round.active = false;
828
+ if (round.endedAt === undefined) round.endedAt = Date.now();
829
+ if (activeRound === round) {
830
+ activeRound = undefined;
831
+ deps.query?.setCompactSummaryActive?.(false);
832
+ stopRoundTick();
833
+ }
834
+ if (render) renderRound(round);
835
+ };
836
+
837
+ const renderAssistantWithoutThinking = (
838
+ component: any,
839
+ message: any,
840
+ isStreaming?: boolean,
841
+ ): any => {
562
842
  const content = Array.isArray(message?.content) ? message.content : [];
563
- const result = passThroughAssistant(component, {
564
- ...message,
565
- content: content.filter((item: any) => item?.type !== "thinking"),
566
- });
843
+ const result = passThroughAssistant(
844
+ component,
845
+ {
846
+ ...message,
847
+ content: content.filter((item: any) => item?.type !== "thinking"),
848
+ },
849
+ isStreaming,
850
+ );
567
851
  component.lastMessage = message;
568
852
  return result;
569
853
  };
@@ -584,7 +868,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
584
868
  };
585
869
 
586
870
  const renderRound = (round: CompactRound): void => {
587
- const summary = buildMessagesSummary(roundMessages(round), deps.query, round.active);
871
+ const messages = roundMessages(round);
872
+ const stopStatus = roundStopStatus(messages);
873
+ if (stopStatus) endRound(round);
874
+ // Running 时每次 render 重算时长(含挂钟下限);结束后固定。
875
+ const getSummary = () => summarize(roundMessages(round), round.active, round);
876
+ const summary = getSummary();
588
877
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
589
878
  round.suppressedToolIds.clear();
590
879
 
@@ -634,20 +923,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
634
923
  render(width: number): string[] {
635
924
  const theme = themeOf();
636
925
  const box = expandedToolCard(theme);
637
- box.addChild(
638
- compactAssistantLineComponent(round.anchor, summary, deps.query, {
639
- hint: false,
640
- leadingBlank: false,
641
- pad: 0,
642
- }),
643
- );
644
926
  for (const child of assistantChildren) box.addChild(child);
645
927
  const ids = roundToolCallIds(round);
646
928
  for (const tool of trackedToolComponents) {
647
929
  if (!ids.has(tool.toolCallId) || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? ""))) {
648
930
  continue;
649
931
  }
650
- box.addChild({
932
+ box.addToolChild({
651
933
  render: (innerWidth: number) => patch.toolOriginalRender.call(tool, innerWidth),
652
934
  invalidate: () => tool.invalidate?.(),
653
935
  });
@@ -658,13 +940,18 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
658
940
  for (const child of assistantChildren) child.invalidate?.();
659
941
  },
660
942
  });
943
+ // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
944
+ appendStopStatus(round.anchor, stopStatus);
661
945
  return;
662
946
  }
663
947
 
664
948
  for (const [component, message] of round.messages) {
665
949
  if (component === round.anchor) {
666
950
  renderAssistantWithoutThinking(component, message);
667
- compactAssistantLine(component, summary, deps.query);
951
+ // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
952
+ if (summary || round.active) compactAssistantLine(component, getSummary, deps.query);
953
+ // 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
954
+ appendStopStatus(component, stopStatus);
668
955
  } else {
669
956
  component.contentContainer?.clear?.();
670
957
  }
@@ -673,38 +960,37 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
673
960
 
674
961
  const activateRound = (round: CompactRound): void => {
675
962
  round.active = true;
963
+ if (!round.startedAt) round.startedAt = Date.now();
964
+ delete round.endedAt;
676
965
  activeRound = round;
677
966
  deps.query?.setCompactSummaryActive?.(true);
967
+ ensureRoundTick();
678
968
  };
679
969
 
680
- const finishRound = (round: CompactRound): void => {
681
- round.active = false;
682
- renderRound(round);
683
- if (activeRound === round) {
684
- activeRound = undefined;
685
- deps.query?.setCompactSummaryActive?.(false);
686
- }
687
- };
970
+ const finishRound = (round: CompactRound): void => endRound(round, true);
688
971
 
689
972
  const resetRounds = (): void => {
690
973
  activeRound = undefined;
691
974
  roundByComponent = new WeakMap();
692
975
  expandedRoundToolIds.clear();
693
976
  deps.query?.setCompactSummaryActive?.(false);
977
+ stopRoundTick();
694
978
  };
695
979
 
696
- patch.assistantInstalled = function (this: any, message: any) {
980
+ patch.assistantInstalled = function (this: any, message: any, isStreaming?: boolean) {
697
981
  const self = this as any;
982
+ // 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。
983
+ if (isStreaming !== undefined) self.isStreaming = isStreaming;
698
984
  if (self[ASSISTANT_REENTRY_KEY] === patch) {
699
- return patch.assistantNative.call(this, message);
985
+ return patch.assistantNative.call(this, message, isStreaming);
700
986
  }
701
987
  self.lastMessage = message;
702
988
  trackedAssistantComponents.add(this);
703
989
  if (!patch.active || config.mode !== "compact") {
704
- return passThroughAssistant(this, message);
990
+ return passThroughAssistant(this, message, isStreaming);
705
991
  }
706
992
  if (!self.contentContainer || typeof self.contentContainer.clear !== "function") {
707
- return passThroughAssistant(this, message);
993
+ return passThroughAssistant(this, message, isStreaming);
708
994
  }
709
995
 
710
996
  const content = Array.isArray(message?.content) ? message.content : [];
@@ -725,6 +1011,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
725
1011
  detachedMessages: [],
726
1012
  active: true,
727
1013
  suppressedToolIds: new Set(),
1014
+ startedAt: Date.now(),
728
1015
  };
729
1016
  roundByComponent.set(this, round);
730
1017
  activateRound(round);
@@ -735,6 +1022,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
735
1022
  detachedMessages: [],
736
1023
  active: true,
737
1024
  suppressedToolIds: new Set(),
1025
+ startedAt: Date.now(),
738
1026
  };
739
1027
  roundByComponent.set(this, round);
740
1028
  if (!activeRound) activateRound(round);
@@ -757,12 +1045,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
757
1045
  return renderAssistantWithoutThinking(this, message);
758
1046
  }
759
1047
  if (round) {
760
- round.active = false;
1048
+ endRound(round);
761
1049
  roundByComponent.delete(this);
762
- if (activeRound === round) {
763
- activeRound = undefined;
764
- deps.query?.setCompactSummaryActive?.(false);
765
- }
766
1050
  return passThroughAssistant(this, message);
767
1051
  }
768
1052
  if (activeRound) finishRound(activeRound);
@@ -779,6 +1063,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
779
1063
  detachedMessages: [],
780
1064
  active: true,
781
1065
  suppressedToolIds: new Set(),
1066
+ startedAt: Date.now(),
782
1067
  };
783
1068
  roundByComponent.set(this, round);
784
1069
  if (!activeRound) activateRound(round);
@@ -788,6 +1073,15 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
788
1073
  return undefined;
789
1074
  }
790
1075
 
1076
+ // 无可见内容的 abort/error/length:直接外层状态行,并结束进行中的回合。
1077
+ const loneStatus = messageStopStatus(message);
1078
+ if (loneStatus) {
1079
+ if (activeRound) finishRound(activeRound);
1080
+ self.contentContainer.clear();
1081
+ appendStopStatus(this, loneStatus);
1082
+ return undefined;
1083
+ }
1084
+
791
1085
  self.contentContainer.clear();
792
1086
  return undefined;
793
1087
  };
@@ -803,6 +1097,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
803
1097
  if (this.expanded !== true) return lines;
804
1098
  return compactEditWriteExpandedLines(this, width, deps.writeMetadata);
805
1099
  }
1100
+ // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
806
1101
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
807
1102
  // 普通工具折叠时不显示独立行(摘要行已统计),独立展开走原 renderer。
808
1103
  if (this.expanded === true) {
@@ -826,12 +1121,18 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
826
1121
  };
827
1122
 
828
1123
  patch.assertAssistantOwnership = () => {
829
- // 仅从已标记的 compact-thinking 包装器重新认领。未知外部包装器必须保留所有权。
1124
+ // compact 必须在外层:从 compact-thinking 包装器认领,或收回 mode=on/off
1125
+ // 时主动释放给 original/native 的所有权。未知外部包装器不抢,避免递归。
830
1126
  if (!patch.active || assistantPrototype.updateContent === patch.assistantInstalled) return;
831
1127
  const current = assistantPrototype.updateContent;
832
- if ((current as any)[COMPACT_THINKING_PATCH_KEY] !== true) return;
833
- patch.assistantOriginal = current;
834
- assistantPrototype.updateContent = patch.assistantInstalled;
1128
+ if ((current as any)[COMPACT_THINKING_PATCH_KEY] === true) {
1129
+ patch.assistantOriginal = current;
1130
+ assistantPrototype.updateContent = patch.assistantInstalled;
1131
+ return;
1132
+ }
1133
+ if (current === patch.assistantOriginal || current === patch.assistantNative) {
1134
+ assistantPrototype.updateContent = patch.assistantInstalled;
1135
+ }
835
1136
  };
836
1137
 
837
1138
  (patch.assistantInstalled as any)[PROTOTYPE_ORIGINAL_KEY] = patch.assistantNative;
@@ -872,31 +1173,23 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
872
1173
  for (const component of trackedToolComponents) component.expanded = globalExpanded;
873
1174
  };
874
1175
 
875
- const releaseAssistantOwnership = (): void => {
876
- if (assistantPrototype.updateContent === patch.assistantInstalled) {
877
- assistantPrototype.updateContent = patch.assistantOriginal;
878
- }
879
- };
880
-
881
1176
  return {
882
1177
  sync(ctx: any) {
883
1178
  if (!patch.active) return;
1179
+ uiRef = ctx?.ui;
884
1180
  resetRounds();
885
- if (config.mode === "compact") {
886
- patch.assertAssistantOwnership();
887
- syncGlobalExpanded(ctx);
888
- } else {
889
- releaseAssistantOwnership();
890
- }
1181
+ // 始终保持补丁在链上:mode=on/off 走 passThrough,仍写入 lastMessage
1182
+ // 与 tracked 集合,这样 /ccstyle 切回 compact 时不必 /reload。
1183
+ patch.assertAssistantOwnership();
1184
+ if (config.mode === "compact") syncGlobalExpanded(ctx);
1185
+ else hoveredAssistantComponent = undefined;
891
1186
  refreshTrackedComponents();
892
1187
  },
893
1188
  refresh() {
894
1189
  if (!patch.active) return;
895
1190
  resetRounds();
896
- if (config.mode !== "compact") {
897
- releaseAssistantOwnership();
898
- hoveredAssistantComponent = undefined;
899
- }
1191
+ patch.assertAssistantOwnership();
1192
+ if (config.mode !== "compact") hoveredAssistantComponent = undefined;
900
1193
  refreshTrackedComponents();
901
1194
  },
902
1195
  assertOwnership() {