pi-cc-extensions 0.9.5 → 0.9.7

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.
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
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
4
+ * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)时助手文本按原生渲染,
5
+ * thinking 与工具卡装进 userMessageBg 面板(卡内恢复 compact-thinking/Pi 原生或专用
6
+ * renderer)。edit/write 复用 mode=on 的 rich diff
6
7
  * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
7
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
8
9
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
9
10
  *
11
+ * live 围观态(回合进行中 / 收尾静默期):正文与摘要行跟折叠态同形,只在摘要行下面多一张
12
+ * 单一槽位卡(`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)。
13
+ * 思考原地增长不轮换,只有新的思考块或工具调用进来才换槽位内容。
14
+ * 收尾时摘要行原地从 Running... 翻成 Ran for、槽位卡消失,正文与摘要行都不换位置。
15
+ *
10
16
  * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
11
17
  * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
12
18
  * abort/error/length 状态行挂在摘要外层,避免被折叠吞掉。
@@ -35,10 +41,16 @@ import {
35
41
  import { renderRichToolResult } from "./tool/diff/index.ts";
36
42
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
37
43
  import { isToolCallHovered } from "./mouse/hover.ts";
44
+ import { getToolMouseTui } from "./mouse/scroll.ts";
38
45
  import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
39
46
  import { paddedBackgroundRow } from "./tool/grouping.ts";
40
47
  import { formatDisplayPath } from "./tool/names.ts";
41
- import { hasVisibleText, stripBackgroundAnsi } from "../utils/ansi-text.ts";
48
+ import {
49
+ hasVisibleText,
50
+ removeStyledRange,
51
+ stripBackgroundAnsi,
52
+ stripTerminalSequencesPreservingLayout,
53
+ } from "../utils/ansi-text.ts";
42
54
  import { walkComponentTree } from "../utils/component-tree.ts";
43
55
  import {
44
56
  ASSISTANT_REENTRY_KEY,
@@ -50,6 +62,11 @@ import {
50
62
  PROTOTYPE_ORIGINAL_KEY,
51
63
  } from "../utils/patch-keys.ts";
52
64
 
65
+ /** Pi 的 native 组件与扩展可能各持一份 pi-tui,Spacer 不能只靠 instanceof 判。 */
66
+ function isSpacerComponent(value: any): boolean {
67
+ return value instanceof Spacer || value?.constructor?.name === "Spacer";
68
+ }
69
+
53
70
  /** compact 渲染层对 compact-thinking 的只读查询面(不建第二套计时器)。 */
54
71
  export type CompactThinkingQuery = {
55
72
  getMessageThinkingDurationMs(messageTimestamp: number): number | undefined;
@@ -60,6 +77,12 @@ export type CompactThinkingQuery = {
60
77
 
61
78
  const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
62
79
 
80
+ /**
81
+ * 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
82
+ * 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
83
+ */
84
+ const FOLD_SETTLE_MS = 250;
85
+
63
86
  type CompactThinkingTheme = Pick<Theme, "fg" | "italic" | "bold">;
64
87
 
65
88
  /** 与 compact-thinking 主渲染器共用的静态文字样式。 */
@@ -320,46 +343,66 @@ function editWriteExpandedCard(theme: any): any {
320
343
  );
321
344
  }
322
345
 
323
- /** compact 展开卡:外卡片保持 userMessageBg 原色;内部 tool call card
324
- * 背景更深一层且只覆盖内容区(左右内缩、上下限首尾文本行),形成嵌套层次。
325
- * hits:非工具子卡(thinking)的行区间,供展开后点击 hint。 */
346
+ /** compact 展开面板:外卡片保持 userMessageBg;展开的 thinking 另包一层更深的
347
+ * 内卡(自身 Box 背景与外卡同色,不区分会糊在一起),工具卡直接铺在外卡背景上。
348
+ * 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
349
+ * 避免与外卡/内卡 padding 叠出双空行。
350
+ * hits:子卡的行区间与命中目标(thinking 命中自身,工具命中 tool 组件),
351
+ * 供展开后点击 hint。未开启 toolHits 时不映射工具,点击仍归外层面板。
352
+ * live=true:live 槽位用,不铺底色也不留上下空行,首行紧跟 `↳`、其余行缩进对齐
353
+ * (Claude Code 的 ⎿ 口径)。行的产出仍是一行对一行,hits 行号无需平移。
354
+ */
326
355
  function layoutExpandedToolCard(
327
356
  theme: any,
328
357
  children: any[],
329
358
  width: number,
330
359
  paints?: string[][],
360
+ toolHits = false,
361
+ live = false,
331
362
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
332
363
  const slot = "userMessageBg";
333
364
  const toolBgAnsi = darkenBgAnsi(theme, slot);
334
- const innerWidth = Math.max(0, width - 2);
365
+ /** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
366
+ const markerWidth = live ? 2 : 0;
367
+ const innerWidth = Math.max(0, width - 2 - markerWidth);
335
368
  const lines: string[] = [];
336
369
  const hits: Array<{ child: any; start: number; end: number }> = [];
337
370
  const isThinkingPreview = (child: any) => typeof child?.setHintHovered === "function";
338
- lines.push(paddedBackgroundRow(theme, slot, "", width));
339
- // 展开不再显示摘要行:跳过第一个 child 的首行空白,避免白占一行
340
- let skipLeadingBlank = true;
371
+ let lastBlank = false;
372
+ /** live 首行被吃掉的前导缩进:续行同步左移同样格数,保持块内相对对齐。 */
373
+ let liveLead = 0;
374
+ const pushRow = (raw: string) => {
375
+ if (!live) {
376
+ lines.push(paddedBackgroundRow(theme, slot, raw, width));
377
+ return;
378
+ }
379
+ // 槽位里不重复展开入口(见 SLOT_HINT_PATTERNS)。
380
+ const line = stripSlotHint(raw);
381
+ // 工具卡行首带 SGR:ANSI 之后才是缩进空格。
382
+ const head = line.match(/^((?:\x1b\[[0-9;]*m)*)( *)/);
383
+ const ansi = head?.[1] ?? "";
384
+ const spaces = head?.[2]?.length ?? 0;
385
+ const first = lines.length === 0;
386
+ if (first) liveLead = spaces;
387
+ // 首行挂 muted 的 `↳`(与 default-mode 工具结果行同色);续行按 liveLead 去缩进。
388
+ // 两侧都保留行首 SGR:丢掉它整行会掉回终端默认前景色。
389
+ const marker = first ? `${theme.fg("muted", "↳")} ` : " ";
390
+ const lead = first ? spaces : Math.min(spaces, liveLead);
391
+ lines.push(
392
+ `${marker}${ansi}${truncateToWidth(line.slice(ansi.length + lead), innerWidth, "")}`,
393
+ );
394
+ };
395
+ const pushBlank = () => {
396
+ if (live || lastBlank) return;
397
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
398
+ lastBlank = true;
399
+ };
341
400
  for (let childIndex = 0; childIndex < children.length; childIndex++) {
342
401
  const child = children[childIndex];
343
402
  const painted = paints?.[childIndex];
344
403
  const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
345
- // 展开的 thinking 与 tool 一样走嵌套深色卡,避免贴在外卡同色底上看不见。
346
- const nest = child.__ccToolCard || (isThinkingPreview(child) && child.expanded === true);
347
- if (!nest) {
348
- let start = 0;
349
- if (skipLeadingBlank) {
350
- while (start < childLines.length && !hasVisibleText(childLines[start])) start++;
351
- skipLeadingBlank = false;
352
- }
353
- const rangeStart = lines.length;
354
- for (let i = start; i < childLines.length; i++) {
355
- lines.push(paddedBackgroundRow(theme, slot, childLines[i], width));
356
- }
357
- if (lines.length > rangeStart) hits.push({ child, start: rangeStart, end: lines.length });
358
- continue;
359
- }
360
- // 工具卡:前导 1 空行 + 首尾文本行之间的内容区(深色);不保留尾随,
361
- // 相邻工具卡之间正好 1 空行,底部由卡片 padding 收尾。
362
- skipLeadingBlank = false;
404
+ // live 不留空行、也不叠深色内卡:槽位没有边框。
405
+ const innerCard = !live && isThinkingPreview(child) && child.expanded === true;
363
406
  let first = -1;
364
407
  let last = -1;
365
408
  for (let i = 0; i < childLines.length; i++) {
@@ -369,28 +412,41 @@ function layoutExpandedToolCard(
369
412
  }
370
413
  }
371
414
  if (first < 0) {
415
+ if (live) continue;
372
416
  for (const line of childLines) {
373
417
  lines.push(paddedBackgroundRow(theme, slot, line, width));
418
+ lastBlank = !hasVisibleText(line);
374
419
  }
375
420
  continue;
376
421
  }
377
- lines.push(paddedBackgroundRow(theme, slot, "", width));
422
+ if (!innerCard) pushBlank();
378
423
  const rangeStart = lines.length;
379
- // 子卡片内部上下各 1 行深色 padding
380
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
381
- for (let i = first; i <= last; i++) {
382
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, childLines[i], width));
383
- }
384
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
424
+ // 展开的 thinking 走深色内卡(上下各 1 行 padding),工具卡直接铺在外卡上。
425
+ const paintRow = innerCard
426
+ ? (line: string) => lines.push(toolCardBgRow(theme, slot, toolBgAnsi, line, width))
427
+ : pushRow;
428
+ if (innerCard) paintRow("");
429
+ for (let i = first; i <= last; i++) paintRow(childLines[i]);
430
+ if (innerCard) paintRow("");
431
+ // 内卡的下 padding 不能当分隔:后面的子卡要再留 1 行外卡空行。
432
+ lastBlank = false;
433
+ // thinking 命中自身;工具在展开卡内需要能二次展开时才映射回 tool 组件。
385
434
  if (isThinkingPreview(child)) hits.push({ child, start: rangeStart, end: lines.length });
435
+ else if (toolHits && child.__ccTool) {
436
+ hits.push({ child: child.__ccTool, start: rangeStart, end: lines.length });
437
+ }
386
438
  }
387
- lines.push(paddedBackgroundRow(theme, slot, "", width));
439
+ pushBlank();
388
440
  return { lines, hits };
389
441
  }
390
442
 
391
443
  function compactRoundCard(
392
444
  cardItems: Array<{ child?: any; tool?: any }>,
393
445
  toolRender: (tool: any, width: number) => string[],
446
+ /** true 时卡内工具行归 tool 组件,可点击二次展开(整回合展开卡)。 */
447
+ toolHits = false,
448
+ /** true 时走 live 槽位排布:不留空行、首行紧跟 `↳`。 */
449
+ live = false,
394
450
  ): any {
395
451
  const children: any[] = [];
396
452
  for (const item of cardItems) {
@@ -399,11 +455,14 @@ function compactRoundCard(
399
455
  const tool = item.tool;
400
456
  children.push({
401
457
  __ccToolCard: true,
458
+ __ccTool: tool,
402
459
  render: (innerWidth: number) => toolRender(tool, innerWidth),
403
460
  invalidate: () => tool.invalidate?.(),
404
461
  });
405
462
  }
406
463
  }
464
+ /** 子块可用宽度:live 还要让出首行 `↳ ` 的标记位。 */
465
+ const contentWidth = (width: number) => Math.max(0, width - 2 - (live ? 2 : 0));
407
466
  let paint:
408
467
  | {
409
468
  width: number;
@@ -415,7 +474,7 @@ function compactRoundCard(
415
474
  | undefined;
416
475
  const layout = (width: number) => {
417
476
  const theme = themeOf();
418
- const innerWidth = Math.max(0, width - 2);
477
+ const innerWidth = contentWidth(width);
419
478
  const paints = children.map((child) => {
420
479
  const lines = child.render?.(innerWidth);
421
480
  return Array.isArray(lines) ? lines : [];
@@ -429,12 +488,12 @@ function compactRoundCard(
429
488
  ) {
430
489
  return paint;
431
490
  }
432
- const laid = layoutExpandedToolCard(theme, children, width, paints);
491
+ const laid = layoutExpandedToolCard(theme, children, width, paints, toolHits, live);
433
492
  paint = {
434
493
  width,
435
494
  theme,
436
495
  paints,
437
- lines: ["", ...laid.lines],
496
+ lines: laid.lines,
438
497
  hits: laid.hits,
439
498
  };
440
499
  return paint;
@@ -445,10 +504,8 @@ function compactRoundCard(
445
504
  return layout(width).lines;
446
505
  },
447
506
  childAtRow(localRow: number, width: number) {
448
- if (localRow < 1) return null;
449
- const row = localRow - 1;
450
507
  for (const hit of layout(width).hits) {
451
- if (row >= hit.start && row < hit.end) return hit.child;
508
+ if (localRow >= hit.start && localRow < hit.end) return hit.child;
452
509
  }
453
510
  return null;
454
511
  },
@@ -525,12 +582,60 @@ const trackedAssistantComponents = new Set<any>();
525
582
  const trackedToolComponents = new Set<any>();
526
583
  let hoveredAssistantComponent: any;
527
584
 
585
+ /**
586
+ * 活动回合摘要 getter:常驻状态行(pi working row)镜像用。
587
+ * installCompactMode 注册、卸载时清空,直接读 activeRound,不持有回合闭包。
588
+ */
589
+ let compactRunStatusGetter: (() => string | undefined) | undefined;
590
+
591
+ /** 活动回合的摘要文本(`Running... · 9s, bash×1`);无活动回合返回 undefined。 */
592
+ export function getCompactRunStatusText(): string | undefined {
593
+ return compactRunStatusGetter?.();
594
+ }
595
+
528
596
  export function setHoveredCompactAssistant(component: any): boolean {
529
597
  if (hoveredAssistantComponent === component) return false;
530
598
  hoveredAssistantComponent = component;
531
599
  return true;
532
600
  }
533
601
 
602
+ /**
603
+ * 整回合展开卡内的工具被点击展开时调用:让 compact 的强制折叠放行这一个,
604
+ * 并把同一回合里其他展开的工具收回去(保持单开)。非 round 内工具是空操作。
605
+ */
606
+ let roundToolExpansion: { markUserExpanded(tool: any): void } | undefined;
607
+
608
+ export function markCompactRoundToolExpanded(tool: any): void {
609
+ roundToolExpansion?.markUserExpanded(tool);
610
+ }
611
+
612
+ /** 面板展开前的视口位置:收起时还原,避免面板撑高后收起导致视口错位。 */
613
+ const panelViewportBeforeExpand = new WeakMap<
614
+ object,
615
+ { scrollTop: number; followingEnd: boolean }
616
+ >();
617
+
618
+ function capturePanelViewport(card: any): void {
619
+ if (!card || panelViewportBeforeExpand.has(card)) return;
620
+ const view = getToolMouseTui()?.getPrimaryScrollView?.();
621
+ if (!view || typeof view.scrollTop !== "number" || typeof view.scrollTo !== "function") return;
622
+ panelViewportBeforeExpand.set(card, {
623
+ scrollTop: view.scrollTop,
624
+ followingEnd: view.isFollowingEnd !== false,
625
+ });
626
+ }
627
+
628
+ function restorePanelViewport(card: any): void {
629
+ const snapshot = panelViewportBeforeExpand.get(card);
630
+ if (!snapshot) return;
631
+ panelViewportBeforeExpand.delete(card);
632
+ const view = getToolMouseTui()?.getPrimaryScrollView?.();
633
+ if (!view) return;
634
+ // 展开前就在底部:交回官方 follow;否则回到展开前的滚动偏移。
635
+ if (snapshot.followingEnd && typeof view.scrollToEnd === "function") view.scrollToEnd();
636
+ else if (typeof view.scrollTo === "function") view.scrollTo(snapshot.scrollTop);
637
+ }
638
+
534
639
  function compactEditWriteLine(
535
640
  component: any,
536
641
  width: number,
@@ -797,7 +902,9 @@ function compactAssistantLineComponent(
797
902
  return paint.lines;
798
903
  }
799
904
  const plainText = truncateToWidth(resolved, summaryWidth, "…");
800
- let text = theme.fg("muted", plainText);
905
+ // Dim thinking text 开启时整行统一 dim,否则工具计数保持 muted。
906
+ const plain = (value: string) => theme.fg(config.dimThinkingText ? "dim" : "muted", value);
907
+ let text = plain(plainText);
801
908
  if (runningActive || plainText.startsWith("Ran for ")) {
802
909
  const separator = plainText.indexOf(", ");
803
910
  const heading = separator < 0 ? plainText : plainText.slice(0, separator);
@@ -806,13 +913,9 @@ function compactAssistantLineComponent(
806
913
  const durationSeparator = heading.indexOf(" · ");
807
914
  const label = durationSeparator < 0 ? heading : heading.slice(0, durationSeparator);
808
915
  const duration = durationSeparator < 0 ? "" : heading.slice(durationSeparator);
809
- text = `${animateCompactThinkingText(
810
- label,
811
- theme,
812
- query?.getThinkingAnimationFrame?.() ?? 0,
813
- )}${styleCompactThinkingText(duration, theme)}${theme.fg("muted", tools)}`;
916
+ text = `${animateCompactThinkingText(label, theme, query?.getThinkingAnimationFrame?.() ?? 0)}${styleCompactThinkingText(duration, theme)}${plain(tools)}`;
814
917
  } else {
815
- text = `${styleCompactThinkingText(heading, theme)}${theme.fg("muted", tools)}`;
918
+ text = `${styleCompactThinkingText(heading, theme)}${plain(tools)}`;
816
919
  }
817
920
  }
818
921
  const hintColor = hover ? "text" : "dim";
@@ -850,6 +953,51 @@ function compactStopStatusLine(status: string, pad = 0): any {
850
953
  };
851
954
  }
852
955
 
956
+ /**
957
+ * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
958
+ * 提示有两处来源——我们的 showMoreHintText(`… +N more lines • click to show more`)与
959
+ * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
960
+ * 只去掉动作词,`(N more lines)` 这类计数保留。
961
+ */
962
+ /** 展开入口的动作词:我们的 `click to show more`、pi 的 `<key> to expand`(键名可能取不到)。 */
963
+ const EXPAND_ACTION = String.raw`(?:click to show more|(?:[\w/+]+\s+)?to (?:show more|expand))`;
964
+
965
+ const SLOT_HINT_PATTERNS: RegExp[] = [
966
+ // 卡在括号里:`(9 more lines, click to show more)` / `(195 earlier lines, ctrl+o to expand)`,
967
+ // 只去掉动作词,收尾括号与计数保留。
968
+ new RegExp(String.raw`,\s*[^()]*?${EXPAND_ACTION}(?=\))`),
969
+ // 直接挂在行尾:整段去掉。
970
+ new RegExp(String.raw`\s*(?:•\s*)?${EXPAND_ACTION}\s*$`),
971
+ ];
972
+
973
+ function stripSlotHint(line: string): string {
974
+ const plain = stripTerminalSequencesPreservingLayout(line);
975
+ for (const re of SLOT_HINT_PATTERNS) {
976
+ const match = re.exec(plain);
977
+ if (!match) continue;
978
+ // 删除区间而非截断后拼纯文本:收尾括号留在原样式里(dim),不会掉回默认前景色。
979
+ return removeStyledRange(line, match.index, match.index + match[0].length);
980
+ }
981
+ return line;
982
+ }
983
+
984
+ /** live 槽位卡:按 pad 缩进;首行 `↳` 由卡自身的 live 排布加,行不增不减。 */
985
+ function compactLiveSlot(card: any, pad = 0): any {
986
+ const innerOf = (width: number) => Math.max(0, width - Math.max(0, pad));
987
+ return {
988
+ render(width: number): string[] {
989
+ const indent = " ".repeat(Math.max(0, pad));
990
+ return card.render(innerOf(width)).map((line: string) => `${indent}${line}`);
991
+ },
992
+ childAtRow(localRow: number, width: number) {
993
+ return card.childAtRow?.(localRow, innerOf(width)) ?? null;
994
+ },
995
+ invalidate() {
996
+ card.invalidate();
997
+ },
998
+ };
999
+ }
1000
+
853
1001
  function compactAssistantLine(
854
1002
  component: any,
855
1003
  summary: string | (() => string),
@@ -961,6 +1109,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
961
1109
  detachedMessages: any[];
962
1110
  active: boolean;
963
1111
  suppressedToolIds: Set<string>;
1112
+ /** live 槽位内联渲染的工具 id;回合结束时随槽位一起交还外层。 */
1113
+ liveSlotToolIds: Set<string>;
1114
+ /** 收尾后的静默期截止时间戳;期间仍按围观态渲染,到点才收拢。 */
1115
+ settleUntil?: number;
1116
+ foldTimer?: ReturnType<typeof setTimeout>;
964
1117
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
965
1118
  startedAt: number;
966
1119
  endedAt?: number;
@@ -968,6 +1121,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
968
1121
  let activeRound: CompactRound | undefined;
969
1122
  let roundByComponent = new WeakMap<object, CompactRound>();
970
1123
  const expandedRoundToolIds = new Set<string>();
1124
+ /** 用户显式点开的 round 内工具;强制折叠不压回这些 id。 */
1125
+ const explicitRoundToolIds = new Set<string>();
1126
+ const liveSlotToolIds = new Set<string>();
1127
+ const pendingFoldRounds = new Set<CompactRound>();
971
1128
  let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
972
1129
  let roundTickTimer: ReturnType<typeof setInterval> | undefined;
973
1130
 
@@ -1007,8 +1164,22 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1007
1164
  }, 250);
1008
1165
  };
1009
1166
 
1010
- /** 结束回合活动态;可选立即重绘。 */
1011
- const endRound = (round: CompactRound, render = false): void => {
1167
+ /** 围观中:活动回合,或收尾静默期还没走完的回合(槽位卡仍挂在屏幕上)。 */
1168
+ const roundLive = (round: CompactRound): boolean =>
1169
+ round.active || (round.settleUntil !== undefined && Date.now() < round.settleUntil);
1170
+
1171
+ const clearFoldTimer = (round: CompactRound): void => {
1172
+ if (round.foldTimer !== undefined) {
1173
+ clearTimeout(round.foldTimer);
1174
+ round.foldTimer = undefined;
1175
+ }
1176
+ round.settleUntil = undefined;
1177
+ pendingFoldRounds.delete(round);
1178
+ };
1179
+
1180
+ /** 结束回合活动态;不做任何重绘。 */
1181
+ const finalizeRound = (round: CompactRound): void => {
1182
+ clearFoldTimer(round);
1012
1183
  round.active = false;
1013
1184
  if (round.endedAt === undefined) round.endedAt = Date.now();
1014
1185
  if (activeRound === round) {
@@ -1016,7 +1187,36 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1016
1187
  deps.query?.setCompactSummaryActive?.(false);
1017
1188
  stopRoundTick();
1018
1189
  }
1019
- if (render) renderRound(round);
1190
+ };
1191
+
1192
+ /**
1193
+ * 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。
1194
+ * 分组语义不受影响:activeRound 立即让位,新消息不会被并进已收尾的回合。
1195
+ */
1196
+ const endRound = (round: CompactRound, render = false): void => {
1197
+ if (!render || !round.active) {
1198
+ finalizeRound(round);
1199
+ if (render) renderRound(round);
1200
+ return;
1201
+ }
1202
+ finalizeRound(round);
1203
+ if (round.foldTimer !== undefined) return;
1204
+ round.settleUntil = Date.now() + FOLD_SETTLE_MS;
1205
+ pendingFoldRounds.add(round);
1206
+ round.foldTimer = setTimeout(() => {
1207
+ round.foldTimer = undefined;
1208
+ round.settleUntil = undefined;
1209
+ pendingFoldRounds.delete(round);
1210
+ renderRound(round);
1211
+ }, FOLD_SETTLE_MS);
1212
+ };
1213
+
1214
+ /** 新回合接替时立即收掉还在静默期的回合,把两次转场并成一次。 */
1215
+ const flushSettle = (): void => {
1216
+ for (const round of [...pendingFoldRounds]) {
1217
+ clearFoldTimer(round);
1218
+ renderRound(round);
1219
+ }
1020
1220
  };
1021
1221
 
1022
1222
  const renderAssistantWithoutThinking = (
@@ -1037,11 +1237,46 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1037
1237
  return result;
1038
1238
  };
1039
1239
 
1240
+ /** compact 只允许单开:收回一张工具卡的展开态。 */
1241
+ const collapseTool = (tool: any): void => {
1242
+ if (typeof tool?.setExpanded === "function") tool.setExpanded(false);
1243
+ else {
1244
+ tool.expanded = false;
1245
+ tool.updateDisplay?.();
1246
+ }
1247
+ };
1248
+
1249
+ /** 按条件收回已展开的工具卡。 */
1250
+ const collapseTools = (shouldCollapse: (tool: any) => boolean): void => {
1251
+ for (const tool of trackedToolComponents) {
1252
+ if (tool.expanded === true && shouldCollapse(tool)) collapseTool(tool);
1253
+ }
1254
+ };
1255
+
1256
+ const markRoundToolUserExpanded = (tool: any): void => {
1257
+ const id = String(tool?.toolCallId ?? "");
1258
+ if (!id || !expandedRoundToolIds.has(id)) return;
1259
+ for (const other of trackedToolComponents) {
1260
+ const otherId = String(other?.toolCallId ?? "");
1261
+ if (other === tool || !expandedRoundToolIds.has(otherId) || other.expanded !== true) continue;
1262
+ explicitRoundToolIds.delete(otherId);
1263
+ collapseTool(other);
1264
+ }
1265
+ explicitRoundToolIds.add(id);
1266
+ };
1267
+ roundToolExpansion = { markUserExpanded: markRoundToolUserExpanded };
1268
+
1040
1269
  const roundMessages = (round: CompactRound): any[] => [
1041
1270
  ...round.messages.values(),
1042
1271
  ...round.detachedMessages,
1043
1272
  ];
1044
1273
 
1274
+ /** 镜像给常驻状态行的文案:只认当前活动回合(摘要行被顶出视口时由 working-message 取用)。 */
1275
+ compactRunStatusGetter = () => {
1276
+ const round = activeRound;
1277
+ return round?.active ? summarize(roundMessages(round), true, round) || undefined : undefined;
1278
+ };
1279
+
1045
1280
  const roundToolCallIds = (round: CompactRound): Set<string> => {
1046
1281
  const ids = new Set<string>();
1047
1282
  for (const message of roundMessages(round)) {
@@ -1052,26 +1287,146 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1052
1287
  return ids;
1053
1288
  };
1054
1289
 
1290
+ /**
1291
+ * 末块仍是 thinking = 思考还在长;后面跟了正文或工具调用就算结束。
1292
+ * 围观态只预览这一种,已完成的思考直接回收进摘要行,不在屏幕上留 Thought 行。
1293
+ */
1294
+ const hasTrailingThinking = (message: any): boolean => {
1295
+ const content = Array.isArray(message?.content) ? message.content : [];
1296
+ for (let i = content.length - 1; i >= 0; i--) {
1297
+ const item = content[i];
1298
+ if (item?.type === "thinking") return true;
1299
+ if (item?.type === "toolCall") return false;
1300
+ if (item?.type === "text" && String(item.text ?? "").trim()) return false;
1301
+ }
1302
+ return false;
1303
+ };
1304
+
1305
+ /** 运行中的工具:已开始且尚无最终结果。 */
1306
+ const isRunningTool = (tool: any): boolean =>
1307
+ tool?.executionStarted === true && (!tool.result || tool.isPartial === true);
1308
+
1309
+ /**
1310
+ * live 围观态:正文 + 摘要行照折叠态渲染,只在摘要行下多一张槽位卡。
1311
+ * - 思考:只预览最后一条仍在思考的消息,它的块是当前最新内容;
1312
+ * - 工具:没有活动思考时才占槽位,运行中优先,否则取本回合最近的,完成也留着;
1313
+ * - 槽位只放一个块(思考或工具),新的思考块/工具调用进来才轮换;
1314
+ * - 本回合所有工具都由槽位卡接管,外层不再单独成行。
1315
+ * 可见性判断只在这里发生;回合结束(renderRound 非 active)只剩槽位卡消失。
1316
+ */
1317
+ const renderRoundLive = (
1318
+ round: CompactRound,
1319
+ stopStatus: string | undefined,
1320
+ getSummary: () => string,
1321
+ ): void => {
1322
+ // 仍在思考的消息 = 当前最新内容,优先占槽位。
1323
+ let previewComponent: any;
1324
+ for (const [component, message] of round.messages) {
1325
+ if (hasTrailingThinking(message)) previewComponent = component;
1326
+ }
1327
+
1328
+ const thinkingKids: any[] = [];
1329
+ for (const [component, message] of round.messages) {
1330
+ if (component !== previewComponent) {
1331
+ component.contentContainer?.clear?.();
1332
+ continue;
1333
+ }
1334
+ passThroughAssistant(component, message);
1335
+ const kids = Array.isArray(component.contentContainer?.children)
1336
+ ? [...component.contentContainer.children]
1337
+ : [];
1338
+ component.contentContainer?.clear?.();
1339
+ // 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
1340
+ for (const kid of kids) {
1341
+ if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
1342
+ }
1343
+ }
1344
+ // 正文照折叠态渲染(不含 thinking):live 与收尾后同形,收尾只剩槽位卡消失。
1345
+ renderAssistantWithoutThinking(round.anchor, round.messages.get(round.anchor));
1346
+
1347
+ const toolsById = new Map<string, any>();
1348
+ for (const tool of trackedToolComponents) {
1349
+ if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
1350
+ }
1351
+ let runningTool: any;
1352
+ let lastTool: any;
1353
+ for (const [, message] of round.messages) {
1354
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
1355
+ if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
1356
+ const tool = toolsById.get(item.id);
1357
+ // edit/write 保留自己的 compact 行,不进槽位。
1358
+ if (EDIT_WRITE_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1359
+ round.liveSlotToolIds.add(item.id);
1360
+ liveSlotToolIds.add(item.id);
1361
+ if (!tool) continue;
1362
+ lastTool = tool;
1363
+ if (isRunningTool(tool)) runningTool = tool;
1364
+ }
1365
+ }
1366
+ // 槽位单块:活动思考最新;否则本回合最近的工具(运行中原位增长,完成也留着)。
1367
+ const slotTool = thinkingKids.length > 0 ? undefined : (runningTool ?? lastTool);
1368
+ const cardItems: Array<{ child?: any; tool?: any }> = slotTool
1369
+ ? [{ tool: slotTool }]
1370
+ : thinkingKids.map((child) => ({ child }));
1371
+
1372
+ const anchor = round.anchor;
1373
+ const pad = Number(anchor.outputPad) || 0;
1374
+ // 摘要行位置与折叠态一致(正文之后),收尾只有时态变化和槽位卡消失。
1375
+ compactAssistantLine(anchor, getSummary, deps.query);
1376
+ if (cardItems.length > 0) {
1377
+ anchor.contentContainer.addChild(
1378
+ compactLiveSlot(
1379
+ compactRoundCard(
1380
+ cardItems,
1381
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1382
+ false,
1383
+ true,
1384
+ ),
1385
+ pad,
1386
+ ),
1387
+ );
1388
+ }
1389
+ appendStopStatus(anchor, stopStatus);
1390
+ };
1391
+
1055
1392
  const renderRound = (round: CompactRound): void => {
1056
1393
  const messages = roundMessages(round);
1057
1394
  const stopStatus = roundStopStatus(messages);
1058
1395
  if (stopStatus) endRound(round);
1059
1396
  // Running 时每次 render 重算时长(含挂钟下限);结束后固定。
1397
+ // 静默期内回合已收尾,摘要行直接给 Ran for,只有槽位卡多留一拍。
1060
1398
  const getSummary = () => summarize(roundMessages(round), round.active, round);
1061
1399
  const summary = getSummary();
1062
1400
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
1063
1401
  round.suppressedToolIds.clear();
1402
+ // 回合收起(或本回合不在展开态)时忘掉用户的单开选择,并把面板接管的工具一起收回:
1403
+ // 少了后一步,被点开的工具会带着自身的 expanded 落到 toolInstalledRender 的放行分支,
1404
+ // 面板一收就单独渲染成一张卡。
1405
+ if (round.anchor.expanded !== true) {
1406
+ const ids = roundToolCallIds(round);
1407
+ for (const id of ids) explicitRoundToolIds.delete(id);
1408
+ collapseTools(
1409
+ (tool) =>
1410
+ ids.has(String(tool.toolCallId ?? "")) &&
1411
+ !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
1412
+ );
1413
+ }
1414
+ for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
1415
+ round.liveSlotToolIds.clear();
1064
1416
 
1065
1417
  for (const [component, message] of round.messages) {
1066
1418
  component.lastMessage = message;
1067
1419
  ensureAssistantSetExpanded(component);
1068
1420
  component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1421
+ const wasExpanded = round.anchor.expanded === true;
1422
+ if (expanded && !wasExpanded) capturePanelViewport(round.anchor);
1069
1423
  for (const member of round.messages.keys()) member.expanded = expanded;
1070
1424
  if (!expanded) {
1071
1425
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
1072
1426
  round.suppressedToolIds.clear();
1073
1427
  }
1074
1428
  renderRound(round);
1429
+ if (!expanded && wasExpanded) restorePanelViewport(round.anchor);
1075
1430
  };
1076
1431
  }
1077
1432
 
@@ -1080,14 +1435,39 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1080
1435
  for (const tool of trackedToolComponents) {
1081
1436
  if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
1082
1437
  }
1083
- const cardItems: Array<{ child?: any; tool?: any }> = [];
1084
- const placedToolIds = new Set<string>();
1085
- const takeAssistantRun = (kids: any[], cursor: { i: number }) => {
1086
- while (cursor.i < kids.length && kids[cursor.i] instanceof Spacer) {
1087
- cardItems.push({ child: kids[cursor.i++] });
1438
+ // 助手文本按原生渲染、不进面板(面板只留 thinking 与工具卡)。
1439
+ // thinking/工具卡按连续段各包一张面板;先出文本再出面板,
1440
+ // 否则消息内容里的 thinking([thinking, text, toolCall])会盖到文本上面。
1441
+ const anchorChildren: any[] = [];
1442
+ let cardItems: Array<{ child?: any; tool?: any }> = [];
1443
+ const flushPanel = () => {
1444
+ if (cardItems.length === 0) return;
1445
+ if (cardItems.every((item) => !item.tool && isSpacerComponent(item.child))) {
1446
+ for (const item of cardItems) anchorChildren.push(item.child);
1447
+ } else {
1448
+ // 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
1449
+ // 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
1450
+ anchorChildren.push(new Spacer(1));
1451
+ anchorChildren.push(
1452
+ compactRoundCard(
1453
+ cardItems,
1454
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1455
+ // 整回合展开卡:卡内工具行归 tool 组件,可单击二次展开。
1456
+ true,
1457
+ ),
1458
+ );
1459
+ }
1460
+ cardItems = [];
1461
+ };
1462
+ const takeChild = (child: any, outside: boolean) => {
1463
+ if (!outside) {
1464
+ cardItems.push({ child });
1465
+ return;
1088
1466
  }
1089
- if (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
1467
+ flushPanel();
1468
+ anchorChildren.push(child);
1090
1469
  };
1470
+ const placedToolIds = new Set<string>();
1091
1471
  const placeTool = (id: string, fallbackName?: string) => {
1092
1472
  const tool = toolsById.get(id);
1093
1473
  if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
@@ -1095,6 +1475,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1095
1475
  placedToolIds.add(id);
1096
1476
  cardItems.push({ tool });
1097
1477
  };
1478
+ type LocalEntry = { outside: boolean; child?: any; toolId?: string; toolName?: string };
1098
1479
  for (const [component, message] of round.messages) {
1099
1480
  passThroughAssistant(component, message);
1100
1481
  const kids = Array.isArray(component.contentContainer?.children)
@@ -1103,25 +1484,39 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1103
1484
  component.contentContainer?.clear?.();
1104
1485
  const cursor = { i: 0 };
1105
1486
  let inThinkingRun = false;
1487
+ // 本消息产出先收集再发射:文本(含其前导 Spacer)统一排在面板前面。
1488
+ const local: LocalEntry[] = [];
1489
+ const takeRun = (outside: boolean) => {
1490
+ while (cursor.i < kids.length && isSpacerComponent(kids[cursor.i])) {
1491
+ local.push({ outside, child: kids[cursor.i++] });
1492
+ }
1493
+ if (cursor.i < kids.length) local.push({ outside, child: kids[cursor.i++] });
1494
+ };
1106
1495
  for (const item of Array.isArray(message?.content) ? message.content : []) {
1107
1496
  if (item?.type === "thinking") {
1108
1497
  if (!String(item.thinking ?? "").trim()) continue;
1109
1498
  if (!inThinkingRun) {
1110
- takeAssistantRun(kids, cursor);
1499
+ takeRun(false);
1111
1500
  inThinkingRun = true;
1112
1501
  }
1113
1502
  continue;
1114
1503
  }
1115
1504
  inThinkingRun = false;
1116
1505
  if (item?.type === "text" && String(item.text ?? "").trim()) {
1117
- takeAssistantRun(kids, cursor);
1506
+ takeRun(true);
1118
1507
  continue;
1119
1508
  }
1120
1509
  if (item?.type === "toolCall" && typeof item.id === "string") {
1121
- placeTool(item.id, item.name);
1510
+ local.push({ outside: false, toolId: item.id, toolName: item.name });
1122
1511
  }
1123
1512
  }
1124
- while (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
1513
+ while (cursor.i < kids.length) local.push({ outside: false, child: kids[cursor.i++] });
1514
+ for (const entry of local) if (entry.outside) takeChild(entry.child, true);
1515
+ for (const entry of local) {
1516
+ if (entry.outside) continue;
1517
+ if (entry.toolId) placeTool(entry.toolId, entry.toolName);
1518
+ else takeChild(entry.child, false);
1519
+ }
1125
1520
  }
1126
1521
  for (const message of round.detachedMessages) {
1127
1522
  for (const item of Array.isArray(message?.content) ? message.content : []) {
@@ -1136,31 +1531,27 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1136
1531
  expandedRoundToolIds.add(id);
1137
1532
  if (!placedToolIds.has(id)) placeTool(id);
1138
1533
  }
1139
- // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕。
1140
- for (const tool of trackedToolComponents) {
1141
- if (
1142
- !ids.has(tool.toolCallId) ||
1143
- EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) ||
1144
- tool.expanded !== true
1145
- ) {
1146
- continue;
1147
- }
1148
- if (typeof tool.setExpanded === "function") tool.setExpanded(false);
1149
- else {
1150
- tool.expanded = false;
1151
- tool.updateDisplay?.();
1152
- }
1153
- }
1154
- round.anchor.contentContainer.addChild(
1155
- compactRoundCard(cardItems, (tool, innerWidth) =>
1156
- patch.toolOriginalRender.call(tool, innerWidth),
1157
- ),
1534
+ // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕;
1535
+ // 用户单独点开的(explicitRoundToolIds)不压回去。
1536
+ collapseTools(
1537
+ (tool) =>
1538
+ ids.has(String(tool.toolCallId ?? "")) &&
1539
+ !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) &&
1540
+ !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
1158
1541
  );
1542
+ flushPanel();
1543
+ for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
1159
1544
  // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
1160
1545
  appendStopStatus(round.anchor, stopStatus);
1161
1546
  return;
1162
1547
  }
1163
1548
 
1549
+ if (roundLive(round)) {
1550
+ // 围观态:单一槽位卡(活动思考预览 + 当前工具),回合结束自动收回。
1551
+ renderRoundLive(round, stopStatus, getSummary);
1552
+ return;
1553
+ }
1554
+
1164
1555
  for (const [component, message] of round.messages) {
1165
1556
  if (component === round.anchor) {
1166
1557
  renderAssistantWithoutThinking(component, message);
@@ -1175,6 +1566,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1175
1566
  };
1176
1567
 
1177
1568
  const activateRound = (round: CompactRound): void => {
1569
+ flushSettle();
1178
1570
  round.active = true;
1179
1571
  if (!round.startedAt) round.startedAt = Date.now();
1180
1572
  delete round.endedAt;
@@ -1186,9 +1578,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1186
1578
  const finishRound = (round: CompactRound): void => endRound(round, true);
1187
1579
 
1188
1580
  const resetRounds = (): void => {
1581
+ for (const round of [...pendingFoldRounds]) clearFoldTimer(round);
1189
1582
  activeRound = undefined;
1190
1583
  roundByComponent = new WeakMap();
1191
1584
  expandedRoundToolIds.clear();
1585
+ explicitRoundToolIds.clear();
1586
+ liveSlotToolIds.clear();
1192
1587
  deps.query?.setCompactSummaryActive?.(false);
1193
1588
  stopRoundTick();
1194
1589
  };
@@ -1227,6 +1622,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1227
1622
  detachedMessages: [],
1228
1623
  active: true,
1229
1624
  suppressedToolIds: new Set(),
1625
+ liveSlotToolIds: new Set(),
1230
1626
  startedAt: Date.now(),
1231
1627
  };
1232
1628
  roundByComponent.set(this, round);
@@ -1238,6 +1634,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1238
1634
  detachedMessages: [],
1239
1635
  active: true,
1240
1636
  suppressedToolIds: new Set(),
1637
+ liveSlotToolIds: new Set(),
1241
1638
  startedAt: Date.now(),
1242
1639
  };
1243
1640
  roundByComponent.set(this, round);
@@ -1261,7 +1658,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1261
1658
  return renderAssistantWithoutThinking(this, message);
1262
1659
  }
1263
1660
  if (round) {
1264
- endRound(round);
1661
+ // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
1662
+ endRound(round, true);
1265
1663
  roundByComponent.delete(this);
1266
1664
  return passThroughAssistant(this, message);
1267
1665
  }
@@ -1279,6 +1677,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1279
1677
  detachedMessages: [],
1280
1678
  active: true,
1281
1679
  suppressedToolIds: new Set(),
1680
+ liveSlotToolIds: new Set(),
1282
1681
  startedAt: Date.now(),
1283
1682
  };
1284
1683
  roundByComponent.set(this, round);
@@ -1314,6 +1713,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1314
1713
  }
1315
1714
  // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
1316
1715
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
1716
+ // live 围观态:本回合工具交由槽位卡内联渲染,外层不再单独成行,
1717
+ // 卡片因此不会随工具完成反复挂载/卸载。
1718
+ if (liveSlotToolIds.has(String(this.toolCallId ?? ""))) return [];
1317
1719
  // 普通工具折叠时不显示独立行(摘要行已统计),独立展开走原 renderer。
1318
1720
  if (this.expanded === true) {
1319
1721
  return patch.toolOriginalRender.call(this, width);
@@ -1322,12 +1724,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1322
1724
  };
1323
1725
 
1324
1726
  patch.toolInstalledUpdateDisplay = function (this: any) {
1325
- if (
1326
- patch.active &&
1327
- config.mode === "compact" &&
1328
- expandedRoundToolIds.has(String(this.toolCallId ?? ""))
1329
- ) {
1330
- this.expanded = false;
1727
+ const id = String(this.toolCallId ?? "");
1728
+ if (patch.active && config.mode === "compact" && expandedRoundToolIds.has(id)) {
1729
+ // 用户点开的保留展开;其余(含全局展开被收回的)继续强制折叠。
1730
+ if (this.expanded !== true) explicitRoundToolIds.delete(id);
1731
+ else if (!explicitRoundToolIds.has(id)) this.expanded = false;
1331
1732
  }
1332
1733
  const result = patch.toolOriginalUpdateDisplay.call(this);
1333
1734
  if (!patch.active) return result;
@@ -1355,6 +1756,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1355
1756
  patch.dispose = () => {
1356
1757
  if (!patch.active) return;
1357
1758
  patch.active = false;
1759
+ if (roundToolExpansion?.markUserExpanded === markRoundToolUserExpanded) {
1760
+ roundToolExpansion = undefined;
1761
+ }
1358
1762
  if (assistantPrototype.updateContent === patch.assistantInstalled) {
1359
1763
  assistantPrototype.updateContent = patch.assistantOriginal;
1360
1764
  }
@@ -1370,6 +1774,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1370
1774
  trackedToolComponents.clear();
1371
1775
  hoveredAssistantComponent = undefined;
1372
1776
  resetRounds();
1777
+ compactRunStatusGetter = undefined;
1373
1778
  };
1374
1779
 
1375
1780
  assistantPrototype.updateContent = patch.assistantInstalled;