pi-cc-extensions 0.9.9 → 0.9.11

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
+ }
@@ -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,
@@ -528,6 +535,12 @@ function isToolComponent(value: any): boolean {
528
535
  return value instanceof ToolExecutionComponent;
529
536
  }
530
537
 
538
+ /** 成员离开回合后不再算外层面板,避免点别的卡时被跳过收起。 */
539
+ function clearRoundAnchor(component: any): void {
540
+ if (!component || Object.getOwnPropertyDescriptor(component, "roundAnchor")?.get) return;
541
+ delete component.roundAnchor;
542
+ }
543
+
531
544
  function detachAssistantExpansion(component: any): void {
532
545
  if (
533
546
  typeof component?.[ASSISTANT_SET_EXPANDED_KEY] !== "function" ||
@@ -539,6 +552,7 @@ function detachAssistantExpansion(component: any): void {
539
552
  delete component[ASSISTANT_SET_EXPANDED_KEY];
540
553
  delete component[ASSISTANT_TOGGLE_ROUND_KEY];
541
554
  delete component[ASSISTANT_REENTRY_KEY];
555
+ clearRoundAnchor(component);
542
556
  }
543
557
 
544
558
  /** 供 mouse-interaction 识别可点击的 compact assistant 行(仅 compact 模式下生效)。 */
@@ -697,7 +711,7 @@ function compactEditWriteLine(
697
711
  const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
698
712
  const namePart = theme.fg("toolTitle", name);
699
713
  const hintText =
700
- options.hint !== false && component.expanded !== true ? ` • ${showMoreHintText()}` : "";
714
+ options.hint !== false && component.expanded !== true ? ` · ${showMoreHintText()}` : "";
701
715
  const fixedWidth =
702
716
  visibleWidth(iconPart) +
703
717
  visibleWidth(namePart) +
@@ -719,6 +733,8 @@ const compactRichDiffCache = new WeakMap<
719
733
  type CompactEditPaintHit = {
720
734
  width: number;
721
735
  theme: unknown;
736
+ /** 展开卡的背景槽位同样参与命中判断。 */
737
+ bgSlot: string;
722
738
  expanded: boolean;
723
739
  result: unknown;
724
740
  isPartial: boolean;
@@ -756,6 +772,7 @@ function compactEditWriteLines(
756
772
  hit &&
757
773
  hit.width === width &&
758
774
  hit.theme === theme &&
775
+ hit.bgSlot === config.expandedCardBackground &&
759
776
  hit.expanded === expanded &&
760
777
  hit.result === component.result &&
761
778
  hit.isPartial === isPartial &&
@@ -771,6 +788,7 @@ function compactEditWriteLines(
771
788
  compactEditPaintCache.set(component, {
772
789
  width,
773
790
  theme,
791
+ bgSlot: config.expandedCardBackground,
774
792
  expanded,
775
793
  result: component.result,
776
794
  isPartial,
@@ -901,7 +919,7 @@ function compactAssistantLineComponent(
901
919
  const pad = Math.max(0, options.pad ?? (Number(self.outputPad) || 0));
902
920
  const available = Math.max(0, width - pad);
903
921
  const hint = options.hint !== false;
904
- const hintText = hint ? ` • ${showMoreHintText()}` : "";
922
+ const hintText = hint ? ` · ${showMoreHintText()}` : "";
905
923
  const summaryWidth = Math.max(0, available - visibleWidth(hintText));
906
924
  const resolved = typeof summary === "function" ? summary() : summary;
907
925
  const runningActive = resolved.startsWith("Running...");
@@ -938,8 +956,11 @@ function compactAssistantLineComponent(
938
956
  text = `${styleCompactThinkingText(heading, theme)}${plain(tools)}`;
939
957
  }
940
958
  }
941
- const hintColor = hover ? "text" : "dim";
942
- const line = `${text}${hintText ? theme.fg(hintColor, hintText) : ""}`;
959
+ // 圆点保持 dim;hover 只高亮可点击文字。
960
+ const hintPart = hint
961
+ ? `${theme.fg("dim", " · ")}${theme.fg(hover ? "text" : "dim", showMoreHintText())}`
962
+ : "";
963
+ const line = `${text}${hintPart}`;
943
964
  const rendered = `${" ".repeat(pad)}${truncateToWidth(line, available, "")}`;
944
965
  const lines = leadingBlank ? ["", rendered] : [rendered];
945
966
  paint = {
@@ -975,7 +996,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
975
996
 
976
997
  /**
977
998
  * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
978
- * 提示有两处来源——我们的 showMoreHintText(`… +N more lines • click to show more`)与
999
+ * 提示有两处来源——我们的 showMoreHintText(`… +N more lines · click to show more`)与
979
1000
  * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
980
1001
  * 只去掉动作词,`(N more lines)` 这类计数保留。
981
1002
  */
@@ -987,7 +1008,8 @@ const SLOT_HINT_PATTERNS: RegExp[] = [
987
1008
  // 只去掉动作词,收尾括号与计数保留。
988
1009
  new RegExp(String.raw`,\s*[^()]*?${EXPAND_ACTION}(?=\))`),
989
1010
  // 直接挂在行尾:整段去掉。
990
- new RegExp(String.raw`\s*(?:•\s*)?${EXPAND_ACTION}\s*$`),
1011
+ // 尾部分隔点:ccstyle 统一 `·`,同时兼容旧帧与 pi 原生的 `•`
1012
+ new RegExp(String.raw`\s*(?:[•·]\s*)?${EXPAND_ACTION}\s*$`),
991
1013
  ];
992
1014
 
993
1015
  function stripSlotHint(line: string): string {
@@ -1357,7 +1379,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1357
1379
  const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
1358
1380
  const host: any = {
1359
1381
  outputPad: Number(round.anchor?.outputPad) || 0,
1382
+ children: [],
1360
1383
  render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
1384
+ childAtRow(localRow: number, width: number) {
1385
+ let offset = 0;
1386
+ for (const part of tail.parts) {
1387
+ const lines = part?.render?.(width);
1388
+ const count = Array.isArray(lines) ? lines.length : 0;
1389
+ if (localRow < offset + count) {
1390
+ return part?.childAtRow?.(localRow - offset, width) ?? null;
1391
+ }
1392
+ offset += count;
1393
+ }
1394
+ return null;
1395
+ },
1361
1396
  invalidate: () => {
1362
1397
  for (const part of tail.parts) part?.invalidate?.();
1363
1398
  },
@@ -1373,6 +1408,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1373
1408
  // 点击/悬停归属与 anchor 同一展开入口;toggle 每次 renderRound 重建,按值转发。
1374
1409
  // 经 tail.round 动态解引用:重建过户时 anchor 可能换成别的组件。
1375
1410
  ensureAssistantSetExpanded(host);
1411
+ Object.defineProperty(host, "roundAnchor", {
1412
+ configurable: true,
1413
+ get: () => tail.round?.anchor,
1414
+ });
1376
1415
  host[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) =>
1377
1416
  tail.round?.anchor?.[ASSISTANT_TOGGLE_ROUND_KEY]?.(expanded);
1378
1417
  tail.host = host;
@@ -1527,6 +1566,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1527
1566
  }
1528
1567
  const tail = ensureTailHost(round);
1529
1568
  tail.parts = makeParts(tail.host);
1569
+ tail.host.children = tail.parts;
1530
1570
  syncTailPosition(round);
1531
1571
  };
1532
1572
 
@@ -1665,6 +1705,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1665
1705
  for (const [component, message] of round.messages) {
1666
1706
  component.lastMessage = message;
1667
1707
  ensureAssistantSetExpanded(component);
1708
+ // 尾行用 getter 指向 anchor;成员用同名属性,点内部工具时能认出同一回合。
1709
+ if (!Object.getOwnPropertyDescriptor(component, "roundAnchor")?.get) {
1710
+ component.roundAnchor = round.anchor;
1711
+ }
1668
1712
  component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1669
1713
  const wasExpanded = round.anchor.expanded === true;
1670
1714
  if (expanded && !wasExpanded) capturePanelViewport(round.anchor);
@@ -1679,8 +1723,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1679
1723
  }
1680
1724
 
1681
1725
  if (round.anchor.expanded === true) {
1682
- // 展开卡自带全部内容,尾行摘下(收起时在折叠分支重新挂载)。
1683
- unmountRoundTail(round);
1726
+ // 展开内容留在摘要行那个位置:回合最后一个成员(含 write/edit)之后。
1727
+ // 放进 anchor 内部会跳到回合起点,盖到中间的 write/edit 上面。
1728
+ // anchor 未挂进 transcript 时没有这个位置,退回 anchor 内部。
1729
+ const mounted = Boolean(tailContainerOf(round.anchor));
1684
1730
  const toolsById = new Map<string, any>();
1685
1731
  for (const tool of trackedToolComponents) {
1686
1732
  if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
@@ -1790,7 +1836,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1790
1836
  !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
1791
1837
  );
1792
1838
  flushPanel();
1793
- for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
1839
+ if (mounted) {
1840
+ // 正文留在各自的 assistant 消息上,面板挂回摘要行原位。
1841
+ const outside: any[] = [];
1842
+ const panel: any[] = [];
1843
+ for (const child of anchorChildren) {
1844
+ (typeof child?.childAtRow === "function" ? panel : outside).push(child);
1845
+ }
1846
+ for (const child of outside) round.anchor.contentContainer.addChild(child);
1847
+ if (panel.length > 0) mountRoundTail(round, () => panel);
1848
+ else unmountRoundTail(round);
1849
+ } else {
1850
+ unmountRoundTail(round);
1851
+ for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
1852
+ }
1794
1853
  // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
1795
1854
  appendStopStatus(round.anchor, stopStatus);
1796
1855
  return;
@@ -1901,7 +1960,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1901
1960
  if (hasToolCalls) {
1902
1961
  let round = roundByComponent.get(this);
1903
1962
  if (hasText && (!round || round.anchor !== this)) {
1904
- if (round) round.messages.delete(this);
1963
+ if (round) {
1964
+ round.messages.delete(this);
1965
+ clearRoundAnchor(this);
1966
+ }
1905
1967
  if (activeRound) finishRound(activeRound);
1906
1968
  round = createRound(this, message);
1907
1969
  roundByComponent.set(this, round);
@@ -1921,6 +1983,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1921
1983
  // 最终回答开始后,当前组件恢复原生文本;它已完成的 thinking
1922
1984
  // 留在上一轮摘要中,避免再次生成独立 Thought 行。
1923
1985
  round.messages.delete(this);
1986
+ clearRoundAnchor(this);
1924
1987
  round.detachedMessages.push(previousMessage);
1925
1988
  roundByComponent.delete(this);
1926
1989
  finishRound(round);
@@ -1930,6 +1993,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1930
1993
  // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
1931
1994
  endRound(round, true);
1932
1995
  roundByComponent.delete(this);
1996
+ clearRoundAnchor(this);
1933
1997
  return passThroughAssistant(this, message);
1934
1998
  }
1935
1999
  if (activeRound) finishRound(activeRound);
@@ -33,6 +33,11 @@ import {
33
33
  } from "./tool/result.ts";
34
34
  import { oneLine } from "../utils/format.ts";
35
35
  import { showMoreHintText } from "./tool/show-more-hint.ts";
36
+ import {
37
+ CODEMODE_TOOL_NAME,
38
+ codemodeExpandedBody,
39
+ createCodemodeResultComponent,
40
+ } from "./tool/codemode.ts";
36
41
  import { countWriteDiffStats } from "./tool/diff/diff-renderer.ts";
37
42
  import { renderRichToolResult, type WriteExecutionMetadataStore } from "./tool/diff/index.ts";
38
43
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
@@ -80,8 +85,8 @@ type GlobalToolRenderPatch = {
80
85
  prototype: any;
81
86
  active: boolean;
82
87
  mode: () => CompactStyleMode;
83
- wrap: (tool: any) => any;
84
- byDefinition: WeakMap<object, any>;
88
+ wrap: (tool: any, toolName: string) => any;
89
+ byDefinition: WeakMap<object, Map<string, any>>;
85
90
  byName: Map<string, any>;
86
91
  downstream: ToolRenderMethods;
87
92
  installed: ToolRenderMethods;
@@ -157,7 +162,7 @@ function taskListSummary(tasks: ParsedTask[]): string {
157
162
  counts.completed ? `${counts.completed} completed` : "",
158
163
  ]
159
164
  .filter(Boolean)
160
- .join(" • ");
165
+ .join(" · ");
161
166
  }
162
167
 
163
168
  function renderExpandedTaskResult(
@@ -212,8 +217,8 @@ function renderExpandedTaskResult(
212
217
  function createCcstyleTool(
213
218
  originalTool: any,
214
219
  writeExecutionMetadata: WriteExecutionMetadataStore,
220
+ toolName: string,
215
221
  ): any {
216
- const toolName = originalTool.name;
217
222
  const label = originalTool.label || toolName;
218
223
  const defaultTitle = label === toolName ? humanizeToolLabel(label) : label;
219
224
  // MCP 工具直接用 adapter 暴露的真实工具名,不做人性化
@@ -305,13 +310,38 @@ function createCcstyleTool(
305
310
  }
306
311
 
307
312
  const expanded = isToolExpanded(options, context);
313
+ const isError = Boolean(options?.isError || context?.isError);
314
+ // codemode:折叠态自己画子调用树,展开态给全部子调用与去掉脚本头的输出
315
+ if (toolName === CODEMODE_TOOL_NAME) {
316
+ if (!options?.isPartial) setToolVisualState(context, isError ? "error" : "success");
317
+ if (expanded) {
318
+ return renderExpandedToolResult(
319
+ codemodeExpandedBody(result),
320
+ theme,
321
+ isError,
322
+ context?.lastComponent,
323
+ context?.args,
324
+ context,
325
+ true,
326
+ );
327
+ }
328
+ // 折叠态不再复用展开视图,免得 hover/鼠标命中指向已隐藏的组件
329
+ if (context?.state) context.state.ccstyleIoView = undefined;
330
+ const toolCallId = context?.toolCallId;
331
+ return createCodemodeResultComponent({
332
+ result,
333
+ theme,
334
+ running: Boolean(options?.isPartial),
335
+ isError,
336
+ isHovered: () => isToolCallHovered(toolCallId),
337
+ });
338
+ }
308
339
  if (options?.isPartial) {
309
340
  // 展开态贴左(Box 提供 1 格 pad);折叠态保持 3 格对齐标题
310
341
  const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
311
342
  return new Text(theme.fg("muted", pending), 0, 0);
312
343
  }
313
344
 
314
- const isError = options?.isError || context?.isError;
315
345
  setToolVisualState(context, isError ? "error" : "success");
316
346
  const toolCallId = context?.toolCallId;
317
347
  if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
@@ -356,7 +386,7 @@ function createCcstyleTool(
356
386
  : "Done";
357
387
  const expandable = !expanded && (tasks.length > 0 || hasExpandableDetail(text, args));
358
388
  const hintText = showMoreHintText();
359
- const hintPrefix = expandable ? theme.fg("dim", " • ") : "";
389
+ const hintPrefix = expandable ? theme.fg("dim", " · ") : "";
360
390
  const hint = expandable ? hintPrefix + theme.fg("dim", hintText) : "";
361
391
  const hoveredHint = expandable ? hintPrefix + theme.fg("text", hintText) : "";
362
392
  if (expanded) {
@@ -429,19 +459,24 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
429
459
 
430
460
  function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
431
461
  const definition = component.toolDefinition ?? component.builtInToolDefinition;
462
+ const name = String(component.toolName || definition?.name || "tool");
432
463
  if (definition && typeof definition === "object") {
433
- let wrapped = patch.byDefinition.get(definition);
464
+ let byName = patch.byDefinition.get(definition);
465
+ if (!byName) {
466
+ byName = new Map();
467
+ patch.byDefinition.set(definition, byName);
468
+ }
469
+ let wrapped = byName.get(name);
434
470
  if (!wrapped) {
435
- wrapped = patch.wrap(definition);
436
- patch.byDefinition.set(definition, wrapped);
471
+ wrapped = patch.wrap(definition, name);
472
+ byName.set(name, wrapped);
437
473
  }
438
474
  return wrapped;
439
475
  }
440
476
 
441
- const name = String(component.toolName || "tool");
442
477
  let wrapped = patch.byName.get(name);
443
478
  if (!wrapped) {
444
- wrapped = patch.wrap({ name, label: name });
479
+ wrapped = patch.wrap({ name, label: name }, name);
445
480
  patch.byName.set(name, wrapped);
446
481
  }
447
482
  return wrapped;
@@ -538,7 +573,8 @@ function installGlobalToolRendering(
538
573
  prototype,
539
574
  active: true,
540
575
  mode: () => config.mode,
541
- wrap: (tool: any) => createCcstyleTool(tool, writeExecutionMetadata),
576
+ wrap: (tool: any, toolName: string) =>
577
+ createCcstyleTool(tool, writeExecutionMetadata, toolName),
542
578
  byDefinition: new WeakMap(),
543
579
  byName: new Map(),
544
580
  downstream,
@@ -669,7 +705,9 @@ export function installToolExpandedBackground(): () => void {
669
705
  if (box) {
670
706
  box.paddingX = 1;
671
707
  box.paddingY = 1;
672
- if (box.setBgFn) box.setBgFn((text: string) => theme.bg("userMessageBg", text));
708
+ if (box.setBgFn) {
709
+ box.setBgFn((text: string) => theme.bg(config.expandedCardBackground, text));
710
+ }
673
711
  }
674
712
  },
675
713
  original,
@@ -13,7 +13,6 @@ import {
13
13
  setHoveredCompactAssistant,
14
14
  } from "../compact-mode.ts";
15
15
  import { isMessageDisplayComponent } from "../tool/message-display.ts";
16
- import { config } from "../../config/config.ts";
17
16
  import { isLazyProxyTui } from "../../utils/fullscreen-detect.ts";
18
17
  import { setToolTuiFullscreen, isCollapseHintLine } from "../tool/show-more-hint.ts";
19
18
  import {
@@ -144,7 +143,10 @@ function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
144
143
  }
145
144
  const title = section === "input" ? "Tool Input" : "Tool Output";
146
145
  const content = section === "input" ? ioView.getInputBody() : ioView.getOutputBody();
147
- void showTextPreview({ ui }, title, content || "(empty)");
146
+ // 整段就是代码时包一层围栏:弹框按 Markdown 渲染,围栏代码块才会着色
147
+ const fence = section === "input" ? ioView.getInputCodeFence() : undefined;
148
+ const preview = fence ? `\`\`\`${fence.language}\n${fence.code}\n\`\`\`` : content;
149
+ void showTextPreview({ ui }, title, preview || "(empty)");
148
150
  return true;
149
151
  }
150
152
 
@@ -305,10 +307,6 @@ function ensureFullscreenToolMouseMotion(tui: any): void {
305
307
  releaseFullscreenToolMouseMotion(tui);
306
308
  return;
307
309
  }
308
- // 面板改 scrollStepLines 后,下一帧渲染即同步(restore 仍按 original 恢复)。
309
- if (typeof tui.wheelScrollLines === "number" && tui.wheelScrollLines !== config.scrollStepLines) {
310
- tui.wheelScrollLines = config.scrollStepLines;
311
- }
312
310
  if (
313
311
  !toolMouseInteractionActive() ||
314
312
  tui.mouseEnabled === false ||
@@ -345,7 +343,6 @@ function releaseFullscreenToolMouseMotion(tui?: any): void {
345
343
  }
346
344
 
347
345
  const FULLSCREEN_VIEWPORT_PATCH = Symbol("ccstyle.fullscreen-viewport-patch");
348
- const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol("ccstyle.fullscreen-wheel-scroll-original");
349
346
 
350
347
  /**
351
348
  * diff 结果组件自带 remainder 行:声明存在时只有那一行是展开入口(正文里的同名字样不算);
@@ -415,11 +412,11 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
415
412
  if (!isTool && !isGroup && !isAssistant && !isThinking && !isMessage) return false;
416
413
  if (!component.expanded) {
417
414
  // collapsed 仅按钮文本可展开,不能把同一行正文/留白变成点击区。
415
+ // thinking 例外:标题行(标题、隐藏行数与提示同一行)整行都是展开入口。
418
416
  const hint = collapsedHintHitbox(line);
419
417
  const onHint = Boolean(
420
418
  hint &&
421
- packet.col >= hint.startCol &&
422
- packet.col <= hint.endCol &&
419
+ (isThinking || (packet.col >= hint.startCol && packet.col <= hint.endCol)) &&
423
420
  isCollapsedHintRow(component, line),
424
421
  );
425
422
  if (!onHint) {
@@ -433,13 +430,17 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
433
430
  for (const other of others) {
434
431
  if (other !== component && other.expanded) {
435
432
  // 展开 round 卡内 thinking/工具时,外层 compact 卡是它的容器,不能收起。
436
- if ((isThinking || isTool) && isCompactAssistantComponent(other)) continue;
433
+ // 面板挂在摘要行时,anchor 与摘要行是同一张外层卡,点内部工具不能收起它。
434
+ // 同一回合的摘要行、anchor 和其他消息都是外层面板,收起任一个都会关掉整块。
435
+ if ((isThinking || isTool) && isSameExpandedRound(other, target.owner)) continue;
437
436
  other.setExpanded(false);
438
437
  other.invalidate?.();
439
438
  }
440
439
  }
441
440
  // 展开 round 卡内工具时,让 compact 的强制折叠放行它(非 round 内工具为空操作)。
442
441
  if (isTool) markCompactRoundToolExpanded(component);
442
+ // compact 摘要行自己记展开前的视口(收起时还原跟随),这里不动它。
443
+ if (!isAssistant) keepViewportOnExpand(tui);
443
444
  component.setExpanded(true);
444
445
  clearPendingCollapsePress();
445
446
  } else {
@@ -480,6 +481,25 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
480
481
  return true;
481
482
  }
482
483
 
484
+ /** 点到的组件和外层卡是否属于同一张 compact 回合面板。 */
485
+ function isSameExpandedRound(other: any, owner: any): boolean {
486
+ if (!owner || !isCompactAssistantComponent(other)) return false;
487
+ const anchor = owner.roundAnchor ?? owner;
488
+ return other === owner || other === anchor || other.roundAnchor === anchor;
489
+ }
490
+
491
+ /**
492
+ * 原地展开时停在当前滚动位置:transcript 跟随底部时,内容变高会把视口拖到底,
493
+ * 点击的行和它上方的外层面板被顶出屏幕。展开前关掉跟随,点击行留在原位。
494
+ */
495
+ function keepViewportOnExpand(tui: any): void {
496
+ const view = tui.getPrimaryScrollView?.();
497
+ if (typeof view?.scrollTo !== "function" || typeof view.scrollTop !== "number") return;
498
+ view.scrollTo(view.scrollTop, { disableFollow: true });
499
+ // 已脱离底部跟随:亮出回到底部按钮,新输出到来时仍有入口。
500
+ if (view.isFollowingEnd === false) setScrollButtonVisible(true);
501
+ }
502
+
483
503
  /**
484
504
  * fullscreen 鼠标悬停:collapsed 卡 [click to show more] hint、
485
505
  * expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
@@ -519,7 +539,8 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
519
539
  if (component instanceof ToolGroupComponent) {
520
540
  if (overHint) target = { kind: "group", component };
521
541
  } else if (component instanceof ThinkingPreviewBlock) {
522
- if (component.expanded || overHint) target = { kind: "thinking", component };
542
+ // thinking 折叠标题行整行可点:整块 hover 都高亮动作词,不只高亮按钮文本。
543
+ if (component.expanded || Boolean(hintBox)) target = { kind: "thinking", component };
523
544
  } else if (isToolExecutionComponent(component)) {
524
545
  let view: ExpandedToolIoView | null = null;
525
546
  let section: ToolIoSection | null = null;
@@ -562,11 +583,6 @@ function patchFullscreenViewportInput(tui: any): void {
562
583
  const proto = Object.getPrototypeOf(tui);
563
584
  const original = proto?.handleViewportInput;
564
585
  if (typeof original !== "function") return;
565
- // 官方原生 routeWheel 已完整处理嵌套 ScrollView;只调整默认步进(config.scrollStepLines)。
566
- if (typeof tui.wheelScrollLines === "number") {
567
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = tui.wheelScrollLines;
568
- tui.wheelScrollLines = config.scrollStepLines;
569
- }
570
586
  tui[FULLSCREEN_VIEWPORT_PATCH] = true;
571
587
  tui.handleViewportInput = function (this: any, data: string) {
572
588
  if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
@@ -615,11 +631,6 @@ function restoreFullscreenViewportInput(tui: any): void {
615
631
  if (typeof proto?.handleViewportInput === "function") {
616
632
  tui.handleViewportInput = proto.handleViewportInput;
617
633
  }
618
- const originalWheelScrollLines = tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL];
619
- if (typeof originalWheelScrollLines === "number") {
620
- tui.wheelScrollLines = originalWheelScrollLines;
621
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = undefined;
622
- }
623
634
  tui[FULLSCREEN_VIEWPORT_PATCH] = false;
624
635
  }
625
636