pi-cc-extensions 1.0.2 → 1.0.4

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.
@@ -432,6 +432,8 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
432
432
  });
433
433
 
434
434
  pi.on("before_agent_start", async (event, ctx) => {
435
+ // Pi 1.2.0 custom events have no prompt. Older hosts omit messageType.
436
+ if (!("prompt" in event)) return;
435
437
  const referenceIds = extractSessionReferenceIds(event.prompt);
436
438
  if (referenceIds.length === 0) return;
437
439
 
@@ -208,6 +208,8 @@ export default function agentAutocompleteExtension(pi: ExtensionAPI): void {
208
208
 
209
209
  // Inject as a request message, not systemPrompt, so the cached prefix stays stable.
210
210
  pi.on("before_agent_start", async (event, _ctx) => {
211
+ // Pi 1.2.0 custom events have no prompt. Older hosts omit messageType.
212
+ if (!("prompt" in event)) return;
211
213
  const agents = getAgents();
212
214
  if (agents.length === 0) return;
213
215
 
@@ -444,6 +444,7 @@ const createCustomFooterFactory =
444
444
  displayTokenRate(config.footerTokenRateMode),
445
445
  currentTtftMs(),
446
446
  config.footerTokenRateMode,
447
+ config.footerTokenRateTtft,
447
448
  )
448
449
  : "";
449
450
  const rateChip = rateLabel ? theme.fg("dim", rateLabel) : "";
@@ -182,11 +182,23 @@ export function currentTtftMs(): number {
182
182
  return Math.max(0, firstTokenAt - runStartTime);
183
183
  }
184
184
 
185
- /** `avg 1.2k tok/s (TTFT: 812ms)`;无数据显示空串,未出首 token 时无括号段。 */
186
- export function formatTokenRate(rate: number, ttftMs = 0, mode: TokenRateMode = "average"): string {
185
+ /** 不足 1 秒用毫秒,否则用一位小数的秒。 */
186
+ export function formatTtft(ttftMs: number): string {
187
+ if (ttftMs < 1000) return `${Math.round(ttftMs)}ms`;
188
+ return `${(ttftMs / 1000).toFixed(1)}s`;
189
+ }
190
+
191
+ /** `avg 1.2k tok/s (TTFT: 812ms)`;无数据显示空串,未出首 token 或 showTtft 为 false 时无括号段。 */
192
+ export function formatTokenRate(
193
+ rate: number,
194
+ ttftMs = 0,
195
+ mode: TokenRateMode = "average",
196
+ showTtft = true,
197
+ ): string {
187
198
  if (!Number.isFinite(rate) || rate <= 0) return "";
188
199
  const value = rate >= 1000 ? `${(rate / 1000).toFixed(1)}k` : `${Math.round(rate)}`;
189
- const ttft = Number.isFinite(ttftMs) && ttftMs > 0 ? ` (TTFT: ${Math.round(ttftMs)}ms)` : "";
200
+ const ttft =
201
+ showTtft && Number.isFinite(ttftMs) && ttftMs > 0 ? ` (TTFT: ${formatTtft(ttftMs)})` : "";
190
202
  const label = mode === "live" ? "live" : "avg";
191
203
  return `${label} ${value} tok/s${ttft}`;
192
204
  }
@@ -1,10 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { getCompactRunStatusText } from "../../renderer/compact-mode.ts";
3
- import {
4
- fullscreenLazyTui,
5
- getToolMouseTui,
6
- isFullscreenAtBottom,
7
- } from "../../renderer/mouse/scroll.ts";
8
2
  import { formatDuration, formatTokenCount } from "../../utils/format.ts";
9
3
  import {
10
4
  resetTokenRate,
@@ -60,23 +54,10 @@ export default function (pi: ExtensionAPI): void {
60
54
  if (output > 0) recordProviderUsage(output);
61
55
  }
62
56
 
63
- /**
64
- * 摘要行滚出视口才镜像:仅 fullscreen 且已离开 transcript 底部。
65
- * regular 没有“离开底部”信号(transcript 在终端回滚区),保持 Pi 默认文案。
66
- */
67
- function compactMirrorText(): string | undefined {
68
- const tui = getToolMouseTui();
69
- if (!tui || !fullscreenLazyTui(tui) || isFullscreenAtBottom(tui)) return undefined;
70
- return getCompactRunStatusText();
71
- }
72
-
73
57
  function buildWorkingMessage(): string {
74
58
  const parts: string[] = [];
75
59
  const tokens = tokenCount();
76
60
  if (tokens > 0) parts.push(`↓ ${formatTokenCount(tokens)} tokens`);
77
- // compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
78
- const compactStatus = compactMirrorText();
79
- if (compactStatus) return [compactStatus, ...parts].join(" · ");
80
61
  const elapsed = Date.now() - (agentStartTime || turnStartTime);
81
62
  if (elapsed >= SHOW_TIMER_AFTER_MS || tokens > 0) {
82
63
  // formatDuration 低于 1 秒返回 "",此处回退 "0s" 保持计时器连续跳动。
@@ -592,6 +592,11 @@ export type CompactModeHooks = {
592
592
  assertOwnership(): void;
593
593
  /** 重新渲染包含指定 toolCallId 的 assistant 消息(思考收尾时刷新)。 */
594
594
  refreshToolCallMessage(toolCallId: string | undefined): void;
595
+ /**
596
+ * 收尾 resume 重放留下的活动回合。重放是同步的,回合之间靠下一条消息收口,
597
+ * 只有最后一条没有后续消息,会停在 Running。
598
+ */
599
+ settleReplayedRound(): void;
595
600
  shutdown(): void;
596
601
  };
597
602
 
@@ -618,17 +623,6 @@ const trackedAssistantComponents = new Set<any>();
618
623
  const trackedToolComponents = new Set<any>();
619
624
  let hoveredAssistantComponent: any;
620
625
 
621
- /**
622
- * 活动回合摘要 getter:常驻状态行(pi working row)镜像用。
623
- * installCompactMode 注册、卸载时清空,直接读 activeRound,不持有回合闭包。
624
- */
625
- let compactRunStatusGetter: (() => string | undefined) | undefined;
626
-
627
- /** 活动回合的摘要文本(`Running... · 9s, bash×1`);无活动回合返回 undefined。 */
628
- export function getCompactRunStatusText(): string | undefined {
629
- return compactRunStatusGetter?.();
630
- }
631
-
632
626
  /**
633
627
  * 回合尾行记录:摘要行(live 时连槽位卡)挂成 transcript 容器里回合末尾的
634
628
  * 兄弟组件,而不是 anchor 内部。anchor 内部的可变内容一旦被工具/diff 顶出视口
@@ -1196,6 +1190,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1196
1190
  liveSlotToolIds: Set<string>;
1197
1191
  /** 收尾后的静默期截止时间戳;期间仍按围观态渲染,到点才收拢。 */
1198
1192
  settleUntil?: number;
1193
+ /** 消息已有 toolCall、工具组件还没到:等它一 updateDisplay 就重画槽位。 */
1194
+ pendingLiveTool?: boolean;
1199
1195
  foldTimer?: ReturnType<typeof setTimeout>;
1200
1196
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
1201
1197
  startedAt: number;
@@ -1205,6 +1201,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1205
1201
  };
1206
1202
  let activeRound: CompactRound | undefined;
1207
1203
  let roundByComponent = new WeakMap<object, CompactRound>();
1204
+ /** 本 pass 建起的回合(含被收编的):清扫孤儿尾行的在册名单。 */
1205
+ const liveRounds = new Set<CompactRound>();
1208
1206
  const expandedRoundToolIds = new Set<string>();
1209
1207
  /** 用户显式点开的 round 内工具;强制折叠不压回这些 id。 */
1210
1208
  const explicitRoundToolIds = new Set<string>();
@@ -1263,11 +1261,30 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1263
1261
  pendingFoldRounds.delete(round);
1264
1262
  };
1265
1263
 
1266
- /** 结束回合活动态;不做任何重绘。 */
1267
- const finalizeRound = (round: CompactRound): void => {
1264
+ /** 供采纳为回合边界的 timestamp:必须是看起来像 epoch 毫秒的数(桩数据/秒级会被拒)。 */
1265
+ const isUsableTimestamp = (value: unknown): value is number =>
1266
+ typeof value === "number" &&
1267
+ Number.isFinite(value) &&
1268
+ value >= 1_600_000_000_000 &&
1269
+ value <= Date.now() + 60_000;
1270
+
1271
+ /** 结束回合活动态;不做任何重绘。endTimestamp 用后一段内容的真实时刻收边(回放里
1272
+ * 同一毫秒完成的 create/finalize 差不出真实耗时);缺省取回合内消息的最大 timestamp。 */
1273
+ const finalizeRound = (round: CompactRound, endTimestamp?: number): void => {
1268
1274
  clearFoldTimer(round);
1269
1275
  round.active = false;
1270
- if (round.endedAt === undefined) round.endedAt = Date.now();
1276
+ if (round.endedAt === undefined) {
1277
+ let fallback = 0;
1278
+ for (const message of roundMessages(round)) {
1279
+ if (isUsableTimestamp(message?.timestamp) && message.timestamp > fallback) {
1280
+ fallback = message.timestamp;
1281
+ }
1282
+ }
1283
+ round.endedAt = Math.max(
1284
+ round.startedAt,
1285
+ isUsableTimestamp(endTimestamp) ? endTimestamp : fallback || Date.now(),
1286
+ );
1287
+ }
1271
1288
  if (activeRound === round) {
1272
1289
  activeRound = undefined;
1273
1290
  deps.query?.setCompactSummaryActive?.(false);
@@ -1279,13 +1296,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1279
1296
  * 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。
1280
1297
  * 分组语义不受影响:activeRound 立即让位,新消息不会被并进已收尾的回合。
1281
1298
  */
1282
- const endRound = (round: CompactRound, render = false): void => {
1299
+ const endRound = (round: CompactRound, render = false, endTimestamp?: number): void => {
1283
1300
  if (!render || !round.active) {
1284
- finalizeRound(round);
1301
+ finalizeRound(round, endTimestamp);
1285
1302
  if (render) renderRound(round);
1286
1303
  return;
1287
1304
  }
1288
- finalizeRound(round);
1305
+ finalizeRound(round, endTimestamp);
1289
1306
  if (round.foldTimer !== undefined) return;
1290
1307
  round.settleUntil = Date.now() + FOLD_SETTLE_MS;
1291
1308
  pendingFoldRounds.add(round);
@@ -1357,12 +1374,6 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1357
1374
  ...round.detachedMessages,
1358
1375
  ];
1359
1376
 
1360
- /** 镜像给常驻状态行的文案:只认当前活动回合(摘要行被顶出视口时由 working-message 取用)。 */
1361
- compactRunStatusGetter = () => {
1362
- const round = activeRound;
1363
- return round?.active ? summarize(roundMessages(round), true, round) || undefined : undefined;
1364
- };
1365
-
1366
1377
  const roundToolCallIds = (round: CompactRound): Set<string> => {
1367
1378
  const ids = new Set<string>();
1368
1379
  for (const message of roundMessages(round)) {
@@ -1373,6 +1384,26 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1373
1384
  return ids;
1374
1385
  };
1375
1386
 
1387
+ /**
1388
+ * 摘要行有东西可汇总才挂:普通工具(edit/write/ask 之外)或非空思考。
1389
+ * 只剩外层卡的回合不挂——那些卡各自能展开,空摘要点开只会跳位。
1390
+ */
1391
+ const roundHasSummaryContent = (round: CompactRound): boolean => {
1392
+ for (const message of roundMessages(round)) {
1393
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
1394
+ if (item?.type === "thinking" && String(item.thinking ?? "").trim()) return true;
1395
+ if (
1396
+ item?.type === "toolCall" &&
1397
+ typeof item.name === "string" &&
1398
+ !OUTER_TOOLS.has(item.name)
1399
+ ) {
1400
+ return true;
1401
+ }
1402
+ }
1403
+ }
1404
+ return false;
1405
+ };
1406
+
1376
1407
  /**
1377
1408
  * anchor 所在的 transcript 容器(pi chatContainer)。
1378
1409
  * 优先走分组补丁在 addChild 时挂的父指针;没有则沿 tui 组件树找直属父容器。
@@ -1452,7 +1483,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1452
1483
  /** 取并入目标;旧回合被用户消息隔开时先收尾(静默期一转场,新回合立即接手)。 */
1453
1484
  const joinOrCreateRound = (component: any, message: any): CompactRound => {
1454
1485
  const joinable = joinableActiveRound(component);
1455
- if (!joinable && activeRound) finishRound(activeRound);
1486
+ if (!joinable && activeRound) finishRound(activeRound, message?.timestamp);
1456
1487
  return joinable ?? createRound(component, message);
1457
1488
  };
1458
1489
 
@@ -1521,10 +1552,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1521
1552
  };
1522
1553
 
1523
1554
  /**
1524
- * 尾行归位。运行中(含收尾静默期)尾行恒为容器最末一行:计数行钉在
1525
- * transcript 底缘,运行中到达的提示行/custom 条目只能落在它上方,计数
1526
- * 不会被顶离底部截成两行。回合收尾后摘要行回落到回合最后一个成员之后,
1527
- * 外来行统一换到摘要行之下,通知行永远落在回合边界外。
1555
+ * 尾行归位。目标位:活回合(运行中/收尾静默期)且未展开 → 容器最末,
1556
+ * 计数行钉在 transcript 可写底缘,运行中到达的提示行/custom 条目只能落在
1557
+ * 它上方,不会被顶离底部截成两行;其余(收尾回合、展开的回合)→ 回合最后
1558
+ * 一个成员之后,外来行统一换到摘要行之下。展开的回合不按 live 推到底:
1559
+ * 展开卡属于回合原位,推进容器末尾会把它挪到别的消息之间。
1560
+ * 尾行已在目标位就不动容器:展开/收起只换 tail.parts,无谓的重摘重插
1561
+ * 本身就是摘要行挪位的来源。
1528
1562
  */
1529
1563
  const syncTailPosition = (round: CompactRound): void => {
1530
1564
  const tail = round.tail;
@@ -1536,11 +1570,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1536
1570
  if (!container || !Array.isArray(container.children)) return;
1537
1571
  tail.container = container;
1538
1572
  const children = container.children as any[];
1573
+ const isMember = roundMemberMatcher(round);
1539
1574
  const current = children.indexOf(tail.host);
1575
+ // 摘尾行前先定目标位,已在位就整段跳过。
1576
+ let last = -1;
1577
+ for (let i = 0; i < children.length; i++) {
1578
+ if (children[i] === tail.host || !isMember(children[i])) continue;
1579
+ last = i;
1580
+ }
1581
+ const pinToEnd = roundLive(round) && round.anchor?.expanded !== true;
1582
+ const desired = pinToEnd || last < 0 ? children.length - 1 : last + 1;
1583
+ if (current === desired) return;
1540
1584
  if (current >= 0) children.splice(current, 1);
1541
- const isMember = roundMemberMatcher(round);
1542
1585
  let first = -1;
1543
- let last = -1;
1586
+ last = -1;
1544
1587
  for (let i = 0; i < children.length; i++) {
1545
1588
  if (!isMember(children[i])) continue;
1546
1589
  if (first < 0) first = i;
@@ -1555,7 +1598,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1555
1598
  }
1556
1599
  if (lastMember) last = children.indexOf(lastMember);
1557
1600
  const insertAt = last < 0 ? children.length : last + 1;
1558
- if (roundLive(round)) {
1601
+ if (pinToEnd) {
1559
1602
  // 活回合:外来行压到最后成员之后,尾行占容器最末。
1560
1603
  children.splice(insertAt, 0, ...intruders);
1561
1604
  children.push(tail.host);
@@ -1581,10 +1624,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1581
1624
  const syncTailForTool = (toolCallId: string): void => {
1582
1625
  if (!toolCallId) return;
1583
1626
  for (const round of [activeRound, ...pendingFoldRounds]) {
1584
- if (round?.tail && roundToolCallIds(round).has(toolCallId)) {
1585
- syncTailPosition(round);
1586
- return;
1587
- }
1627
+ if (!round || !roundToolCallIds(round).has(toolCallId)) continue;
1628
+ // 槽位是在消息更新时画的,当时工具组件还不存在:组件到了就补上。
1629
+ // 参数流式更新也走这里,槽位卡跟着换内容。
1630
+ if (round.pendingLiveTool || roundLive(round)) renderRound(round);
1631
+ else if (round.tail) syncTailPosition(round);
1632
+ return;
1588
1633
  }
1589
1634
  };
1590
1635
 
@@ -1628,7 +1673,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1628
1673
  }
1629
1674
  };
1630
1675
 
1631
- /** 新建活回合:literal + 激活 + refresh 重建时的旧回合收编。 */
1676
+ /** 新建活回合:起点用消息真实时刻(回放里 Date.now 会把整轮挤在同一毫秒)。 */
1632
1677
  const createRound = (anchor: any, message: any): CompactRound => {
1633
1678
  const round: CompactRound = {
1634
1679
  anchor,
@@ -1637,8 +1682,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1637
1682
  active: true,
1638
1683
  suppressedToolIds: new Set(),
1639
1684
  liveSlotToolIds: new Set(),
1640
- startedAt: Date.now(),
1685
+ startedAt: isUsableTimestamp(message?.timestamp) ? message.timestamp : Date.now(),
1641
1686
  };
1687
+ liveRounds.add(round);
1642
1688
  activateRound(round);
1643
1689
  adoptPriorRound(round, message);
1644
1690
  return round;
@@ -1728,6 +1774,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1728
1774
  }
1729
1775
  let runningTool: any;
1730
1776
  let lastTool: any;
1777
+ let missingTool = false;
1731
1778
  for (const [, message] of round.messages) {
1732
1779
  for (const item of Array.isArray(message?.content) ? message.content : []) {
1733
1780
  if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
@@ -1736,11 +1783,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1736
1783
  if (OUTER_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1737
1784
  round.liveSlotToolIds.add(item.id);
1738
1785
  liveSlotToolIds.add(item.id);
1739
- if (!tool) continue;
1786
+ // pi 的 message_update 先 updateContent 再建工具组件:这一帧计数已经算上,
1787
+ // 组件要等 updateDisplay 才能进槽位。记下来,组件一到就补画。
1788
+ if (!tool) {
1789
+ missingTool = true;
1790
+ continue;
1791
+ }
1740
1792
  lastTool = tool;
1741
1793
  if (isRunningTool(tool)) runningTool = tool;
1742
1794
  }
1743
1795
  }
1796
+ round.pendingLiveTool = missingTool;
1744
1797
  // 槽位单块:活动思考最新;否则本回合最近的工具(运行中原位增长,完成也留着)。
1745
1798
  const slotTool = thinkingKids.length > 0 ? undefined : (runningTool ?? lastTool);
1746
1799
  const cardItems: Array<{ child?: any; tool?: any }> = slotTool
@@ -1795,6 +1848,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1795
1848
  }
1796
1849
  for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
1797
1850
  round.liveSlotToolIds.clear();
1851
+ // pendingLiveTool 留给 renderRoundLive 重算:这里清掉的话,组件刚到的那次
1852
+ // 补画会把自己的触发条件抹掉,槽位仍是空的。
1798
1853
 
1799
1854
  for (const [component, message] of round.messages) {
1800
1855
  component.lastMessage = message;
@@ -1816,6 +1871,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1816
1871
  };
1817
1872
  }
1818
1873
 
1874
+ if (round.anchor.expanded === true) {
1875
+ // 没有可汇总内容的回合不进展开态:摘要行不存在,展开只会把正文搬回回合开头。
1876
+ if (!roundHasSummaryContent(round)) {
1877
+ for (const member of round.messages.keys()) member.expanded = false;
1878
+ round.anchor.expanded = false;
1879
+ }
1880
+ }
1819
1881
  if (round.anchor.expanded === true) {
1820
1882
  // 展开内容留在摘要行那个位置:回合最后一个成员(含 write/edit)之后。
1821
1883
  // 放进 anchor 内部会跳到回合起点,盖到中间的 write/edit 上面。
@@ -1959,8 +2021,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1959
2021
  for (const [component, message] of round.messages) {
1960
2022
  if (component === round.anchor) {
1961
2023
  renderAssistantWithoutThinking(component, message);
1962
- // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
1963
- if (summary || round.active) {
2024
+ // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)。
2025
+ // 只剩外层卡的回合同样不挂:摘要行没有可汇总的内容。
2026
+ if ((summary || round.active) && (round.active || roundHasSummaryContent(round))) {
1964
2027
  mountRoundTail(round, (identity) => [
1965
2028
  compactAssistantLineComponent(identity, getSummary, deps.query),
1966
2029
  ]);
@@ -1985,11 +2048,32 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1985
2048
  ensureRoundTick();
1986
2049
  };
1987
2050
 
1988
- const finishRound = (round: CompactRound): void => endRound(round, true);
2051
+ /** 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。 */
2052
+ const finishRound = (round: CompactRound, endTimestamp?: number): void =>
2053
+ endRound(round, true, endTimestamp);
2054
+
2055
+ /**
2056
+ * 重放/重建结束后的对账:mountedTails 里凡 tail.round 不在本 pass 回合名单的
2057
+ * 都是没被收编的孤儿(anchor 组件没了、消息换成了新对象、回放顺序错位),
2058
+ * 就地摘除。收编过的尾行 round 已过户到新 round,不在此列。
2059
+ */
2060
+ const sweepOrphanTails = (): void => {
2061
+ for (const tail of [...mountedTails]) {
2062
+ const round = tail.round as CompactRound | undefined;
2063
+ if (round && liveRounds.has(round)) continue;
2064
+ removeTailHost(tail);
2065
+ mountedTails.delete(tail);
2066
+ if (round?.anchor && tailByAnchor.get(round.anchor) === tail) {
2067
+ tailByAnchor.delete(round.anchor);
2068
+ }
2069
+ if (round?.tail === tail) round.tail = undefined;
2070
+ }
2071
+ };
1989
2072
 
1990
2073
  const resetRounds = (): void => {
1991
2074
  for (const round of [...pendingFoldRounds]) clearFoldTimer(round);
1992
2075
  activeRound = undefined;
2076
+ liveRounds.clear();
1993
2077
  roundByComponent = new WeakMap();
1994
2078
  expandedRoundToolIds.clear();
1995
2079
  explicitRoundToolIds.clear();
@@ -2059,7 +2143,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2059
2143
  round.messages.delete(this);
2060
2144
  clearRoundAnchor(this);
2061
2145
  }
2062
- if (activeRound) finishRound(activeRound);
2146
+ if (activeRound) finishRound(activeRound, message.timestamp);
2063
2147
  round = createRound(this, message);
2064
2148
  roundByComponent.set(this, round);
2065
2149
  } else if (!round) {
@@ -2068,6 +2152,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2068
2152
  roundByComponent.set(this, round);
2069
2153
  }
2070
2154
  round.messages.set(this, message);
2155
+ if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
2156
+ round.startedAt = message.timestamp;
2157
+ }
2071
2158
  renderRound(round);
2072
2159
  return undefined;
2073
2160
  }
@@ -2082,17 +2169,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2082
2169
  clearRoundAnchor(this);
2083
2170
  round.detachedMessages.push(previousMessage);
2084
2171
  roundByComponent.delete(this);
2085
- finishRound(round);
2172
+ finishRound(round, message.timestamp);
2086
2173
  return renderAssistantWithoutThinking(this, message);
2087
2174
  }
2088
2175
  if (round) {
2089
2176
  // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
2090
- endRound(round, true);
2177
+ endRound(round, true, message.timestamp);
2091
2178
  roundByComponent.delete(this);
2092
2179
  clearRoundAnchor(this);
2093
2180
  return passThroughAssistant(this, message);
2094
2181
  }
2095
- if (activeRound) finishRound(activeRound);
2182
+ if (activeRound) finishRound(activeRound, message.timestamp);
2096
2183
  return renderAssistantWithoutThinking(this, message);
2097
2184
  }
2098
2185
 
@@ -2104,6 +2191,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2104
2191
  roundByComponent.set(this, round);
2105
2192
  }
2106
2193
  round.messages.set(this, message);
2194
+ if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
2195
+ round.startedAt = message.timestamp;
2196
+ }
2107
2197
  renderRound(round);
2108
2198
  return undefined;
2109
2199
  }
@@ -2111,7 +2201,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2111
2201
  // 无可见内容的 abort/error/length:直接外层状态行,并结束进行中的回合。
2112
2202
  const loneStatus = messageStopStatus(message);
2113
2203
  if (loneStatus) {
2114
- if (activeRound) finishRound(activeRound);
2204
+ if (activeRound) finishRound(activeRound, message.timestamp);
2115
2205
  self.contentContainer.clear();
2116
2206
  appendStopStatus(this, loneStatus);
2117
2207
  return undefined;
@@ -2152,8 +2242,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2152
2242
 
2153
2243
  patch.toolInstalledUpdateDisplay = function (this: any) {
2154
2244
  const id = String(this.toolCallId ?? "");
2155
- if (patch.active && config.mode === "compact" && expandedRoundToolIds.has(id)) {
2156
- // 用户点开的保留展开;其余(含全局展开被收回的)继续强制折叠。
2245
+ if (
2246
+ patch.active &&
2247
+ config.mode === "compact" &&
2248
+ (expandedRoundToolIds.has(id) || liveSlotToolIds.has(id))
2249
+ ) {
2250
+ // 用户点开的保留展开;其余(含全局展开、live 槽位接管的)继续强制折叠。
2251
+ // 槽位卡在消息更新时就接管了 id,组件后到时 setExpanded 会把它翻开,外层多出一张卡。
2157
2252
  if (this.expanded !== true) explicitRoundToolIds.delete(id);
2158
2253
  else if (!explicitRoundToolIds.has(id)) this.expanded = false;
2159
2254
  }
@@ -2202,7 +2297,6 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2202
2297
  trackedToolComponents.clear();
2203
2298
  hoveredAssistantComponent = undefined;
2204
2299
  resetRounds();
2205
- compactRunStatusGetter = undefined;
2206
2300
  };
2207
2301
 
2208
2302
  assistantPrototype.updateContent = patch.assistantInstalled;
@@ -2232,6 +2326,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2232
2326
  if (config.mode === "compact") syncGlobalExpanded(ctx);
2233
2327
  else hoveredAssistantComponent = undefined;
2234
2328
  refreshTrackedComponents();
2329
+ sweepOrphanTails();
2235
2330
  },
2236
2331
  refresh() {
2237
2332
  if (!patch.active) return;
@@ -2239,6 +2334,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2239
2334
  patch.assertAssistantOwnership();
2240
2335
  if (config.mode !== "compact") hoveredAssistantComponent = undefined;
2241
2336
  refreshTrackedComponents();
2337
+ sweepOrphanTails();
2242
2338
  },
2243
2339
  assertOwnership() {
2244
2340
  if (!patch.active) return;
@@ -2259,6 +2355,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2259
2355
  }
2260
2356
  }
2261
2357
  },
2358
+ settleReplayedRound() {
2359
+ if (!patch.active || config.mode !== "compact") return;
2360
+ const round = activeRound;
2361
+ if (!round) return;
2362
+ finalizeRound(round); // clears activeRound — capture it first
2363
+ renderRound(round);
2364
+ },
2262
2365
  shutdown() {
2263
2366
  patch.dispose();
2264
2367
  },
@@ -250,6 +250,9 @@ export default function (
250
250
  // compact-thinking 的 session_start 处理在本 handler 之后执行(在其之上再装
251
251
  // 一层 updateContent);延迟再同步一次,保证 compact 补丁最终位于外层。
252
252
  setTimeout(() => syncCompactMode(ctx), 0);
253
+ // 历史回放发生在 session_start 之后,延迟到回放结束再收尾最后一个回合,
254
+ // 否则它没有后续消息,摘要行会停在 Running。
255
+ setTimeout(() => compactModeHooks?.settleReplayedRound(), 0);
253
256
  scheduleSessionRender(() => hooks.toolGrouping.refresh(getToolMouseTui()));
254
257
  });
255
258