@herbertgao/pi-extensions 2026.10.4 → 2026.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +13 -9
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +13 -9
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +72 -16
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +262 -48
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/core.ts +2 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +1 -1
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +131 -28
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +2 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/subagent.ts +3 -0
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +96 -59
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +204 -0
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -86
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +212 -74
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +50 -362
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +35 -3
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +16 -83
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +1 -1
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +28 -26
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +1 -1
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +450 -0
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ccstyle-tool.ts +467 -0
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +5 -9
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +1 -24
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-limits.ts +1 -1
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-text.ts +2 -1
  33. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +36 -10
  34. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +1 -1
  35. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +140 -51
  36. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/message-display.ts +1 -1
  37. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +44 -4
  38. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +34 -803
  39. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-io.ts +735 -0
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-state.ts +138 -0
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  43. package/node_modules/@herbertgao/pi-cc-extensions/package.json +5 -4
  44. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +2 -2
  45. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +3 -3
  46. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  47. package/node_modules/@herbertgao/sol-pi/README.md +3 -3
  48. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
  49. package/node_modules/@herbertgao/sol-pi/agents-install.md +3 -3
  50. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +1 -1
  51. package/package.json +2 -2
  52. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/show-more-hint.ts +0 -48
@@ -6,6 +6,10 @@
6
6
  * renderer)。edit/write 复用 mode=on 的 rich diff
7
7
  * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
8
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
9
+ * edit/write 与 ask_user_question 留在外层:不进摘要计数,折叠态也单独成行。
10
+ * 用户消息是回合的硬边界:steer 消息插进回合中间、或上一回合没正文就收尾时,回合
11
+ * 就地收口、后续消息另起一回合,区间里的用户消息不会被当成外来行挪走(摘要行、
12
+ * 展开面板因此留在自己回合的位置上)。
9
13
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
10
14
  *
11
15
  * live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
@@ -26,27 +30,34 @@
26
30
  */
27
31
  import {
28
32
  AssistantMessageComponent,
33
+ SkillInvocationMessageComponent,
29
34
  ToolExecutionComponent,
35
+ UserMessageComponent,
30
36
  type Theme,
31
37
  } from "@earendil-works/pi-coding-agent";
32
38
  import { truncateToWidth, visibleWidth, Box, Spacer } from "@earendil-works/pi-tui";
33
39
  import { config, getToolDisplayConfig } from "../config/config.ts";
40
+ import { ASK_USER_QUESTION_TOOL_NAME } from "./tool/ask-user-question.ts";
34
41
  import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
35
42
  import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
36
43
  import { refreshTranscriptComponent } from "./transcript-refresh.ts";
37
44
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
38
- import { showMoreHintText } from "./tool/show-more-hint.ts";
45
+ import { showMoreHintText } from "./tool/names.ts";
39
46
  import {
40
47
  countEditDiffStats,
41
48
  countWriteDiffStats,
42
49
  isRichDiffComponent,
43
50
  } from "./tool/diff/diff-renderer.ts";
44
51
  import { renderRichToolResult } from "./tool/diff/index.ts";
52
+ import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
45
53
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
46
54
  import { isToolCallHovered } from "./mouse/hover.ts";
47
55
  import { getToolMouseTui } from "./mouse/scroll.ts";
48
- import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
56
+ import { insetComponent } from "./tool/result.ts";
57
+ import { isLiveToolCall, scheduleAnimation } from "./tool/tool-state.ts";
58
+ import { renderExpandedToolResult } from "./tool/tool-io.ts";
49
59
  import { paddedBackgroundRow } from "./tool/grouping.ts";
60
+ import { styleChangeCounts } from "./tool/ccstyle-tool.ts";
50
61
  import { formatDisplayPath } from "./tool/names.ts";
51
62
  import {
52
63
  hasVisibleText,
@@ -81,6 +92,13 @@ export type CompactThinkingQuery = {
81
92
 
82
93
  const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
83
94
 
95
+ /**
96
+ * 留在外层的工具:不进摘要计数,折叠态也单独成行。
97
+ * edit/write 走 compact 自己的 rich diff;ask_user_question 走 default 模式的专用渲染
98
+ *(标题 (+N) 加折叠问答树)。
99
+ */
100
+ const OUTER_TOOLS = new Set([...EDIT_WRITE_TOOLS, ASK_USER_QUESTION_TOOL_NAME]);
101
+
84
102
  /**
85
103
  * 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
86
104
  * 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
@@ -205,7 +223,7 @@ function buildMessagesSummary(
205
223
  for (const item of content) {
206
224
  if (item?.type !== "toolCall") continue;
207
225
  const rawName = typeof item.name === "string" ? item.name : "tool";
208
- if (EDIT_WRITE_TOOLS.has(rawName)) continue;
226
+ if (OUTER_TOOLS.has(rawName)) continue;
209
227
  const name = sanitizeToolResultText(rawName);
210
228
  if (rawName.split(".").pop() === "read") {
211
229
  const args = item.arguments ?? item.args ?? {};
@@ -574,6 +592,11 @@ export type CompactModeHooks = {
574
592
  assertOwnership(): void;
575
593
  /** 重新渲染包含指定 toolCallId 的 assistant 消息(思考收尾时刷新)。 */
576
594
  refreshToolCallMessage(toolCallId: string | undefined): void;
595
+ /**
596
+ * 收尾 resume 重放留下的活动回合。重放是同步的,回合之间靠下一条消息收口,
597
+ * 只有最后一条没有后续消息,会停在 Running。
598
+ */
599
+ settleReplayedRound(): void;
577
600
  shutdown(): void;
578
601
  };
579
602
 
@@ -600,17 +623,6 @@ const trackedAssistantComponents = new Set<any>();
600
623
  const trackedToolComponents = new Set<any>();
601
624
  let hoveredAssistantComponent: any;
602
625
 
603
- /**
604
- * 活动回合摘要 getter:常驻状态行(pi working row)镜像用。
605
- * installCompactMode 注册、卸载时清空,直接读 activeRound,不持有回合闭包。
606
- */
607
- let compactRunStatusGetter: (() => string | undefined) | undefined;
608
-
609
- /** 活动回合的摘要文本(`Running... · 9s, bash×1`);无活动回合返回 undefined。 */
610
- export function getCompactRunStatusText(): string | undefined {
611
- return compactRunStatusGetter?.();
612
- }
613
-
614
626
  /**
615
627
  * 回合尾行记录:摘要行(live 时连槽位卡)挂成 transcript 容器里回合末尾的
616
628
  * 兄弟组件,而不是 anchor 内部。anchor 内部的可变内容一旦被工具/diff 顶出视口
@@ -643,38 +655,44 @@ export function markCompactRoundToolExpanded(tool: any): void {
643
655
  roundToolExpansion?.markUserExpanded(tool);
644
656
  }
645
657
 
646
- /** 面板展开前的视口位置:收起时还原,避免面板撑高后收起导致视口错位。 */
647
- const panelViewportBeforeExpand = new WeakMap<
648
- object,
649
- { scrollTop: number; followingEnd: boolean }
650
- >();
658
+ /**
659
+ * 展开面板前的分隔空行与面板绑成一体:挂在同一尾行上,才不会被拆到
660
+ * anchor 正文之后(面板挂回合尾行,空行却留在消息里)。
661
+ * 带 childAtRow 返回 null:拆分判据认它为面板成员,行命中落空时归外层面板。
662
+ */
663
+ function panelBoundSpacer(): any {
664
+ return {
665
+ render: () => [""],
666
+ childAtRow: () => null,
667
+ invalidate() {},
668
+ };
669
+ }
670
+
671
+ /** 面板展开前的视口标记:收起时需要重钉视口的卡。 */
672
+ const panelViewportBeforeExpand = new WeakSet<object>();
651
673
 
652
674
  function capturePanelViewport(card: any): void {
653
- if (!card || panelViewportBeforeExpand.has(card)) return;
654
- const view = getToolMouseTui()?.getPrimaryScrollView?.();
655
- if (!view || typeof view.scrollTop !== "number" || typeof view.scrollTo !== "function") return;
656
- panelViewportBeforeExpand.set(card, {
657
- scrollTop: view.scrollTop,
658
- followingEnd: view.isFollowingEnd !== false,
659
- });
675
+ if (!card) return;
676
+ panelViewportBeforeExpand.add(card);
660
677
  }
661
678
 
662
679
  function restorePanelViewport(card: any): void {
663
- const snapshot = panelViewportBeforeExpand.get(card);
664
- if (!snapshot) return;
665
- panelViewportBeforeExpand.delete(card);
680
+ if (!panelViewportBeforeExpand.delete(card)) return;
666
681
  const view = getToolMouseTui()?.getPrimaryScrollView?.();
667
682
  if (!view) return;
668
- // 展开前就在底部:交回官方 follow;否则回到展开前的滚动偏移。
669
- if (snapshot.followingEnd && typeof view.scrollToEnd === "function") view.scrollToEnd();
670
- else if (typeof view.scrollTo === "function") view.scrollTo(snapshot.scrollTop);
683
+ // 不用展开前的快照:展开期间用户可能滚进面板里,回跳旧位置才是"跳"。
684
+ // 面板在摘要行之下,收起只删它以下的行,保持当前 scrollTop 摘要行原地不动;
685
+ // clamp 后落在新底时 scrollTo 会自动恢复 followingEnd(并清 followSuppressedAtEnd)。
686
+ if (typeof view.scrollTo === "function" && typeof view.scrollTop === "number") {
687
+ view.scrollTo(view.scrollTop);
688
+ }
671
689
  }
672
690
 
673
691
  function compactEditWriteLine(
674
692
  component: any,
675
693
  width: number,
676
694
  writeMetadata?: WriteExecutionMetadataStore,
677
- options: { hint?: boolean; flushLeft?: boolean } = {},
695
+ options: { hint?: boolean; flushLeft?: boolean; showStats?: boolean } = {},
678
696
  ): string[] {
679
697
  const theme = themeOf();
680
698
  const name = String(component.toolName ?? "tool");
@@ -691,7 +709,8 @@ function compactEditWriteLine(
691
709
  const iconColor = isError ? "error" : isPending ? "accent" : "success";
692
710
  let statsText = "";
693
711
  let statsStyled = "";
694
- if (!isError && !isPending) {
712
+ // 统计只显示一处:正文自带 +A -D 时标题不再重复(showStats=false)。
713
+ if (!isError && !isPending && options.showStats !== false) {
695
714
  const stats =
696
715
  name === "edit"
697
716
  ? countEditDiffStats(component.result?.details)
@@ -703,8 +722,13 @@ function compactEditWriteLine(
703
722
  )
704
723
  : undefined;
705
724
  if (stats) {
706
- statsText = ` (+${stats.added} -${stats.removed})`;
707
- statsStyled = ` ${theme.fg("dim", "(")}${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}${theme.fg("dim", ")")}`;
725
+ const counts = styleChangeCounts(
726
+ (color, text) => theme.fg(color, text),
727
+ stats.added,
728
+ stats.removed,
729
+ );
730
+ statsText = counts.text;
731
+ statsStyled = counts.styled;
708
732
  }
709
733
  }
710
734
  // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
@@ -729,6 +753,12 @@ function compactEditWriteLine(
729
753
  return ["", truncateToWidth(line, width, "")];
730
754
  }
731
755
 
756
+ /** 正文自带 +A -D 的声明:diff 组件自报,折叠行数/宽度这些细节由它自己判断。 */
757
+ function richDiffBodyShowsStats(component: unknown): boolean {
758
+ const probe = (component as { bodyShowsStats?: () => boolean } | undefined)?.bodyShowsStats;
759
+ return typeof probe === "function" && probe.call(component) === true;
760
+ }
761
+
732
762
  /** expanded 写进 rich diff 闭包,折叠/展开分槽缓存,避免每帧 parseDiff。 */
733
763
  const compactRichDiffCache = new WeakMap<
734
764
  object,
@@ -740,6 +770,7 @@ type CompactEditPaintHit = {
740
770
  theme: unknown;
741
771
  /** 展开卡的背景槽位同样参与命中判断。 */
742
772
  bgSlot: string;
773
+ diffConfigKey: string;
743
774
  expanded: boolean;
744
775
  result: unknown;
745
776
  isPartial: boolean;
@@ -771,6 +802,7 @@ function compactEditWriteLines(
771
802
  const hover = isToolCallHovered(component.toolCallId);
772
803
  const ioHover = compactEditIoHover(component);
773
804
  const theme = themeOf();
805
+ const diffConfigKey = displayConfigCacheKey(getToolDisplayConfig());
774
806
  if (!pending) {
775
807
  const hit = compactEditPaintCache.get(component);
776
808
  if (
@@ -778,6 +810,7 @@ function compactEditWriteLines(
778
810
  hit.width === width &&
779
811
  hit.theme === theme &&
780
812
  hit.bgSlot === config.expandedCardBackground &&
813
+ hit.diffConfigKey === diffConfigKey &&
781
814
  hit.expanded === expanded &&
782
815
  hit.result === component.result &&
783
816
  hit.isPartial === isPartial &&
@@ -794,6 +827,7 @@ function compactEditWriteLines(
794
827
  width,
795
828
  theme,
796
829
  bgSlot: config.expandedCardBackground,
830
+ diffConfigKey,
797
831
  expanded,
798
832
  result: component.result,
799
833
  isPartial,
@@ -848,7 +882,12 @@ function paintCompactEditWrite(
848
882
  component.resultRendererComponent = candidate;
849
883
  }
850
884
 
851
- const title = compactEditWriteLine(component, width, writeMetadata, { hint: !hasRich });
885
+ // 统计只显示一处:正文里已经有 +A -D(diff 头行/0 行摘要行)时标题不再重复。
886
+ const showStats = !(hasRich && richDiffBodyShowsStats(candidate));
887
+ const title = compactEditWriteLine(component, width, writeMetadata, {
888
+ hint: !hasRich,
889
+ showStats,
890
+ });
852
891
  if (!hasRich && !expanded) {
853
892
  return title;
854
893
  }
@@ -888,6 +927,7 @@ function paintCompactEditWrite(
888
927
  return compactEditWriteLine(component, innerWidth, writeMetadata, {
889
928
  hint: false,
890
929
  flushLeft: true,
930
+ showStats,
891
931
  }).slice(1);
892
932
  },
893
933
  invalidate() {},
@@ -1001,7 +1041,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
1001
1041
 
1002
1042
  /**
1003
1043
  * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
1004
- * 提示有两处来源——我们的 showMoreHintText(`… +N more lines · click to show more`)与
1044
+ * 提示有两处来源——我们的 showMoreHintText(`… N more lines · click to show more`)与
1005
1045
  * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
1006
1046
  * 只去掉动作词,`(N more lines)` 这类计数保留。
1007
1047
  */
@@ -1150,6 +1190,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1150
1190
  liveSlotToolIds: Set<string>;
1151
1191
  /** 收尾后的静默期截止时间戳;期间仍按围观态渲染,到点才收拢。 */
1152
1192
  settleUntil?: number;
1193
+ /** 消息已有 toolCall、工具组件还没到:等它一 updateDisplay 就重画槽位。 */
1194
+ pendingLiveTool?: boolean;
1153
1195
  foldTimer?: ReturnType<typeof setTimeout>;
1154
1196
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
1155
1197
  startedAt: number;
@@ -1311,12 +1353,6 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1311
1353
  ...round.detachedMessages,
1312
1354
  ];
1313
1355
 
1314
- /** 镜像给常驻状态行的文案:只认当前活动回合(摘要行被顶出视口时由 working-message 取用)。 */
1315
- compactRunStatusGetter = () => {
1316
- const round = activeRound;
1317
- return round?.active ? summarize(roundMessages(round), true, round) || undefined : undefined;
1318
- };
1319
-
1320
1356
  const roundToolCallIds = (round: CompactRound): Set<string> => {
1321
1357
  const ids = new Set<string>();
1322
1358
  for (const message of roundMessages(round)) {
@@ -1327,6 +1363,26 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1327
1363
  return ids;
1328
1364
  };
1329
1365
 
1366
+ /**
1367
+ * 摘要行有东西可汇总才挂:普通工具(edit/write/ask 之外)或非空思考。
1368
+ * 只剩外层卡的回合不挂——那些卡各自能展开,空摘要点开只会跳位。
1369
+ */
1370
+ const roundHasSummaryContent = (round: CompactRound): boolean => {
1371
+ for (const message of roundMessages(round)) {
1372
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
1373
+ if (item?.type === "thinking" && String(item.thinking ?? "").trim()) return true;
1374
+ if (
1375
+ item?.type === "toolCall" &&
1376
+ typeof item.name === "string" &&
1377
+ !OUTER_TOOLS.has(item.name)
1378
+ ) {
1379
+ return true;
1380
+ }
1381
+ }
1382
+ }
1383
+ return false;
1384
+ };
1385
+
1330
1386
  /**
1331
1387
  * anchor 所在的 transcript 容器(pi chatContainer)。
1332
1388
  * 优先走分组补丁在 addChild 时挂的父指针;没有则沿 tui 组件树找直属父容器。
@@ -1362,6 +1418,54 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1362
1418
  return found;
1363
1419
  };
1364
1420
 
1421
+ /** 回合成员判定:本回合的 assistant 组件,或归属本回合的工具(含分组容器)。 */
1422
+ const roundMemberMatcher = (round: CompactRound): ((value: any) => boolean) => {
1423
+ const ids = roundToolCallIds(round);
1424
+ const matchesTool = (value: any): boolean =>
1425
+ typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1426
+ return (value: any): boolean =>
1427
+ round.messages.has(value) ||
1428
+ matchesTool(value) ||
1429
+ (Array.isArray(value?.children) && value.children.some(matchesTool));
1430
+ };
1431
+
1432
+ /** 用户消息(含 skill 调用块)是回合的硬边界。 */
1433
+ const isUserMessageComponent = (value: any): boolean =>
1434
+ value instanceof UserMessageComponent || value instanceof SkillInvocationMessageComponent;
1435
+
1436
+ /**
1437
+ * 新组件与回合已有成员之间是否隔着一条用户消息。隔着就说明这是下一回合的内容:
1438
+ * steer 消息插进了回合中间,或上一回合没有正文就收尾、用户又问了新问题。
1439
+ * 组件还没挂进容器时判断不了,按不隔开处理(会话重放的 refresh 会再求值一次)。
1440
+ */
1441
+ const crossesUserMessage = (round: CompactRound, component: any): boolean => {
1442
+ const container = tailContainerOf(round.anchor);
1443
+ const children = container?.children;
1444
+ if (!Array.isArray(children)) return false;
1445
+ const index = children.indexOf(component);
1446
+ if (index < 0) return false;
1447
+ const isMember = roundMemberMatcher(round);
1448
+ for (let i = index - 1; i >= 0; i--) {
1449
+ if (isMember(children[i])) return false;
1450
+ if (isUserMessageComponent(children[i])) return true;
1451
+ }
1452
+ return false;
1453
+ };
1454
+
1455
+ /**
1456
+ * 可以并入的活动回合。被用户消息隔开时返回 undefined,调用方收掉旧回合另起一个,
1457
+ * 保证回合不跨用户消息——否则区间里的用户消息会被当成外来行挪到回合末尾之后。
1458
+ */
1459
+ const joinableActiveRound = (component: any): CompactRound | undefined =>
1460
+ activeRound && !crossesUserMessage(activeRound, component) ? activeRound : undefined;
1461
+
1462
+ /** 取并入目标;旧回合被用户消息隔开时先收尾(静默期一转场,新回合立即接手)。 */
1463
+ const joinOrCreateRound = (component: any, message: any): CompactRound => {
1464
+ const joinable = joinableActiveRound(component);
1465
+ if (!joinable && activeRound) finishRound(activeRound);
1466
+ return joinable ?? createRound(component, message);
1467
+ };
1468
+
1365
1469
  /** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
1366
1470
  const tailHostContainer = (tail: MountedRoundTail): any =>
1367
1471
  containerOfHost(tail.host) ??
@@ -1444,13 +1548,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1444
1548
  const children = container.children as any[];
1445
1549
  const current = children.indexOf(tail.host);
1446
1550
  if (current >= 0) children.splice(current, 1);
1447
- const ids = roundToolCallIds(round);
1448
- const matchesTool = (value: any): boolean =>
1449
- typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1450
- const isMember = (value: any): boolean =>
1451
- round.messages.has(value) ||
1452
- matchesTool(value) ||
1453
- (Array.isArray(value?.children) && value.children.some(matchesTool));
1551
+ const isMember = roundMemberMatcher(round);
1454
1552
  let first = -1;
1455
1553
  let last = -1;
1456
1554
  for (let i = 0; i < children.length; i++) {
@@ -1461,7 +1559,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1461
1559
  const lastMember = last >= 0 ? children[last] : undefined;
1462
1560
  const intruders: any[] = [];
1463
1561
  for (let i = last - 1; i > first; i--) {
1464
- if (!isMember(children[i])) intruders.unshift(children.splice(i, 1)[0]);
1562
+ // 用户消息永远留在原地:挪走会把别人的消息挪到用户消息下面去。
1563
+ if (isMember(children[i]) || isUserMessageComponent(children[i])) continue;
1564
+ intruders.unshift(children.splice(i, 1)[0]);
1465
1565
  }
1466
1566
  if (lastMember) last = children.indexOf(lastMember);
1467
1567
  const insertAt = last < 0 ? children.length : last + 1;
@@ -1491,10 +1591,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1491
1591
  const syncTailForTool = (toolCallId: string): void => {
1492
1592
  if (!toolCallId) return;
1493
1593
  for (const round of [activeRound, ...pendingFoldRounds]) {
1494
- if (round?.tail && roundToolCallIds(round).has(toolCallId)) {
1495
- syncTailPosition(round);
1496
- return;
1497
- }
1594
+ if (!round || !roundToolCallIds(round).has(toolCallId)) continue;
1595
+ // 槽位是在消息更新时画的,当时工具组件还不存在:组件到了就补上。
1596
+ // 参数流式更新也走这里,槽位卡跟着换内容。
1597
+ if (round.pendingLiveTool || roundLive(round)) renderRound(round);
1598
+ else if (round.tail) syncTailPosition(round);
1599
+ return;
1498
1600
  }
1499
1601
  };
1500
1602
 
@@ -1638,19 +1740,26 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1638
1740
  }
1639
1741
  let runningTool: any;
1640
1742
  let lastTool: any;
1743
+ let missingTool = false;
1641
1744
  for (const [, message] of round.messages) {
1642
1745
  for (const item of Array.isArray(message?.content) ? message.content : []) {
1643
1746
  if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
1644
1747
  const tool = toolsById.get(item.id);
1645
1748
  // edit/write 保留自己的 compact 行,不进槽位。
1646
- if (EDIT_WRITE_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1749
+ if (OUTER_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1647
1750
  round.liveSlotToolIds.add(item.id);
1648
1751
  liveSlotToolIds.add(item.id);
1649
- if (!tool) continue;
1752
+ // pi 的 message_update 先 updateContent 再建工具组件:这一帧计数已经算上,
1753
+ // 组件要等 updateDisplay 才能进槽位。记下来,组件一到就补画。
1754
+ if (!tool) {
1755
+ missingTool = true;
1756
+ continue;
1757
+ }
1650
1758
  lastTool = tool;
1651
1759
  if (isRunningTool(tool)) runningTool = tool;
1652
1760
  }
1653
1761
  }
1762
+ round.pendingLiveTool = missingTool;
1654
1763
  // 槽位单块:活动思考最新;否则本回合最近的工具(运行中原位增长,完成也留着)。
1655
1764
  const slotTool = thinkingKids.length > 0 ? undefined : (runningTool ?? lastTool);
1656
1765
  const cardItems: Array<{ child?: any; tool?: any }> = slotTool
@@ -1700,12 +1809,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1700
1809
  for (const id of ids) explicitRoundToolIds.delete(id);
1701
1810
  collapseTools(
1702
1811
  (tool) =>
1703
- ids.has(String(tool.toolCallId ?? "")) &&
1704
- !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
1812
+ ids.has(String(tool.toolCallId ?? "")) && !OUTER_TOOLS.has(String(tool.toolName ?? "")),
1705
1813
  );
1706
1814
  }
1707
1815
  for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
1708
1816
  round.liveSlotToolIds.clear();
1817
+ // pendingLiveTool 留给 renderRoundLive 重算:这里清掉的话,组件刚到的那次
1818
+ // 补画会把自己的触发条件抹掉,槽位仍是空的。
1709
1819
 
1710
1820
  for (const [component, message] of round.messages) {
1711
1821
  component.lastMessage = message;
@@ -1727,6 +1837,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1727
1837
  };
1728
1838
  }
1729
1839
 
1840
+ if (round.anchor.expanded === true) {
1841
+ // 没有可汇总内容的回合不进展开态:摘要行不存在,展开只会把正文搬回回合开头。
1842
+ if (!roundHasSummaryContent(round)) {
1843
+ for (const member of round.messages.keys()) member.expanded = false;
1844
+ round.anchor.expanded = false;
1845
+ }
1846
+ }
1730
1847
  if (round.anchor.expanded === true) {
1731
1848
  // 展开内容留在摘要行那个位置:回合最后一个成员(含 write/edit)之后。
1732
1849
  // 放进 anchor 内部会跳到回合起点,盖到中间的 write/edit 上面。
@@ -1748,7 +1865,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1748
1865
  } else {
1749
1866
  // 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
1750
1867
  // 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
1751
- anchorChildren.push(new Spacer(1));
1868
+ // 空行必须与面板同进退(childAtRow 参与面板拆分),用 panelBoundSpacer。
1869
+ anchorChildren.push(panelBoundSpacer());
1752
1870
  anchorChildren.push(
1753
1871
  compactRoundCard(
1754
1872
  cardItems,
@@ -1771,7 +1889,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1771
1889
  const placedToolIds = new Set<string>();
1772
1890
  const placeTool = (id: string, fallbackName?: string) => {
1773
1891
  const tool = toolsById.get(id);
1774
- if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1892
+ if (!tool || OUTER_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1775
1893
  if (placedToolIds.has(id)) return;
1776
1894
  placedToolIds.add(id);
1777
1895
  cardItems.push({ tool });
@@ -1837,7 +1955,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1837
1955
  collapseTools(
1838
1956
  (tool) =>
1839
1957
  ids.has(String(tool.toolCallId ?? "")) &&
1840
- !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) &&
1958
+ !OUTER_TOOLS.has(String(tool.toolName ?? "")) &&
1841
1959
  !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
1842
1960
  );
1843
1961
  flushPanel();
@@ -1869,8 +1987,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1869
1987
  for (const [component, message] of round.messages) {
1870
1988
  if (component === round.anchor) {
1871
1989
  renderAssistantWithoutThinking(component, message);
1872
- // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
1873
- if (summary || round.active) {
1990
+ // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)。
1991
+ // 只剩外层卡的回合同样不挂:摘要行没有可汇总的内容。
1992
+ if ((summary || round.active) && (round.active || roundHasSummaryContent(round))) {
1874
1993
  mountRoundTail(round, (identity) => [
1875
1994
  compactAssistantLineComponent(identity, getSummary, deps.query),
1876
1995
  ]);
@@ -1973,7 +2092,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1973
2092
  round = createRound(this, message);
1974
2093
  roundByComponent.set(this, round);
1975
2094
  } else if (!round) {
1976
- round = activeRound ?? createRound(this, message);
2095
+ // 中间隔着用户消息就另起一回合,不并进上一个回合。
2096
+ round = joinOrCreateRound(this, message);
1977
2097
  roundByComponent.set(this, round);
1978
2098
  }
1979
2099
  round.messages.set(this, message);
@@ -2009,7 +2129,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2009
2129
  if (hasThinking) {
2010
2130
  let round = roundByComponent.get(this);
2011
2131
  if (!round) {
2012
- round = activeRound ?? createRound(this, message);
2132
+ round = joinOrCreateRound(this, message);
2013
2133
  roundByComponent.set(this, round);
2014
2134
  }
2015
2135
  round.messages.set(this, message);
@@ -2035,10 +2155,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2035
2155
  return patch.toolOriginalRender.call(this, width);
2036
2156
  }
2037
2157
  const name = String(this.toolName ?? "");
2038
- if (EDIT_WRITE_TOOLS.has(name)) {
2039
- if (this.executionStarted && (!this.result || this.isPartial === true))
2040
- scheduleAnimation(this);
2041
- return compactEditWriteLines(this, width, deps.writeMetadata);
2158
+ if (OUTER_TOOLS.has(name)) {
2159
+ // edit/write 走 compact 自己的 rich diff;ask_user_question 走 default 模式的专用渲染。
2160
+ // 两者都留在外层,不进摘要、不进展开面板。
2161
+ if (EDIT_WRITE_TOOLS.has(name)) {
2162
+ const pending = !this.result || this.isPartial === true;
2163
+ // 等待窗口(声明流式中或等待派发)也要转,否则这一行冻在第一帧。
2164
+ if (pending && (this.executionStarted || isLiveToolCall(this.toolCallId)))
2165
+ scheduleAnimation(this);
2166
+ return compactEditWriteLines(this, width, deps.writeMetadata);
2167
+ }
2168
+ return patch.toolOriginalRender.call(this, width);
2042
2169
  }
2043
2170
  // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
2044
2171
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
@@ -2054,8 +2181,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2054
2181
 
2055
2182
  patch.toolInstalledUpdateDisplay = function (this: any) {
2056
2183
  const id = String(this.toolCallId ?? "");
2057
- if (patch.active && config.mode === "compact" && expandedRoundToolIds.has(id)) {
2058
- // 用户点开的保留展开;其余(含全局展开被收回的)继续强制折叠。
2184
+ if (
2185
+ patch.active &&
2186
+ config.mode === "compact" &&
2187
+ (expandedRoundToolIds.has(id) || liveSlotToolIds.has(id))
2188
+ ) {
2189
+ // 用户点开的保留展开;其余(含全局展开、live 槽位接管的)继续强制折叠。
2190
+ // 槽位卡在消息更新时就接管了 id,组件后到时 setExpanded 会把它翻开,外层多出一张卡。
2059
2191
  if (this.expanded !== true) explicitRoundToolIds.delete(id);
2060
2192
  else if (!explicitRoundToolIds.has(id)) this.expanded = false;
2061
2193
  }
@@ -2104,7 +2236,6 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2104
2236
  trackedToolComponents.clear();
2105
2237
  hoveredAssistantComponent = undefined;
2106
2238
  resetRounds();
2107
- compactRunStatusGetter = undefined;
2108
2239
  };
2109
2240
 
2110
2241
  assistantPrototype.updateContent = patch.assistantInstalled;
@@ -2161,6 +2292,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2161
2292
  }
2162
2293
  }
2163
2294
  },
2295
+ settleReplayedRound() {
2296
+ if (!patch.active || config.mode !== "compact") return;
2297
+ const round = activeRound;
2298
+ if (!round) return;
2299
+ finalizeRound(round); // clears activeRound — capture it first
2300
+ renderRound(round);
2301
+ },
2164
2302
  shutdown() {
2165
2303
  patch.dispose();
2166
2304
  },