pi-cc-extensions 0.9.11 → 1.0.0

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.
@@ -0,0 +1,162 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { FooterGitStatsMode } from "../../config/config.ts";
4
+
5
+ export type GitStats = { add: number; del: number; status?: string };
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const BRANCH_BASE_REFS = ["refs/remotes/origin/HEAD", "refs/heads/main", "refs/heads/master"];
9
+
10
+ export function parseGitStats(stdout: string): GitStats {
11
+ let add = 0;
12
+ let del = 0;
13
+ for (const line of stdout.split("\n")) {
14
+ const match = line.match(/^(\d+)\s+(\d+)/);
15
+ if (match) {
16
+ add += Number(match[1]);
17
+ del += Number(match[2]);
18
+ }
19
+ }
20
+ return { add, del };
21
+ }
22
+
23
+ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
24
+
25
+ /**
26
+ * git status --porcelain --branch 的第一行带分支头(## branch...origin/branch [ahead N, behind M]),
27
+ * 其余行是 XY 文件状态。返回 Starship 风格的状态符号串(= ! ? ⇡⇣),无变化时 undefined。
28
+ */
29
+ export function parseGitStatus(stdout: string): string | undefined {
30
+ let conflicted = false;
31
+ let modified = false;
32
+ let untracked = false;
33
+ let ahead: number | undefined;
34
+ let behind: number | undefined;
35
+ for (const line of stdout.split("\n")) {
36
+ if (line.startsWith("## ")) {
37
+ ahead = Number(line.match(/ahead (\d+)/)?.[1]) || undefined;
38
+ behind = Number(line.match(/behind (\d+)/)?.[1]) || undefined;
39
+ continue;
40
+ }
41
+ const code = line.slice(0, 2);
42
+ if (code === "??") untracked = true;
43
+ else if (CONFLICT_CODES.has(code)) {
44
+ // 冲突文件必然也有未提交内容,! 与 = 同时出现
45
+ conflicted = true;
46
+ modified = true;
47
+ } else if (line.trim()) modified = true;
48
+ }
49
+ let status = "";
50
+ if (conflicted) status += "=";
51
+ if (modified) status += "!";
52
+ if (untracked) status += "?";
53
+ if (ahead) status += `⇡${ahead}`;
54
+ if (behind) status += `⇣${behind}`;
55
+ return status || undefined;
56
+ }
57
+
58
+ /** Read only local refs and tracked files; never fetch or change the index. */
59
+ export async function readGitStats(
60
+ cwd: string,
61
+ mode: FooterGitStatsMode,
62
+ signal?: AbortSignal,
63
+ ): Promise<GitStats | undefined> {
64
+ const git = async (args: string[]) => {
65
+ const { stdout } = await execFileAsync("git", args, {
66
+ cwd,
67
+ timeout: 2000,
68
+ maxBuffer: 16 * 1024 * 1024,
69
+ signal,
70
+ });
71
+ return stdout.trim();
72
+ };
73
+ try {
74
+ let base = "HEAD";
75
+ if (mode === "branch") {
76
+ let baseCommit: string | undefined;
77
+ for (const ref of BRANCH_BASE_REFS) {
78
+ try {
79
+ baseCommit = await git(["rev-parse", "--verify", `${ref}^{commit}`]);
80
+ break;
81
+ } catch {
82
+ if (signal?.aborted) return undefined;
83
+ // Missing/dangling remote HEAD: try the local default branches.
84
+ }
85
+ }
86
+ if (!baseCommit) return undefined;
87
+ base = await git(["merge-base", "HEAD", baseCommit]);
88
+ }
89
+ // One comparison includes committed + staged + unstaged net changes,
90
+ // without double-counting edits that are later reverted. Untracked files are excluded.
91
+ const [numstat, statusOut] = await Promise.all([
92
+ git(["diff", "--numstat", base, "--"]),
93
+ git(["status", "--porcelain", "--branch"]).catch(() => ""),
94
+ ]);
95
+ if (signal?.aborted) return undefined;
96
+ const stats = parseGitStats(numstat);
97
+ const status = parseGitStatus(statusOut);
98
+ return status ? { ...stats, status } : stats;
99
+ } catch {
100
+ // Non-repository, unborn HEAD, unrelated histories, timeout, etc.
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /** Coalesce overlapping refreshes and reject stale results after a mode/branch change. */
106
+ export function createGitStatsRefresher(options: {
107
+ getMode: () => FooterGitStatsMode;
108
+ query: (mode: FooterGitStatsMode, signal: AbortSignal) => Promise<GitStats | undefined>;
109
+ onChange: (stats: GitStats | undefined) => void;
110
+ }): { refresh: (reset?: boolean) => void; dispose: () => void } {
111
+ let stats: GitStats | undefined;
112
+ let mode = options.getMode();
113
+ let generation = 0;
114
+ let running = false;
115
+ let pending = false;
116
+ let disposed = false;
117
+ let controller: AbortController | undefined;
118
+ const publish = (next: GitStats | undefined) => {
119
+ if (stats?.add === next?.add && stats?.del === next?.del && stats?.status === next?.status)
120
+ return;
121
+ stats = next;
122
+ options.onChange(next);
123
+ };
124
+ const refresh = (reset = false) => {
125
+ if (disposed) return;
126
+ const nextMode = options.getMode();
127
+ if (reset || mode !== nextMode) publish(undefined);
128
+ mode = nextMode;
129
+ const request = ++generation;
130
+ if (running) {
131
+ pending = true;
132
+ return;
133
+ }
134
+ running = true;
135
+ const queryController = new AbortController();
136
+ controller = queryController;
137
+ void (async () => {
138
+ let next: GitStats | undefined;
139
+ try {
140
+ next = await options.query(nextMode, queryController.signal);
141
+ } catch {
142
+ // Failed queries clear the old numbers rather than leaving a stale chip.
143
+ } finally {
144
+ if (!disposed && request === generation && nextMode === options.getMode()) publish(next);
145
+ running = false;
146
+ controller = undefined;
147
+ if (pending && !disposed) {
148
+ pending = false;
149
+ refresh();
150
+ }
151
+ }
152
+ })();
153
+ };
154
+ return {
155
+ refresh,
156
+ dispose: () => {
157
+ disposed = true;
158
+ generation++;
159
+ controller?.abort();
160
+ },
161
+ };
162
+ }
@@ -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,11 +30,14 @@
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";
@@ -42,10 +49,16 @@ import {
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 {
57
+ insetComponent,
58
+ isLiveToolCall,
59
+ renderExpandedToolResult,
60
+ scheduleAnimation,
61
+ } from "./tool/result.ts";
49
62
  import { paddedBackgroundRow } from "./tool/grouping.ts";
50
63
  import { formatDisplayPath } from "./tool/names.ts";
51
64
  import {
@@ -81,6 +94,13 @@ export type CompactThinkingQuery = {
81
94
 
82
95
  const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
83
96
 
97
+ /**
98
+ * 留在外层的工具:不进摘要计数,折叠态也单独成行。
99
+ * edit/write 走 compact 自己的 rich diff;ask_user_question 走 default 模式的专用渲染
100
+ *(标题 (+N) 加折叠问答树)。
101
+ */
102
+ const OUTER_TOOLS = new Set([...EDIT_WRITE_TOOLS, ASK_USER_QUESTION_TOOL_NAME]);
103
+
84
104
  /**
85
105
  * 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
86
106
  * 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
@@ -205,7 +225,7 @@ function buildMessagesSummary(
205
225
  for (const item of content) {
206
226
  if (item?.type !== "toolCall") continue;
207
227
  const rawName = typeof item.name === "string" ? item.name : "tool";
208
- if (EDIT_WRITE_TOOLS.has(rawName)) continue;
228
+ if (OUTER_TOOLS.has(rawName)) continue;
209
229
  const name = sanitizeToolResultText(rawName);
210
230
  if (rawName.split(".").pop() === "read") {
211
231
  const args = item.arguments ?? item.args ?? {};
@@ -674,7 +694,7 @@ function compactEditWriteLine(
674
694
  component: any,
675
695
  width: number,
676
696
  writeMetadata?: WriteExecutionMetadataStore,
677
- options: { hint?: boolean; flushLeft?: boolean } = {},
697
+ options: { hint?: boolean; flushLeft?: boolean; showStats?: boolean } = {},
678
698
  ): string[] {
679
699
  const theme = themeOf();
680
700
  const name = String(component.toolName ?? "tool");
@@ -691,7 +711,8 @@ function compactEditWriteLine(
691
711
  const iconColor = isError ? "error" : isPending ? "accent" : "success";
692
712
  let statsText = "";
693
713
  let statsStyled = "";
694
- if (!isError && !isPending) {
714
+ // 统计只显示一处:正文自带 +A -D 时标题不再重复(showStats=false)。
715
+ if (!isError && !isPending && options.showStats !== false) {
695
716
  const stats =
696
717
  name === "edit"
697
718
  ? countEditDiffStats(component.result?.details)
@@ -710,8 +731,9 @@ function compactEditWriteLine(
710
731
  // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
711
732
  const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
712
733
  const namePart = theme.fg("toolTitle", name);
713
- const hintText =
714
- options.hint !== false && component.expanded !== true ? ` · ${showMoreHintText()}` : "";
734
+ const clickable = options.hint !== false && component.expanded !== true;
735
+ const clickText = clickable ? showMoreHintText() : "";
736
+ const hintText = clickable ? ` · ${clickText}` : "";
715
737
  const fixedWidth =
716
738
  visibleWidth(iconPart) +
717
739
  visibleWidth(namePart) +
@@ -720,10 +742,20 @@ function compactEditWriteLine(
720
742
  const pathWidth = Math.max(0, width - fixedWidth - (path ? 1 : 0));
721
743
  const pathPart =
722
744
  pathWidth > 0 && path ? ` ${formatDisplayPath(path, component.cwd, pathWidth)}` : "";
723
- const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintText ? theme.fg("dim", hintText) : ""}`;
745
+ // 圆点保持 dim;hover 只高亮可点击文字,与 compact 摘要行一致
746
+ const hintPart = clickable
747
+ ? `${theme.fg("dim", " · ")}${theme.fg(isToolCallHovered(component.toolCallId) ? "text" : "dim", clickText)}`
748
+ : "";
749
+ const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintPart}`;
724
750
  return ["", truncateToWidth(line, width, "")];
725
751
  }
726
752
 
753
+ /** 正文自带 +A -D 的声明:diff 组件自报,折叠行数/宽度这些细节由它自己判断。 */
754
+ function richDiffBodyShowsStats(component: unknown): boolean {
755
+ const probe = (component as { bodyShowsStats?: () => boolean } | undefined)?.bodyShowsStats;
756
+ return typeof probe === "function" && probe.call(component) === true;
757
+ }
758
+
727
759
  /** expanded 写进 rich diff 闭包,折叠/展开分槽缓存,避免每帧 parseDiff。 */
728
760
  const compactRichDiffCache = new WeakMap<
729
761
  object,
@@ -735,6 +767,7 @@ type CompactEditPaintHit = {
735
767
  theme: unknown;
736
768
  /** 展开卡的背景槽位同样参与命中判断。 */
737
769
  bgSlot: string;
770
+ diffConfigKey: string;
738
771
  expanded: boolean;
739
772
  result: unknown;
740
773
  isPartial: boolean;
@@ -766,6 +799,7 @@ function compactEditWriteLines(
766
799
  const hover = isToolCallHovered(component.toolCallId);
767
800
  const ioHover = compactEditIoHover(component);
768
801
  const theme = themeOf();
802
+ const diffConfigKey = displayConfigCacheKey(getToolDisplayConfig());
769
803
  if (!pending) {
770
804
  const hit = compactEditPaintCache.get(component);
771
805
  if (
@@ -773,6 +807,7 @@ function compactEditWriteLines(
773
807
  hit.width === width &&
774
808
  hit.theme === theme &&
775
809
  hit.bgSlot === config.expandedCardBackground &&
810
+ hit.diffConfigKey === diffConfigKey &&
776
811
  hit.expanded === expanded &&
777
812
  hit.result === component.result &&
778
813
  hit.isPartial === isPartial &&
@@ -789,6 +824,7 @@ function compactEditWriteLines(
789
824
  width,
790
825
  theme,
791
826
  bgSlot: config.expandedCardBackground,
827
+ diffConfigKey,
792
828
  expanded,
793
829
  result: component.result,
794
830
  isPartial,
@@ -843,7 +879,12 @@ function paintCompactEditWrite(
843
879
  component.resultRendererComponent = candidate;
844
880
  }
845
881
 
846
- const title = compactEditWriteLine(component, width, writeMetadata, { hint: !hasRich });
882
+ // 统计只显示一处:正文里已经有 +A -D(diff 头行/0 行摘要行)时标题不再重复。
883
+ const showStats = !(hasRich && richDiffBodyShowsStats(candidate));
884
+ const title = compactEditWriteLine(component, width, writeMetadata, {
885
+ hint: !hasRich,
886
+ showStats,
887
+ });
847
888
  if (!hasRich && !expanded) {
848
889
  return title;
849
890
  }
@@ -883,6 +924,7 @@ function paintCompactEditWrite(
883
924
  return compactEditWriteLine(component, innerWidth, writeMetadata, {
884
925
  hint: false,
885
926
  flushLeft: true,
927
+ showStats,
886
928
  }).slice(1);
887
929
  },
888
930
  invalidate() {},
@@ -996,7 +1038,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
996
1038
 
997
1039
  /**
998
1040
  * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
999
- * 提示有两处来源——我们的 showMoreHintText(`… +N more lines · click to show more`)与
1041
+ * 提示有两处来源——我们的 showMoreHintText(`… N more lines · click to show more`)与
1000
1042
  * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
1001
1043
  * 只去掉动作词,`(N more lines)` 这类计数保留。
1002
1044
  */
@@ -1357,6 +1399,54 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1357
1399
  return found;
1358
1400
  };
1359
1401
 
1402
+ /** 回合成员判定:本回合的 assistant 组件,或归属本回合的工具(含分组容器)。 */
1403
+ const roundMemberMatcher = (round: CompactRound): ((value: any) => boolean) => {
1404
+ const ids = roundToolCallIds(round);
1405
+ const matchesTool = (value: any): boolean =>
1406
+ typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1407
+ return (value: any): boolean =>
1408
+ round.messages.has(value) ||
1409
+ matchesTool(value) ||
1410
+ (Array.isArray(value?.children) && value.children.some(matchesTool));
1411
+ };
1412
+
1413
+ /** 用户消息(含 skill 调用块)是回合的硬边界。 */
1414
+ const isUserMessageComponent = (value: any): boolean =>
1415
+ value instanceof UserMessageComponent || value instanceof SkillInvocationMessageComponent;
1416
+
1417
+ /**
1418
+ * 新组件与回合已有成员之间是否隔着一条用户消息。隔着就说明这是下一回合的内容:
1419
+ * steer 消息插进了回合中间,或上一回合没有正文就收尾、用户又问了新问题。
1420
+ * 组件还没挂进容器时判断不了,按不隔开处理(会话重放的 refresh 会再求值一次)。
1421
+ */
1422
+ const crossesUserMessage = (round: CompactRound, component: any): boolean => {
1423
+ const container = tailContainerOf(round.anchor);
1424
+ const children = container?.children;
1425
+ if (!Array.isArray(children)) return false;
1426
+ const index = children.indexOf(component);
1427
+ if (index < 0) return false;
1428
+ const isMember = roundMemberMatcher(round);
1429
+ for (let i = index - 1; i >= 0; i--) {
1430
+ if (isMember(children[i])) return false;
1431
+ if (isUserMessageComponent(children[i])) return true;
1432
+ }
1433
+ return false;
1434
+ };
1435
+
1436
+ /**
1437
+ * 可以并入的活动回合。被用户消息隔开时返回 undefined,调用方收掉旧回合另起一个,
1438
+ * 保证回合不跨用户消息——否则区间里的用户消息会被当成外来行挪到回合末尾之后。
1439
+ */
1440
+ const joinableActiveRound = (component: any): CompactRound | undefined =>
1441
+ activeRound && !crossesUserMessage(activeRound, component) ? activeRound : undefined;
1442
+
1443
+ /** 取并入目标;旧回合被用户消息隔开时先收尾(静默期一转场,新回合立即接手)。 */
1444
+ const joinOrCreateRound = (component: any, message: any): CompactRound => {
1445
+ const joinable = joinableActiveRound(component);
1446
+ if (!joinable && activeRound) finishRound(activeRound);
1447
+ return joinable ?? createRound(component, message);
1448
+ };
1449
+
1360
1450
  /** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
1361
1451
  const tailHostContainer = (tail: MountedRoundTail): any =>
1362
1452
  containerOfHost(tail.host) ??
@@ -1439,13 +1529,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1439
1529
  const children = container.children as any[];
1440
1530
  const current = children.indexOf(tail.host);
1441
1531
  if (current >= 0) children.splice(current, 1);
1442
- const ids = roundToolCallIds(round);
1443
- const matchesTool = (value: any): boolean =>
1444
- typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1445
- const isMember = (value: any): boolean =>
1446
- round.messages.has(value) ||
1447
- matchesTool(value) ||
1448
- (Array.isArray(value?.children) && value.children.some(matchesTool));
1532
+ const isMember = roundMemberMatcher(round);
1449
1533
  let first = -1;
1450
1534
  let last = -1;
1451
1535
  for (let i = 0; i < children.length; i++) {
@@ -1456,7 +1540,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1456
1540
  const lastMember = last >= 0 ? children[last] : undefined;
1457
1541
  const intruders: any[] = [];
1458
1542
  for (let i = last - 1; i > first; i--) {
1459
- if (!isMember(children[i])) intruders.unshift(children.splice(i, 1)[0]);
1543
+ // 用户消息永远留在原地:挪走会把别人的消息挪到用户消息下面去。
1544
+ if (isMember(children[i]) || isUserMessageComponent(children[i])) continue;
1545
+ intruders.unshift(children.splice(i, 1)[0]);
1460
1546
  }
1461
1547
  if (lastMember) last = children.indexOf(lastMember);
1462
1548
  const insertAt = last < 0 ? children.length : last + 1;
@@ -1638,7 +1724,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1638
1724
  if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
1639
1725
  const tool = toolsById.get(item.id);
1640
1726
  // edit/write 保留自己的 compact 行,不进槽位。
1641
- if (EDIT_WRITE_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1727
+ if (OUTER_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1642
1728
  round.liveSlotToolIds.add(item.id);
1643
1729
  liveSlotToolIds.add(item.id);
1644
1730
  if (!tool) continue;
@@ -1695,8 +1781,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1695
1781
  for (const id of ids) explicitRoundToolIds.delete(id);
1696
1782
  collapseTools(
1697
1783
  (tool) =>
1698
- ids.has(String(tool.toolCallId ?? "")) &&
1699
- !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
1784
+ ids.has(String(tool.toolCallId ?? "")) && !OUTER_TOOLS.has(String(tool.toolName ?? "")),
1700
1785
  );
1701
1786
  }
1702
1787
  for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
@@ -1766,7 +1851,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1766
1851
  const placedToolIds = new Set<string>();
1767
1852
  const placeTool = (id: string, fallbackName?: string) => {
1768
1853
  const tool = toolsById.get(id);
1769
- if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1854
+ if (!tool || OUTER_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1770
1855
  if (placedToolIds.has(id)) return;
1771
1856
  placedToolIds.add(id);
1772
1857
  cardItems.push({ tool });
@@ -1832,7 +1917,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1832
1917
  collapseTools(
1833
1918
  (tool) =>
1834
1919
  ids.has(String(tool.toolCallId ?? "")) &&
1835
- !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) &&
1920
+ !OUTER_TOOLS.has(String(tool.toolName ?? "")) &&
1836
1921
  !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
1837
1922
  );
1838
1923
  flushPanel();
@@ -1968,7 +2053,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1968
2053
  round = createRound(this, message);
1969
2054
  roundByComponent.set(this, round);
1970
2055
  } else if (!round) {
1971
- round = activeRound ?? createRound(this, message);
2056
+ // 中间隔着用户消息就另起一回合,不并进上一个回合。
2057
+ round = joinOrCreateRound(this, message);
1972
2058
  roundByComponent.set(this, round);
1973
2059
  }
1974
2060
  round.messages.set(this, message);
@@ -2004,7 +2090,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2004
2090
  if (hasThinking) {
2005
2091
  let round = roundByComponent.get(this);
2006
2092
  if (!round) {
2007
- round = activeRound ?? createRound(this, message);
2093
+ round = joinOrCreateRound(this, message);
2008
2094
  roundByComponent.set(this, round);
2009
2095
  }
2010
2096
  round.messages.set(this, message);
@@ -2030,10 +2116,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2030
2116
  return patch.toolOriginalRender.call(this, width);
2031
2117
  }
2032
2118
  const name = String(this.toolName ?? "");
2033
- if (EDIT_WRITE_TOOLS.has(name)) {
2034
- if (this.executionStarted && (!this.result || this.isPartial === true))
2035
- scheduleAnimation(this);
2036
- return compactEditWriteLines(this, width, deps.writeMetadata);
2119
+ if (OUTER_TOOLS.has(name)) {
2120
+ // edit/write 走 compact 自己的 rich diff;ask_user_question 走 default 模式的专用渲染。
2121
+ // 两者都留在外层,不进摘要、不进展开面板。
2122
+ if (EDIT_WRITE_TOOLS.has(name)) {
2123
+ const pending = !this.result || this.isPartial === true;
2124
+ // 等待窗口(声明流式中或等待派发)也要转,否则这一行冻在第一帧。
2125
+ if (pending && (this.executionStarted || isLiveToolCall(this.toolCallId)))
2126
+ scheduleAnimation(this);
2127
+ return compactEditWriteLines(this, width, deps.writeMetadata);
2128
+ }
2129
+ return patch.toolOriginalRender.call(this, width);
2037
2130
  }
2038
2131
  // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
2039
2132
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];