pi-cc-extensions 0.9.8 → 0.9.10

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,136 @@
1
+ /**
2
+ * 内置 MCP 的状态栏芯片。
3
+ *
4
+ * pi 的内置 MCP 不写 setStatus:连接状态只在 `/mcp` 菜单里,扩展读不到。这里用公开数据推算
5
+ * 「已连接/已配置」,并在别的扩展接管 `/mcp` 时让位(pi-mcp-adapter 这类扩展自己会写 "mcp" 状态)。
6
+ *
7
+ * - 已连接:`pi.getAllTools()` 里来源是 builtin:mcp、曝光非 hidden 的工具,按 namespace 去重。
8
+ * 断线后工具仍在、0 个工具的服务器连上也看不见,needs-auth / failed 与 connecting 也分不开,
9
+ * 所以这只是近似值。
10
+ * - 已配置:mcp.json 里 enabled !== false 的条目(项目级要已 trust)。
11
+ */
12
+
13
+ import { readFileSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ /** 与 pi-mcp-adapter 共用同一个 status key;它写了 key 时本扩展不画。 */
18
+ export const MCP_STATUS_KEY = "mcp";
19
+ /** 内置 MCP 扩展的资源路径。 */
20
+ export const BUILTIN_MCP_SOURCE = "builtin:mcp";
21
+ const MCP_NAMESPACE_PREFIX = "mcp__";
22
+
23
+ type McpConfigCacheEntry = { mtimeMs: number; names: string[] };
24
+
25
+ const configCache = new Map<string, McpConfigCacheEntry>();
26
+
27
+ export function mcpAgentDir(): string {
28
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
29
+ }
30
+
31
+ /** pi 把 `-` 与 `_` 视作同一个服务器名(`mcpNamespace` 只把 `-` 换成 `_`)。 */
32
+ export function normalizeMcpServerName(name: string): string {
33
+ return name.trim().replace(/-/g, "_");
34
+ }
35
+
36
+ /** `mcp__<server>` → `<server>`;不是 MCP 命名空间返回 undefined。 */
37
+ export function mcpNamespaceServer(namespace: string): string | undefined {
38
+ if (!namespace.startsWith(MCP_NAMESPACE_PREFIX)) return undefined;
39
+ const server = namespace.slice(MCP_NAMESPACE_PREFIX.length);
40
+ return server || undefined;
41
+ }
42
+
43
+ /** 已连接的服务器名(按 namespace 去重,返回规范化名字)。 */
44
+ export function connectedMcpServers(tools: readonly any[]): string[] {
45
+ const fromBuiltin = new Set<string>();
46
+ const fromNamespace = new Set<string>();
47
+ for (const tool of tools ?? []) {
48
+ if (tool?.exposure === "hidden") continue;
49
+ const server = mcpNamespaceServer(String(tool?.namespace?.name ?? ""));
50
+ if (!server) continue;
51
+ const name = normalizeMcpServerName(server);
52
+ if (tool?.sourceInfo?.path === BUILTIN_MCP_SOURCE) fromBuiltin.add(name);
53
+ else fromNamespace.add(name);
54
+ }
55
+ // 版本漂移兜底:来源路径对不上时按 namespace 认(接管 /mcp 的扩展已在此之前排除)
56
+ return [...(fromBuiltin.size > 0 ? fromBuiltin : fromNamespace)];
57
+ }
58
+
59
+ /** `MCP 2/3`:已连接/(已连接 ∪ 已配置)。两边都空时不显示。 */
60
+ export function formatMcpChip(
61
+ connected: readonly string[],
62
+ configured: readonly string[],
63
+ ): string | undefined {
64
+ const connectedNames = new Set(connected.map(normalizeMcpServerName).filter(Boolean));
65
+ const configuredNames = new Set(configured.map(normalizeMcpServerName).filter(Boolean));
66
+ const total = new Set([...connectedNames, ...configuredNames]).size;
67
+ if (total === 0) return undefined;
68
+ return `MCP ${connectedNames.size}/${total}`;
69
+ }
70
+
71
+ /** 读一个 mcp.json 里启用中的服务器名;文件缺失或损坏返回 undefined。 */
72
+ function readMcpConfigNames(path: string): string[] | undefined {
73
+ try {
74
+ const { mtimeMs } = statSync(path);
75
+ const cached = configCache.get(path);
76
+ if (cached && cached.mtimeMs === mtimeMs) return cached.names;
77
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: unknown };
78
+ const servers = parsed?.mcpServers;
79
+ const names: string[] = [];
80
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
81
+ for (const [name, entry] of Object.entries(servers as Record<string, unknown>)) {
82
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
83
+ if ((entry as { enabled?: unknown }).enabled === false) continue;
84
+ names.push(name);
85
+ }
86
+ }
87
+ configCache.set(path, { mtimeMs, names });
88
+ return names;
89
+ } catch {
90
+ configCache.delete(path);
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /** mcp.json 里启用中的服务器名:用户级,加上已信任时的项目级(同名以项目为准,计数时去重)。 */
96
+ export function configuredMcpServers(options: {
97
+ agentDir: string;
98
+ cwd: string;
99
+ projectTrusted: boolean;
100
+ }): string[] {
101
+ const names = [...(readMcpConfigNames(join(options.agentDir, "mcp.json")) ?? [])];
102
+ if (options.projectTrusted) {
103
+ names.push(...(readMcpConfigNames(join(options.cwd, ".pi", "mcp.json")) ?? []));
104
+ }
105
+ return names;
106
+ }
107
+
108
+ /** 注册了 `/mcp` 的都是内置 MCP;别的扩展接管时它自己会写状态芯片。 */
109
+ export function builtinMcpOwnsCommand(commands: readonly any[]): boolean {
110
+ const mcpCommands = (commands ?? []).filter((command) => command?.name === MCP_STATUS_KEY);
111
+ return (
112
+ mcpCommands.length > 0 &&
113
+ mcpCommands.every((command) => command?.sourceInfo?.path === BUILTIN_MCP_SOURCE)
114
+ );
115
+ }
116
+
117
+ /** footer 渲染用:当前芯片文案(不含 nerd 图标),不适用时返回 undefined。 */
118
+ export function buildMcpChip(ctx: any, api: any): string | undefined {
119
+ try {
120
+ // 工具与命令列表在 pi API 上,ctx 只提供 cwd / trust
121
+ if (!builtinMcpOwnsCommand(api?.getCommands?.() ?? [])) return undefined;
122
+ const tools = api?.getAllTools?.();
123
+ if (!Array.isArray(tools)) return undefined;
124
+ return formatMcpChip(
125
+ connectedMcpServers(tools),
126
+ configuredMcpServers({
127
+ agentDir: mcpAgentDir(),
128
+ cwd: String(ctx?.cwd ?? process.cwd()),
129
+ projectTrusted: ctx?.isProjectTrusted?.() === true,
130
+ }),
131
+ );
132
+ } catch {
133
+ // footer 不能因为读配置或读工具列表失败而中断渲染
134
+ return undefined;
135
+ }
136
+ }
@@ -1,9 +1,14 @@
1
- import { VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
1
+ import { InteractiveMode, VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
2
2
  import { getKeybindings } from "@earendil-works/pi-tui";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { config } from "../../config/config.ts";
5
5
  import { ansi16ToRgb, ansi256ToRgb } from "../../utils/ansi-color.ts";
6
6
  import { stripAnsi } from "../../utils/ansi-text.ts";
7
+ import {
8
+ EARLY_STARTUP_HEADER_PATCH,
9
+ EARLY_STARTUP_HEADER_SWAP_KEY,
10
+ patchRegistry,
11
+ } from "../../utils/patch-keys.ts";
7
12
  type Rgb = [number, number, number];
8
13
  type StyledPart = {
9
14
  raw: string;
@@ -279,17 +284,88 @@ export function renderHeaderLines(
279
284
  );
280
285
  }
281
286
 
287
+ /** 官方 setHeader 的组件工厂:theme 由宿主注入。 */
288
+ function headerFactory(): (
289
+ tui: unknown,
290
+ theme: any,
291
+ ) => {
292
+ render(width: number): string[];
293
+ invalidate(): void;
294
+ } {
295
+ return (_tui: unknown, theme: any) => ({
296
+ render(width: number): string[] {
297
+ return renderHeaderLines(width, theme);
298
+ },
299
+ invalidate() {},
300
+ });
301
+ }
302
+
282
303
  /** 按配置安装启动头。禁用时不碰槽位,避免清掉其他扩展的 header。 */
283
304
  export function applyStartupHeader(ctx: any): void {
284
305
  if (!ctx?.hasUI || typeof ctx.ui?.setHeader !== "function" || !config.showStartupHeader) {
285
306
  return;
286
307
  }
287
- ctx.ui.setHeader((_tui: unknown, theme: any) => ({
288
- render(width: number): string[] {
289
- return renderHeaderLines(width, theme);
308
+ ctx.ui.setHeader(headerFactory());
309
+ }
310
+
311
+ type EarlyHeaderPatch = {
312
+ active: boolean;
313
+ original: (...args: any[]) => unknown;
314
+ installed: (...args: any[]) => unknown;
315
+ };
316
+
317
+ type HeaderContainerSwap = {
318
+ original: (child: any) => unknown;
319
+ installed: (child: any) => unknown;
320
+ };
321
+
322
+ /**
323
+ * Pi 在 session_start 之前就把原生启动头画上去了:init() 先建 header 再 requestRender,
324
+ * 之后才 await 工具检查、加载扩展,所以扩展最早只能到 session_start 换头,启动时会闪一下原生头。
325
+ * 这里补 init,在原生 header 刚进容器的瞬间换成 ccstyle 的,首次绘制就已经是我们的。
326
+ * 判定用 child === mode.builtInHeader,不依赖类名;配置关掉时不碰槽位,保留原生头。
327
+ */
328
+ export function installEarlyStartupHeader(): void {
329
+ const prototype = (InteractiveMode as any)?.prototype;
330
+ if (!prototype || typeof prototype.init !== "function") return;
331
+ const previous = patchRegistry.get<EarlyHeaderPatch>(EARLY_STARTUP_HEADER_PATCH);
332
+ if (previous) previous.active = false;
333
+ const original =
334
+ previous && prototype.init === previous.installed ? previous.original : prototype.init;
335
+ const patch: EarlyHeaderPatch = {
336
+ active: true,
337
+ original,
338
+ installed: async function (this: any, ...args: any[]) {
339
+ installHeaderContainerSwap(this, patch);
340
+ return original.apply(this, args);
290
341
  },
291
- invalidate() {},
292
- }));
342
+ };
343
+ prototype.init = patch.installed;
344
+ patchRegistry.install(EARLY_STARTUP_HEADER_PATCH, patch);
345
+ }
346
+
347
+ /** 只包一次 headerContainer.addChild;/reload 后按所有权接管旧包装。 */
348
+ function installHeaderContainerSwap(mode: any, patch: EarlyHeaderPatch): void {
349
+ const container = mode?.headerContainer;
350
+ if (!container || typeof container.addChild !== "function") return;
351
+ const previous = container[EARLY_STARTUP_HEADER_SWAP_KEY] as HeaderContainerSwap | undefined;
352
+ const original =
353
+ previous && container.addChild === previous.installed
354
+ ? previous.original
355
+ : container.addChild.bind(container);
356
+ const installed = function (child: any) {
357
+ const result = original(child);
358
+ if (!patch.active || !config.showStartupHeader || child !== mode.builtInHeader) return result;
359
+ if (typeof mode.setExtensionHeader !== "function") return result;
360
+ try {
361
+ mode.setExtensionHeader(headerFactory());
362
+ } catch {
363
+ // 换头失败就退回原生 header,不影响启动
364
+ }
365
+ return result;
366
+ };
367
+ container.addChild = installed;
368
+ container[EARLY_STARTUP_HEADER_SWAP_KEY] = { original, installed };
293
369
  }
294
370
 
295
371
  /** 用户从 /ccstyle 关掉本扩展启动头时,恢复官方 header。 */
@@ -115,18 +115,30 @@ export default function (pi: ExtensionAPI): void {
115
115
  return parts.length ? `Working... (${parts.join(" · ")})` : "";
116
116
  }
117
117
 
118
+ function workingUiAvailable(): boolean {
119
+ try {
120
+ return activeCtx?.hasUI === true;
121
+ } catch {
122
+ // 会话替换/reload 后捕获的 ctx 失效,getter 抛错;停止驱动 footer。
123
+ turnActive = false;
124
+ activeCtx = null;
125
+ stopRefreshLoop();
126
+ return false;
127
+ }
128
+ }
129
+
118
130
  function restoreDefaultWorkingMessage(): void {
119
131
  lastMessage = null;
120
- if (!activeCtx?.hasUI) return;
132
+ if (!workingUiAvailable()) return;
121
133
  try {
122
- activeCtx.ui?.setWorkingMessage();
134
+ activeCtx?.ui?.setWorkingMessage();
123
135
  } catch {
124
136
  // Noop when the TUI is unavailable.
125
137
  }
126
138
  }
127
139
 
128
140
  function syncWorkingMessage(force = false): void {
129
- if (!activeCtx?.hasUI) return;
141
+ if (!workingUiAvailable()) return;
130
142
  const next = buildWorkingMessage();
131
143
  if (!next) {
132
144
  if (force) restoreDefaultWorkingMessage();
@@ -135,7 +147,7 @@ export default function (pi: ExtensionAPI): void {
135
147
  if (!force && next === lastMessage) return;
136
148
  lastMessage = next;
137
149
  try {
138
- activeCtx.ui?.setWorkingMessage(next);
150
+ activeCtx?.ui?.setWorkingMessage(next);
139
151
  } catch {
140
152
  // Noop when the TUI is unavailable.
141
153
  }
@@ -147,9 +159,12 @@ export default function (pi: ExtensionAPI): void {
147
159
  refreshTimer = null;
148
160
  try {
149
161
  syncWorkingMessage();
150
- } finally {
151
- scheduleRefreshTick();
162
+ } catch {
163
+ // 定时器内的异常会成为 uncaughtException 终止 Pi;装饰性刷新直接停止。
164
+ turnActive = false;
165
+ return;
152
166
  }
167
+ scheduleRefreshTick();
153
168
  }, REFRESH_INTERVAL_MS);
154
169
  refreshTimer.unref?.();
155
170
  }
@@ -5,7 +5,7 @@ import { config } from "./config/config.ts";
5
5
  import piAliases from "./feature/shell/aliases.ts";
6
6
  import { installFlushDockedBash } from "./feature/shell/flush-docked-bash.ts";
7
7
  import customFooter from "./feature/shell/footer.ts";
8
- import piStartupHeader from "./feature/shell/startup-header.ts";
8
+ import piStartupHeader, { installEarlyStartupHeader } from "./feature/shell/startup-header.ts";
9
9
  import workingMessage from "./feature/shell/working-message.ts";
10
10
 
11
11
  // feature
@@ -23,6 +23,7 @@ export default function (pi: ExtensionAPI): void {
23
23
  // shell chrome
24
24
  if (config.enableAliases) piAliases(pi);
25
25
  installFlushDockedBash();
26
+ installEarlyStartupHeader();
26
27
  piStartupHeader(pi);
27
28
  if (config.enableWorkingMessage) workingMessage(pi);
28
29
  customFooter(pi);
@@ -338,16 +338,18 @@ function toolCardBgRow(
338
338
  const inset = (n: number) => (outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n));
339
339
  return `${inset(leftInset)}${bgAnsi}${stable}\x1b[49m${inset(rightInset)}`;
340
340
  }
341
- /** edit/write 展开卡:保持原样式(userMessageBg Box),不应用工具卡深色/间距改动。 */
341
+ /** edit/write 展开卡:保持原样式(展开卡背景 Box),不应用工具卡深色/间距改动。 */
342
342
  function editWriteExpandedCard(theme: any): any {
343
343
  return new Box(
344
344
  1,
345
345
  1,
346
- typeof theme.bg === "function" ? (text: string) => theme.bg("userMessageBg", text) : undefined,
346
+ typeof theme.bg === "function"
347
+ ? (text: string) => theme.bg(config.expandedCardBackground, text)
348
+ : undefined,
347
349
  );
348
350
  }
349
351
 
350
- /** compact 展开面板:外卡片保持 userMessageBg;展开的 thinking 另包一层更深的
352
+ /** compact 展开面板:外卡片保持展开卡背景;展开的 thinking 另包一层更深的
351
353
  * 内卡(自身 Box 背景与外卡同色,不区分会糊在一起),工具卡直接铺在外卡背景上。
352
354
  * 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
353
355
  * 避免与外卡/内卡 padding 叠出双空行。
@@ -364,7 +366,7 @@ function layoutExpandedToolCard(
364
366
  toolHits = false,
365
367
  live = false,
366
368
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
367
- const slot = "userMessageBg";
369
+ const slot = config.expandedCardBackground;
368
370
  const toolBgAnsi = darkenBgAnsi(theme, slot);
369
371
  /** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
370
372
  const markerWidth = live ? 2 : 0;
@@ -471,6 +473,8 @@ function compactRoundCard(
471
473
  | {
472
474
  width: number;
473
475
  theme: unknown;
476
+ /** 背景槽位也是面板行的输入:改配置后不能复用旧底色的整卡。 */
477
+ bgSlot: string;
474
478
  paints: unknown[];
475
479
  lines: string[];
476
480
  hits: Array<{ child: any; start: number; end: number }>;
@@ -478,6 +482,7 @@ function compactRoundCard(
478
482
  | undefined;
479
483
  const layout = (width: number) => {
480
484
  const theme = themeOf();
485
+ const bgSlot = config.expandedCardBackground;
481
486
  const innerWidth = contentWidth(width);
482
487
  const paints = children.map((child) => {
483
488
  const lines = child.render?.(innerWidth);
@@ -487,6 +492,7 @@ function compactRoundCard(
487
492
  paint &&
488
493
  paint.width === width &&
489
494
  paint.theme === theme &&
495
+ paint.bgSlot === bgSlot &&
490
496
  paint.paints.length === paints.length &&
491
497
  paint.paints.every((item, index) => item === paints[index])
492
498
  ) {
@@ -496,6 +502,7 @@ function compactRoundCard(
496
502
  paint = {
497
503
  width,
498
504
  theme,
505
+ bgSlot,
499
506
  paints,
500
507
  lines: laid.lines,
501
508
  hits: laid.hits,
@@ -697,7 +704,7 @@ function compactEditWriteLine(
697
704
  const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
698
705
  const namePart = theme.fg("toolTitle", name);
699
706
  const hintText =
700
- options.hint !== false && component.expanded !== true ? ` • ${showMoreHintText()}` : "";
707
+ options.hint !== false && component.expanded !== true ? ` · ${showMoreHintText()}` : "";
701
708
  const fixedWidth =
702
709
  visibleWidth(iconPart) +
703
710
  visibleWidth(namePart) +
@@ -719,6 +726,8 @@ const compactRichDiffCache = new WeakMap<
719
726
  type CompactEditPaintHit = {
720
727
  width: number;
721
728
  theme: unknown;
729
+ /** 展开卡的背景槽位同样参与命中判断。 */
730
+ bgSlot: string;
722
731
  expanded: boolean;
723
732
  result: unknown;
724
733
  isPartial: boolean;
@@ -756,6 +765,7 @@ function compactEditWriteLines(
756
765
  hit &&
757
766
  hit.width === width &&
758
767
  hit.theme === theme &&
768
+ hit.bgSlot === config.expandedCardBackground &&
759
769
  hit.expanded === expanded &&
760
770
  hit.result === component.result &&
761
771
  hit.isPartial === isPartial &&
@@ -771,6 +781,7 @@ function compactEditWriteLines(
771
781
  compactEditPaintCache.set(component, {
772
782
  width,
773
783
  theme,
784
+ bgSlot: config.expandedCardBackground,
774
785
  expanded,
775
786
  result: component.result,
776
787
  isPartial,
@@ -901,7 +912,7 @@ function compactAssistantLineComponent(
901
912
  const pad = Math.max(0, options.pad ?? (Number(self.outputPad) || 0));
902
913
  const available = Math.max(0, width - pad);
903
914
  const hint = options.hint !== false;
904
- const hintText = hint ? ` • ${showMoreHintText()}` : "";
915
+ const hintText = hint ? ` · ${showMoreHintText()}` : "";
905
916
  const summaryWidth = Math.max(0, available - visibleWidth(hintText));
906
917
  const resolved = typeof summary === "function" ? summary() : summary;
907
918
  const runningActive = resolved.startsWith("Running...");
@@ -975,7 +986,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
975
986
 
976
987
  /**
977
988
  * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
978
- * 提示有两处来源——我们的 showMoreHintText(`… +N more lines • click to show more`)与
989
+ * 提示有两处来源——我们的 showMoreHintText(`… +N more lines · click to show more`)与
979
990
  * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
980
991
  * 只去掉动作词,`(N more lines)` 这类计数保留。
981
992
  */
@@ -987,7 +998,8 @@ const SLOT_HINT_PATTERNS: RegExp[] = [
987
998
  // 只去掉动作词,收尾括号与计数保留。
988
999
  new RegExp(String.raw`,\s*[^()]*?${EXPAND_ACTION}(?=\))`),
989
1000
  // 直接挂在行尾:整段去掉。
990
- new RegExp(String.raw`\s*(?:•\s*)?${EXPAND_ACTION}\s*$`),
1001
+ // 尾部分隔点:ccstyle 统一 `·`,同时兼容旧帧与 pi 原生的 `•`
1002
+ new RegExp(String.raw`\s*(?:[•·]\s*)?${EXPAND_ACTION}\s*$`),
991
1003
  ];
992
1004
 
993
1005
  function stripSlotHint(line: string): string {
@@ -1167,6 +1179,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1167
1179
  stopRoundTick();
1168
1180
  return;
1169
1181
  }
1182
+ settleStrandedRounds();
1170
1183
  try {
1171
1184
  // 非 force:保留 fullscreen 布局缓存和差分绘制。
1172
1185
  uiRef?.requestRender?.();
@@ -1320,19 +1333,56 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1320
1333
  return found;
1321
1334
  };
1322
1335
 
1336
+ /** 组件在树里的直属父容器。TUI 切换/reparent 后缓存的 container 会过期。 */
1337
+ const containerOfHost = (host: any): any => {
1338
+ let found: any;
1339
+ walkComponentTree(getToolMouseTui(), (value: any) => {
1340
+ if (found) return false;
1341
+ if (Array.isArray(value?.children) && value.children.includes(host)) {
1342
+ found = value;
1343
+ return false;
1344
+ }
1345
+ return undefined;
1346
+ });
1347
+ return found;
1348
+ };
1349
+
1350
+ /** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
1351
+ const tailHostContainer = (tail: MountedRoundTail): any =>
1352
+ containerOfHost(tail.host) ??
1353
+ (tail.container?.children?.includes?.(tail.host) === true ? tail.container : undefined);
1354
+
1355
+ /** 摘尾行:按实际所在容器摘,避免缓存过期时摘了个空、行留在树上。 */
1356
+ const removeTailHost = (tail: MountedRoundTail): void => {
1357
+ tailHostContainer(tail)?.removeChild?.(tail.host);
1358
+ };
1359
+
1323
1360
  const ensureTailHost = (round: CompactRound): MountedRoundTail => {
1324
1361
  if (round.tail) return round.tail;
1325
1362
  // 同一 anchor 上残留的旧回合尾行(reset/重组后建新回合)先摘除,避免双摘要。
1326
1363
  const stale = tailByAnchor.get(round.anchor);
1327
1364
  if (stale) {
1328
- stale.container?.removeChild?.(stale.host);
1365
+ removeTailHost(stale);
1329
1366
  mountedTails.delete(stale);
1330
1367
  tailByAnchor.delete(round.anchor);
1331
1368
  }
1332
1369
  const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
1333
1370
  const host: any = {
1334
1371
  outputPad: Number(round.anchor?.outputPad) || 0,
1372
+ children: [],
1335
1373
  render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
1374
+ childAtRow(localRow: number, width: number) {
1375
+ let offset = 0;
1376
+ for (const part of tail.parts) {
1377
+ const lines = part?.render?.(width);
1378
+ const count = Array.isArray(lines) ? lines.length : 0;
1379
+ if (localRow < offset + count) {
1380
+ return part?.childAtRow?.(localRow - offset, width) ?? null;
1381
+ }
1382
+ offset += count;
1383
+ }
1384
+ return null;
1385
+ },
1336
1386
  invalidate: () => {
1337
1387
  for (const part of tail.parts) part?.invalidate?.();
1338
1388
  },
@@ -1348,6 +1398,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1348
1398
  // 点击/悬停归属与 anchor 同一展开入口;toggle 每次 renderRound 重建,按值转发。
1349
1399
  // 经 tail.round 动态解引用:重建过户时 anchor 可能换成别的组件。
1350
1400
  ensureAssistantSetExpanded(host);
1401
+ Object.defineProperty(host, "roundAnchor", {
1402
+ configurable: true,
1403
+ get: () => tail.round?.anchor,
1404
+ });
1351
1405
  host[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) =>
1352
1406
  tail.round?.anchor?.[ASSISTANT_TOGGLE_ROUND_KEY]?.(expanded);
1353
1407
  tail.host = host;
@@ -1410,7 +1464,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1410
1464
  const tail = round.tail;
1411
1465
  if (!tail) return;
1412
1466
  round.tail = undefined;
1413
- tail.container?.removeChild?.(tail.host);
1467
+ removeTailHost(tail);
1414
1468
  mountedTails.delete(tail);
1415
1469
  if (tailByAnchor.get(round.anchor) === tail) tailByAnchor.delete(round.anchor);
1416
1470
  };
@@ -1442,7 +1496,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1442
1496
  const stale = tail.round as CompactRound | undefined;
1443
1497
  if (
1444
1498
  stale &&
1445
- (stale.messages.get(round.anchor) === message || stale.detachedMessages.includes(message))
1499
+ // 先认组件:重放时消息对象未必是同一个(会话重读/重建),
1500
+ // 只认消息身份会漏掉本该接回的回合,摘要行就此掉队。
1501
+ (stale.anchor === round.anchor ||
1502
+ stale.messages.has(round.anchor) ||
1503
+ stale.detachedMessages.includes(message))
1446
1504
  ) {
1447
1505
  prior = stale;
1448
1506
  break;
@@ -1498,6 +1556,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1498
1556
  }
1499
1557
  const tail = ensureTailHost(round);
1500
1558
  tail.parts = makeParts(tail.host);
1559
+ tail.host.children = tail.parts;
1501
1560
  syncTailPosition(round);
1502
1561
  };
1503
1562
 
@@ -1650,8 +1709,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1650
1709
  }
1651
1710
 
1652
1711
  if (round.anchor.expanded === true) {
1653
- // 展开卡自带全部内容,尾行摘下(收起时在折叠分支重新挂载)。
1654
- unmountRoundTail(round);
1712
+ // 展开内容留在摘要行那个位置:回合最后一个成员(含 write/edit)之后。
1713
+ // 放进 anchor 内部会跳到回合起点,盖到中间的 write/edit 上面。
1714
+ // anchor 未挂进 transcript 时没有这个位置,退回 anchor 内部。
1715
+ const mounted = Boolean(tailContainerOf(round.anchor));
1655
1716
  const toolsById = new Map<string, any>();
1656
1717
  for (const tool of trackedToolComponents) {
1657
1718
  if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
@@ -1761,7 +1822,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1761
1822
  !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
1762
1823
  );
1763
1824
  flushPanel();
1764
- for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
1825
+ if (mounted) {
1826
+ // 正文留在各自的 assistant 消息上,面板挂回摘要行原位。
1827
+ const outside: any[] = [];
1828
+ const panel: any[] = [];
1829
+ for (const child of anchorChildren) {
1830
+ (typeof child?.childAtRow === "function" ? panel : outside).push(child);
1831
+ }
1832
+ for (const child of outside) round.anchor.contentContainer.addChild(child);
1833
+ if (panel.length > 0) mountRoundTail(round, () => panel);
1834
+ else unmountRoundTail(round);
1835
+ } else {
1836
+ unmountRoundTail(round);
1837
+ for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
1838
+ }
1765
1839
  // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
1766
1840
  appendStopStatus(round.anchor, stopStatus);
1767
1841
  return;
@@ -1815,12 +1889,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1815
1889
  // 一致;同 anchor 重建回合时 ensureTailHost 去重摘除),非 compact 摘掉,
1816
1890
  // 别把紧凑摘要带进 default 渲染。
1817
1891
  for (const tail of [...mountedTails]) {
1818
- if (tail.container?.children?.includes?.(tail.host) !== true) {
1892
+ const container = tailHostContainer(tail);
1893
+ if (!container) {
1819
1894
  mountedTails.delete(tail);
1820
1895
  continue;
1821
1896
  }
1897
+ tail.container = container;
1822
1898
  if (config.mode === "compact") continue;
1823
- tail.container.removeChild(tail.host);
1899
+ container.removeChild(tail.host);
1824
1900
  mountedTails.delete(tail);
1825
1901
  if (tail.round?.tail === tail) tail.round.tail = undefined;
1826
1902
  if (tail.round?.anchor && tailByAnchor.get(tail.round.anchor) === tail) {
@@ -1831,6 +1907,19 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1831
1907
  stopRoundTick();
1832
1908
  };
1833
1909
 
1910
+ /**
1911
+ * 兜底:resetRounds 会丢掉回合索引但保留已挂的尾行,重放没能接回的回合会一直
1912
+ * active——尾行永远显示 Running...(时长还在涨)却不再计入新工具。既不是活动
1913
+ * 回合也不在收尾静默期的活回合就地收尾,不让它永远挂在屏幕上。
1914
+ */
1915
+ const settleStrandedRounds = (): void => {
1916
+ for (const tail of [...mountedTails]) {
1917
+ const round = tail.round as CompactRound | undefined;
1918
+ if (!round?.active || round === activeRound || pendingFoldRounds.has(round)) continue;
1919
+ endRound(round, true);
1920
+ }
1921
+ };
1922
+
1834
1923
  patch.assistantInstalled = function (this: any, message: any, isStreaming?: boolean) {
1835
1924
  const self = this as any;
1836
1925
  // 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。