pi-cc-extensions 0.9.12 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,43 +5,28 @@ import {
5
5
  getToolMouseTui,
6
6
  isFullscreenAtBottom,
7
7
  } from "../../renderer/mouse/scroll.ts";
8
- import { formatDuration } from "../../utils/format.ts";
8
+ import { formatDuration, formatTokenCount } from "../../utils/format.ts";
9
+ import {
10
+ resetTokenRate,
11
+ carryTokenRate,
12
+ pauseTokenRate,
13
+ resumeTokenRate,
14
+ markFirstToken,
15
+ sampleTokenDelta,
16
+ recordProviderUsage,
17
+ startMessageSegment,
18
+ totalTokens,
19
+ } from "./token-rate.ts";
9
20
 
10
21
  const REFRESH_INTERVAL_MS = 1_000;
11
22
  /** Elapsed time is only shown once the turn has run this long. */
12
23
  const SHOW_TIMER_AFTER_MS = 3_000;
13
24
 
14
- function formatCount(value: number): string {
15
- return new Intl.NumberFormat("en-US").format(value);
16
- }
17
-
18
- type ContentBlock = {
19
- type?: unknown;
20
- text?: unknown;
21
- thinkingSignature?: { body?: unknown };
22
- };
23
-
24
25
  type StreamMessage = {
25
26
  content?: unknown;
26
27
  usage?: { output?: unknown };
27
28
  };
28
29
 
29
- /** 每个 content index 的可见文本/思考长度;无对应块的 index 保持稀疏洞。 */
30
- function textBlockLengths(message: StreamMessage): number[] {
31
- const content = message.content;
32
- if (!Array.isArray(content)) return [];
33
- const lengths: number[] = [];
34
- for (let index = 0; index < content.length; index++) {
35
- const block = content[index] as ContentBlock;
36
- if (block?.type === "text" && typeof block.text === "string") {
37
- lengths[index] = block.text.length;
38
- } else if (block?.type === "thinking" && block.thinkingSignature?.body) {
39
- lengths[index] = (block.thinkingSignature.body as string).length;
40
- }
41
- }
42
- return lengths;
43
- }
44
-
45
30
  function outputUsage(message: StreamMessage): number {
46
31
  const value = Number(message?.usage?.output);
47
32
  return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
@@ -55,39 +40,24 @@ type WorkingUi = {
55
40
  * Extend Pi's footer working row while preserving its spinner and "Working...":
56
41
  * `⠋ Working... (↓ 1,234 tokens · 12s)`
57
42
  *
58
- * Live tokens use the same chars/4 estimate as pi-claude-code-ui, then switch to
59
- * provider `usage.output` whenever the stream exposes an actual count.
60
- */
43
+ * token 数直接读 token-rate 的累计(估算 → provider usage 同源),不再本地重数。 */
61
44
  export default function (pi: ExtensionAPI): void {
62
45
  let turnActive = false;
63
46
  let agentStartTime = 0;
64
47
  let turnStartTime = 0;
65
- let responseLength = 0;
66
- let responseTextBlockLengths: number[] = [];
67
- let providerOutputTokens = 0;
48
+ /** 当前用户指令的起点;工具调用后的 turn 复用它,直到下一条指令。 */
49
+ let rateRunStartTime = 0;
68
50
  let refreshTimer: ReturnType<typeof setTimeout> | null = null;
69
51
  let lastMessage: string | null = null;
70
52
  let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
71
53
 
72
54
  function tokenCount(): number {
73
- return providerOutputTokens || Math.max(0, Math.round(responseLength / 4));
74
- }
75
-
76
- function setTextBlockLength(index: number, length: number): void {
77
- const previous = responseTextBlockLengths[index] ?? 0;
78
- responseTextBlockLengths[index] = Math.max(0, length);
79
- responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
80
- }
81
-
82
- function resetResponseTracking(message?: StreamMessage): void {
83
- responseTextBlockLengths = message ? textBlockLengths(message) : [];
84
- responseLength = responseTextBlockLengths.reduce((sum, length) => sum + length, 0);
85
- providerOutputTokens = message ? outputUsage(message) : 0;
55
+ return totalTokens();
86
56
  }
87
57
 
88
58
  function updateProviderUsage(message: StreamMessage): void {
89
59
  const output = outputUsage(message);
90
- if (output > 0) providerOutputTokens = output;
60
+ if (output > 0) recordProviderUsage(output);
91
61
  }
92
62
 
93
63
  /**
@@ -103,7 +73,7 @@ export default function (pi: ExtensionAPI): void {
103
73
  function buildWorkingMessage(): string {
104
74
  const parts: string[] = [];
105
75
  const tokens = tokenCount();
106
- if (tokens > 0) parts.push(`↓ ${formatCount(tokens)} tokens`);
76
+ if (tokens > 0) parts.push(`↓ ${formatTokenCount(tokens)} tokens`);
107
77
  // compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
108
78
  const compactStatus = compactMirrorText();
109
79
  if (compactStatus) return [compactStatus, ...parts].join(" · ");
@@ -179,7 +149,7 @@ export default function (pi: ExtensionAPI): void {
179
149
  stopRefreshLoop();
180
150
  agentStartTime = 0;
181
151
  turnStartTime = 0;
182
- resetResponseTracking();
152
+ // 不重置 token-rate:footer 定格显示上一回合速率
183
153
  restoreDefaultWorkingMessage();
184
154
  }
185
155
 
@@ -187,12 +157,19 @@ export default function (pi: ExtensionAPI): void {
187
157
  if (!agentStartTime) agentStartTime = Date.now();
188
158
  });
189
159
 
190
- pi.on("turn_start", async (_event, ctx) => {
160
+ pi.on("turn_start", async (event, ctx) => {
191
161
  turnActive = true;
192
162
  activeCtx = ctx;
193
- turnStartTime = Date.now();
163
+ // 事件时间戳在 handler 之前产生;用 Date.now() 会和紧随的采样落在同一毫秒,分母变成 0。
164
+ turnStartTime = event?.timestamp || Date.now();
194
165
  if (!agentStartTime) agentStartTime = turnStartTime;
195
- resetResponseTracking();
166
+ // 工具调用后的 turn 属于同一条用户指令:计时、TTFT 与累计 token 保持到下一条指令。
167
+ // 速率起点用本指令第一个 turn 的时间戳;before_agent_start 只当指令 id。
168
+ if (rateRunStartTime !== agentStartTime) {
169
+ rateRunStartTime = agentStartTime;
170
+ resetTokenRate(turnStartTime);
171
+ }
172
+ startMessageSegment(turnStartTime);
196
173
  syncWorkingMessage(true);
197
174
  scheduleRefreshTick();
198
175
  });
@@ -203,24 +180,25 @@ export default function (pi: ExtensionAPI): void {
203
180
  if (!evt) return;
204
181
 
205
182
  if (evt.type === "start") {
206
- resetResponseTracking(evt.partial);
207
- } else if (evt.type === "thinking_start" || evt.type === "text_start") {
208
- setTextBlockLength(evt.contentIndex, 0);
183
+ // 新 assistant 消息起点:速率分段已在 turn_start 处理。
184
+ } else if (
185
+ evt.type === "thinking_start" ||
186
+ evt.type === "text_start" ||
187
+ evt.type === "toolcall_start"
188
+ ) {
189
+ // TTFT 的首块含 toolcall_start:模型直接发起 tool call(thinking/text 省略)时也算首 token
190
+ markFirstToken();
191
+ startMessageSegment();
209
192
  updateProviderUsage(evt.partial);
210
193
  } else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
211
- const add = typeof evt.delta === "string" ? evt.delta.length : 0;
212
- setTextBlockLength(evt.contentIndex, (responseTextBlockLengths[evt.contentIndex] ?? 0) + add);
194
+ if (typeof evt.delta === "string") sampleTokenDelta(evt.delta);
213
195
  updateProviderUsage(evt.partial);
214
- } else if (evt.type === "text_end") {
215
- setTextBlockLength(
216
- evt.contentIndex,
217
- typeof evt.content === "string" ? evt.content.length : 0,
218
- );
196
+ } else if (evt.type === "text_end" || evt.type === "thinking_end") {
219
197
  updateProviderUsage(evt.partial);
220
198
  } else if (evt.type === "done") {
221
- resetResponseTracking(evt.message);
199
+ updateProviderUsage(evt.message);
222
200
  } else if (evt.type === "error") {
223
- resetResponseTracking(evt.error);
201
+ updateProviderUsage(evt.error);
224
202
  } else {
225
203
  updateProviderUsage(evt.partial);
226
204
  }
@@ -229,11 +207,20 @@ export default function (pi: ExtensionAPI): void {
229
207
  scheduleRefreshTick();
230
208
  });
231
209
 
210
+ pi.on("tool_execution_start", async () => {
211
+ pauseTokenRate();
212
+ });
213
+
214
+ pi.on("tool_execution_end", async () => {
215
+ resumeTokenRate();
216
+ });
217
+
232
218
  pi.on("turn_end", async (_event, ctx) => {
233
219
  turnActive = false;
234
220
  activeCtx = ctx;
235
221
  stopRefreshLoop();
236
- resetResponseTracking();
222
+ // 本段 token 结转进累计,下一段 turn 继续累加;此处不清速率。
223
+ carryTokenRate();
237
224
  // No completion message: return immediately to Pi's default idle state.
238
225
  restoreDefaultWorkingMessage();
239
226
  });
@@ -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";
@@ -46,7 +53,12 @@ import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
46
53
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
47
54
  import { isToolCallHovered } from "./mouse/hover.ts";
48
55
  import { getToolMouseTui } from "./mouse/scroll.ts";
49
- import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
56
+ import {
57
+ insetComponent,
58
+ isLiveToolCall,
59
+ renderExpandedToolResult,
60
+ scheduleAnimation,
61
+ } from "./tool/result.ts";
50
62
  import { paddedBackgroundRow } from "./tool/grouping.ts";
51
63
  import { formatDisplayPath } from "./tool/names.ts";
52
64
  import {
@@ -82,6 +94,13 @@ export type CompactThinkingQuery = {
82
94
 
83
95
  const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
84
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
+
85
104
  /**
86
105
  * 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
87
106
  * 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
@@ -206,7 +225,7 @@ function buildMessagesSummary(
206
225
  for (const item of content) {
207
226
  if (item?.type !== "toolCall") continue;
208
227
  const rawName = typeof item.name === "string" ? item.name : "tool";
209
- if (EDIT_WRITE_TOOLS.has(rawName)) continue;
228
+ if (OUTER_TOOLS.has(rawName)) continue;
210
229
  const name = sanitizeToolResultText(rawName);
211
230
  if (rawName.split(".").pop() === "read") {
212
231
  const args = item.arguments ?? item.args ?? {};
@@ -675,7 +694,7 @@ function compactEditWriteLine(
675
694
  component: any,
676
695
  width: number,
677
696
  writeMetadata?: WriteExecutionMetadataStore,
678
- options: { hint?: boolean; flushLeft?: boolean } = {},
697
+ options: { hint?: boolean; flushLeft?: boolean; showStats?: boolean } = {},
679
698
  ): string[] {
680
699
  const theme = themeOf();
681
700
  const name = String(component.toolName ?? "tool");
@@ -692,7 +711,8 @@ function compactEditWriteLine(
692
711
  const iconColor = isError ? "error" : isPending ? "accent" : "success";
693
712
  let statsText = "";
694
713
  let statsStyled = "";
695
- if (!isError && !isPending) {
714
+ // 统计只显示一处:正文自带 +A -D 时标题不再重复(showStats=false)。
715
+ if (!isError && !isPending && options.showStats !== false) {
696
716
  const stats =
697
717
  name === "edit"
698
718
  ? countEditDiffStats(component.result?.details)
@@ -730,6 +750,12 @@ function compactEditWriteLine(
730
750
  return ["", truncateToWidth(line, width, "")];
731
751
  }
732
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
+
733
759
  /** expanded 写进 rich diff 闭包,折叠/展开分槽缓存,避免每帧 parseDiff。 */
734
760
  const compactRichDiffCache = new WeakMap<
735
761
  object,
@@ -853,7 +879,12 @@ function paintCompactEditWrite(
853
879
  component.resultRendererComponent = candidate;
854
880
  }
855
881
 
856
- 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
+ });
857
888
  if (!hasRich && !expanded) {
858
889
  return title;
859
890
  }
@@ -893,6 +924,7 @@ function paintCompactEditWrite(
893
924
  return compactEditWriteLine(component, innerWidth, writeMetadata, {
894
925
  hint: false,
895
926
  flushLeft: true,
927
+ showStats,
896
928
  }).slice(1);
897
929
  },
898
930
  invalidate() {},
@@ -1006,7 +1038,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
1006
1038
 
1007
1039
  /**
1008
1040
  * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
1009
- * 提示有两处来源——我们的 showMoreHintText(`… +N more lines · click to show more`)与
1041
+ * 提示有两处来源——我们的 showMoreHintText(`… N more lines · click to show more`)与
1010
1042
  * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
1011
1043
  * 只去掉动作词,`(N more lines)` 这类计数保留。
1012
1044
  */
@@ -1367,6 +1399,54 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1367
1399
  return found;
1368
1400
  };
1369
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
+
1370
1450
  /** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
1371
1451
  const tailHostContainer = (tail: MountedRoundTail): any =>
1372
1452
  containerOfHost(tail.host) ??
@@ -1449,13 +1529,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1449
1529
  const children = container.children as any[];
1450
1530
  const current = children.indexOf(tail.host);
1451
1531
  if (current >= 0) children.splice(current, 1);
1452
- const ids = roundToolCallIds(round);
1453
- const matchesTool = (value: any): boolean =>
1454
- typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1455
- const isMember = (value: any): boolean =>
1456
- round.messages.has(value) ||
1457
- matchesTool(value) ||
1458
- (Array.isArray(value?.children) && value.children.some(matchesTool));
1532
+ const isMember = roundMemberMatcher(round);
1459
1533
  let first = -1;
1460
1534
  let last = -1;
1461
1535
  for (let i = 0; i < children.length; i++) {
@@ -1466,7 +1540,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1466
1540
  const lastMember = last >= 0 ? children[last] : undefined;
1467
1541
  const intruders: any[] = [];
1468
1542
  for (let i = last - 1; i > first; i--) {
1469
- 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]);
1470
1546
  }
1471
1547
  if (lastMember) last = children.indexOf(lastMember);
1472
1548
  const insertAt = last < 0 ? children.length : last + 1;
@@ -1648,7 +1724,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1648
1724
  if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
1649
1725
  const tool = toolsById.get(item.id);
1650
1726
  // edit/write 保留自己的 compact 行,不进槽位。
1651
- if (EDIT_WRITE_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1727
+ if (OUTER_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1652
1728
  round.liveSlotToolIds.add(item.id);
1653
1729
  liveSlotToolIds.add(item.id);
1654
1730
  if (!tool) continue;
@@ -1705,8 +1781,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1705
1781
  for (const id of ids) explicitRoundToolIds.delete(id);
1706
1782
  collapseTools(
1707
1783
  (tool) =>
1708
- ids.has(String(tool.toolCallId ?? "")) &&
1709
- !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
1784
+ ids.has(String(tool.toolCallId ?? "")) && !OUTER_TOOLS.has(String(tool.toolName ?? "")),
1710
1785
  );
1711
1786
  }
1712
1787
  for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
@@ -1776,7 +1851,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1776
1851
  const placedToolIds = new Set<string>();
1777
1852
  const placeTool = (id: string, fallbackName?: string) => {
1778
1853
  const tool = toolsById.get(id);
1779
- if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1854
+ if (!tool || OUTER_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1780
1855
  if (placedToolIds.has(id)) return;
1781
1856
  placedToolIds.add(id);
1782
1857
  cardItems.push({ tool });
@@ -1842,7 +1917,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1842
1917
  collapseTools(
1843
1918
  (tool) =>
1844
1919
  ids.has(String(tool.toolCallId ?? "")) &&
1845
- !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) &&
1920
+ !OUTER_TOOLS.has(String(tool.toolName ?? "")) &&
1846
1921
  !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
1847
1922
  );
1848
1923
  flushPanel();
@@ -1978,7 +2053,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1978
2053
  round = createRound(this, message);
1979
2054
  roundByComponent.set(this, round);
1980
2055
  } else if (!round) {
1981
- round = activeRound ?? createRound(this, message);
2056
+ // 中间隔着用户消息就另起一回合,不并进上一个回合。
2057
+ round = joinOrCreateRound(this, message);
1982
2058
  roundByComponent.set(this, round);
1983
2059
  }
1984
2060
  round.messages.set(this, message);
@@ -2014,7 +2090,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2014
2090
  if (hasThinking) {
2015
2091
  let round = roundByComponent.get(this);
2016
2092
  if (!round) {
2017
- round = activeRound ?? createRound(this, message);
2093
+ round = joinOrCreateRound(this, message);
2018
2094
  roundByComponent.set(this, round);
2019
2095
  }
2020
2096
  round.messages.set(this, message);
@@ -2040,10 +2116,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2040
2116
  return patch.toolOriginalRender.call(this, width);
2041
2117
  }
2042
2118
  const name = String(this.toolName ?? "");
2043
- if (EDIT_WRITE_TOOLS.has(name)) {
2044
- if (this.executionStarted && (!this.result || this.isPartial === true))
2045
- scheduleAnimation(this);
2046
- 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);
2047
2130
  }
2048
2131
  // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
2049
2132
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];