pi-cc-extensions 0.9.10 → 0.9.12

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.
@@ -324,7 +324,7 @@ function hiddenPreviewHint(
324
324
  return undefined;
325
325
  }
326
326
 
327
- const expandedThinking = new Set<number>();
327
+ const expandedThinking = new Set<string>();
328
328
 
329
329
  /** 折叠预览 + 展开全文。fullscreen 点击 hint 展开、展开卡单击整块收起(拖动选择文本时不收起)。 */
330
330
  export class ThinkingPreviewBlock implements Component {
@@ -335,7 +335,6 @@ export class ThinkingPreviewBlock implements Component {
335
335
  readonly runStartIndex: number;
336
336
  private style: (text: string) => string;
337
337
  private theme: Theme | undefined;
338
- private _expanded: boolean;
339
338
  private hintHovered = false;
340
339
  private collapsedMemo:
341
340
  | {
@@ -363,7 +362,6 @@ export class ThinkingPreviewBlock implements Component {
363
362
  this.runStartIndex = runStartIndex;
364
363
  this.style = style;
365
364
  this.theme = theme;
366
- this._expanded = expandedThinking.has(messageTimestamp);
367
365
  }
368
366
 
369
367
  private paint(color: string, text: string): string {
@@ -372,18 +370,20 @@ export class ThinkingPreviewBlock implements Component {
372
370
  : text;
373
371
  }
374
372
 
373
+ /** 展开态按消息时间戳 + run 序号共享:面板重排/内容重建换掉实例后,
374
+ * 布局里的旧实例与 render 的新实例同键,点谁都能展开。 */
375
375
  get expanded(): boolean {
376
- return this._expanded;
376
+ return expandedThinking.has(`${this.messageTimestamp}:${this.runStartIndex}`);
377
377
  }
378
378
 
379
379
  setExpanded(expanded: boolean): void {
380
- if (this._expanded !== expanded) this.collapsedMemo = undefined;
381
- this._expanded = expanded;
382
- if (expanded) expandedThinking.add(this.messageTimestamp);
380
+ const key = `${this.messageTimestamp}:${this.runStartIndex}`;
381
+ if (expanded) expandedThinking.add(key);
383
382
  else {
384
- expandedThinking.delete(this.messageTimestamp);
383
+ expandedThinking.delete(key);
385
384
  evictExpandedWraps(this.messageTimestamp, this.runStartIndex);
386
385
  }
386
+ this.collapsedMemo = undefined;
387
387
  }
388
388
 
389
389
  setHintHovered(hovered: boolean): void {
@@ -392,7 +392,7 @@ export class ThinkingPreviewBlock implements Component {
392
392
  }
393
393
 
394
394
  private headingLines(width: number, hiddenLines: number, padding: number): string[] {
395
- const hint = this._expanded
395
+ const hint = this.expanded
396
396
  ? undefined
397
397
  : hiddenPreviewHint(hiddenLines, Boolean(this.text) && config.previewLines <= 0);
398
398
  if (!hint) return new Text(this.heading, padding, 0).render(width);
@@ -412,10 +412,10 @@ export class ThinkingPreviewBlock implements Component {
412
412
  }
413
413
 
414
414
  private bodyLines(width: number, padding: number): { lines: string[]; hiddenLines: number } {
415
- if (!this.text || (config.previewLines <= 0 && !this._expanded)) {
415
+ if (!this.text || (config.previewLines <= 0 && !this.expanded)) {
416
416
  return { lines: [], hiddenLines: 0 };
417
417
  }
418
- if (this._expanded) {
418
+ if (this.expanded) {
419
419
  return {
420
420
  lines: wrapExpandedThinking(this.messageTimestamp, this.runStartIndex, this.text, width),
421
421
  hiddenLines: 0,
@@ -426,7 +426,7 @@ export class ThinkingPreviewBlock implements Component {
426
426
  }
427
427
 
428
428
  render(width: number) {
429
- if (this._expanded) {
429
+ if (this.expanded) {
430
430
  const innerWidth = Math.max(1, width - 2);
431
431
  const body = this.bodyLines(innerWidth, 0);
432
432
  const heading = this.headingLines(innerWidth, 0, 0);
@@ -0,0 +1,29 @@
1
+ const KNOWN_CURRENCIES = new Set(Intl.supportedValuesOf("currency"));
2
+
3
+ /** 1 美元费用对应的显示币种数量。面板切换币种时写入。 */
4
+ export const FOOTER_CURRENCY_RATES = {
5
+ CNY: 7.12,
6
+ EUR: 0.92,
7
+ JPY: 150,
8
+ GBP: 0.76,
9
+ INR: 96.25,
10
+ HKD: 7.78,
11
+ } as const;
12
+
13
+ export const FOOTER_CURRENCY_PRESETS = ["USD", ...Object.keys(FOOTER_CURRENCY_RATES)] as const;
14
+
15
+ /** 不在表里的币种,包括 USD,返回 null。 */
16
+ export function presetFooterCurrencyRate(currency: string): number | null {
17
+ const code = currency.trim().toUpperCase();
18
+ return FOOTER_CURRENCY_RATES[code as keyof typeof FOOTER_CURRENCY_RATES] ?? null;
19
+ }
20
+
21
+ /** 底栏费用文本。USD 或不支持的币种保持美元写法。没有倍率时只换符号。 */
22
+ export function formatFooterCost(cost: number, currency: string, rate: number | null): string {
23
+ const usd = `$${cost.toFixed(2)}`;
24
+ if (currency === "USD" || !KNOWN_CURRENCIES.has(currency)) return usd;
25
+ const formatted = new Intl.NumberFormat("en", { style: "currency", currency }).format(
26
+ rate === null ? cost : cost * rate,
27
+ );
28
+ return rate === null ? formatted : `≈${formatted}`;
29
+ }
@@ -14,13 +14,13 @@
14
14
 
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { truncateToWidth } from "@earendil-works/pi-tui";
17
- import { execFile } from "node:child_process";
18
17
  import { existsSync } from "node:fs";
19
18
  import { homedir } from "node:os";
20
19
  import { join } from "node:path";
21
20
  import { pathToFileURL } from "node:url";
22
21
  import { config } from "../../config/config.ts";
23
22
  import { stripAnsi } from "../../utils/ansi-text.ts";
23
+ import { formatFooterCost } from "./footer-currency.ts";
24
24
  import {
25
25
  PI_USAGE_KEY,
26
26
  isSkippedFooterStatusKey,
@@ -28,8 +28,11 @@ import {
28
28
  visibleFooterPluginTexts,
29
29
  type FooterChipLayout,
30
30
  } from "./footer-layout.ts";
31
+ import { createGitStatsRefresher, readGitStats, type GitStats } from "./git-stats.ts";
31
32
  import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
32
33
 
34
+ export { parseGitStats } from "./git-stats.ts";
35
+
33
36
  const GIT_REFRESH_INTERVAL_MS = 10_000;
34
37
  const USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
35
38
  // 后台连接 MCP 服务器不会触发 footer 重绘,靠这个兜底轮询把芯片刷出来
@@ -48,8 +51,6 @@ export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string;
48
51
  : { git: "", cache: "", mcp: "" };
49
52
  }
50
53
 
51
- type GitStats = { add: number; del: number };
52
-
53
54
  type XaiFooterReport = {
54
55
  buckets?: Array<{
55
56
  id?: string;
@@ -61,19 +62,6 @@ type XaiFooterReport = {
61
62
  metrics?: Array<{ id?: string; value?: unknown }>;
62
63
  };
63
64
 
64
- export function parseGitStats(stdout: string): GitStats {
65
- let add = 0;
66
- let del = 0;
67
- for (const line of stdout.split("\n")) {
68
- const match = line.match(/^(\d+)\s+(\d+)/);
69
- if (match) {
70
- add += Number(match[1]);
71
- del += Number(match[2]);
72
- }
73
- }
74
- return { add, del };
75
- }
76
-
77
65
  export function formatXaiFooterChip(report: XaiFooterReport): string | undefined {
78
66
  const included = report.buckets?.find((b) => b.id === "included-allowance");
79
67
  if (included?.unit === "percent" && typeof included.used === "number") {
@@ -180,7 +168,12 @@ function colorUsageChip(theme: any, text: string): string {
180
168
  }
181
169
 
182
170
  let currentTui: any = undefined;
183
- let refreshCurrentGitStats: (() => void) | undefined;
171
+ let refreshCurrentGitStats: ((reset?: boolean) => void) | undefined;
172
+
173
+ /** Apply a /ccstyle Git mode change immediately, without recreating the footer. */
174
+ export function refreshFooterGitStats(): void {
175
+ refreshCurrentGitStats?.(true);
176
+ }
184
177
  let refreshCurrentUsage: (() => void) | undefined;
185
178
  let refreshCurrentMcpChip: (() => void) | undefined;
186
179
  /** 工具/命令列表在 pi API 上,而 footer 工厂只拿得到 ctx,加载时记下来。 */
@@ -192,33 +185,23 @@ const createCustomFooterFactory =
192
185
  const sep = theme.fg("muted", " · ");
193
186
  const joinChips = (parts: string[]) => parts.filter(Boolean).join(sep);
194
187
  let gitStats: GitStats | undefined;
195
- let gitRefreshRunning = false;
196
188
  let localUsageChip = "";
197
189
  let usageGeneration = 0;
198
190
  let usageAbort: AbortController | undefined;
199
191
  let disposed = false;
200
192
 
201
- // 查询在 render 外执行,保留旧值;仅统计结果真正变化时重绘,避免周期性清零抖动。
202
- const refreshGitStats = () => {
203
- if (gitRefreshRunning || disposed) return;
204
- gitRefreshRunning = true;
205
- execFile(
206
- "git",
207
- ["diff", "--numstat", "HEAD"],
208
- { cwd: ctx.cwd, timeout: 2000 },
209
- (err, stdout) => {
210
- gitRefreshRunning = false;
211
- if (err || disposed) return;
212
- const next = parseGitStats(stdout);
213
- if (gitStats?.add === next.add && gitStats.del === next.del) return;
214
- gitStats = next;
215
- tui.requestRender();
216
- },
217
- );
218
- };
219
- refreshCurrentGitStats = refreshGitStats;
220
- refreshGitStats();
221
- const gitRefreshTimer = setInterval(refreshGitStats, GIT_REFRESH_INTERVAL_MS);
193
+ // 查询在 render 外执行;模式/分支变化清旧值,普通轮询只在统计变化时重绘。
194
+ const gitRefresher = createGitStatsRefresher({
195
+ getMode: () => config.footerGitStatsMode,
196
+ query: (mode, signal) => readGitStats(ctx.cwd, mode, signal),
197
+ onChange: (next) => {
198
+ gitStats = next;
199
+ tui.requestRender();
200
+ },
201
+ });
202
+ refreshCurrentGitStats = gitRefresher.refresh;
203
+ gitRefresher.refresh();
204
+ const gitRefreshTimer = setInterval(() => gitRefresher.refresh(), GIT_REFRESH_INTERVAL_MS);
222
205
  gitRefreshTimer.unref?.();
223
206
 
224
207
  const refreshUsage = () => {
@@ -314,9 +297,9 @@ const createCustomFooterFactory =
314
297
  const mcpRefreshTimer = setInterval(refreshMcp, MCP_REFRESH_INTERVAL_MS);
315
298
  mcpRefreshTimer.unref?.();
316
299
 
317
- // 分支变化时同步更新分支名和相对 HEAD 的统计。
300
+ // 分支变化时重新选择基准,并拒绝旧分支的异步查询结果。
318
301
  const unsubBranch = footerData.onBranchChange(() => {
319
- refreshGitStats();
302
+ gitRefresher.refresh(true);
320
303
  tui.requestRender();
321
304
  });
322
305
 
@@ -419,8 +402,10 @@ const createCustomFooterFactory =
419
402
  cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
420
403
  const costChip =
421
404
  cost || usingSubscription
422
- ? theme.fg("dim", `$${cost.toFixed(2)}`) +
423
- (usingSubscription ? theme.fg("warning", " sub") : "")
405
+ ? theme.fg(
406
+ "dim",
407
+ formatFooterCost(cost, config.footerCurrency, config.footerCurrencyRate),
408
+ ) + (usingSubscription ? theme.fg("warning", " sub") : "")
424
409
  : "";
425
410
  const line1 = joinChips([
426
411
  theme.fg("accent", modelLabel),
@@ -439,14 +424,17 @@ const createCustomFooterFactory =
439
424
  }
440
425
  const branch = footerData.getGitBranch();
441
426
  if (branch) {
442
- const stats =
443
- gitStats && (gitStats.add || gitStats.del)
444
- ? theme.fg("dim", " (") +
445
- theme.fg("success", `+${gitStats.add}`) +
446
- " " +
447
- theme.fg("error", `−${gitStats.del}`) +
448
- theme.fg("dim", ")")
449
- : "";
427
+ const s = gitStats;
428
+ let stats = "";
429
+ if (s && s.status)
430
+ stats = theme.fg("dim", " [") + gitColor(s.status) + theme.fg("dim", "]");
431
+ if (s && (s.add || s.del))
432
+ stats +=
433
+ theme.fg("dim", "(") +
434
+ theme.fg("success", `+${s.add}`) +
435
+ " " +
436
+ theme.fg("error", `−${s.del}`) +
437
+ theme.fg("dim", ")");
450
438
  const gitLabel = glyphs.git ? `${glyphs.git} ${branch}` : branch;
451
439
  place += theme.fg("muted", " on ") + gitColor(gitLabel) + stats;
452
440
  }
@@ -467,6 +455,7 @@ const createCustomFooterFactory =
467
455
  },
468
456
  dispose(): void {
469
457
  disposed = true;
458
+ gitRefresher.dispose();
470
459
  usageAbort?.abort();
471
460
  usageAbort = undefined;
472
461
  clearInterval(gitRefreshTimer);
@@ -0,0 +1,162 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { FooterGitStatsMode } from "../../config/config.ts";
4
+
5
+ export type GitStats = { add: number; del: number; status?: string };
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const BRANCH_BASE_REFS = ["refs/remotes/origin/HEAD", "refs/heads/main", "refs/heads/master"];
9
+
10
+ export function parseGitStats(stdout: string): GitStats {
11
+ let add = 0;
12
+ let del = 0;
13
+ for (const line of stdout.split("\n")) {
14
+ const match = line.match(/^(\d+)\s+(\d+)/);
15
+ if (match) {
16
+ add += Number(match[1]);
17
+ del += Number(match[2]);
18
+ }
19
+ }
20
+ return { add, del };
21
+ }
22
+
23
+ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
24
+
25
+ /**
26
+ * git status --porcelain --branch 的第一行带分支头(## branch...origin/branch [ahead N, behind M]),
27
+ * 其余行是 XY 文件状态。返回 Starship 风格的状态符号串(= ! ? ⇡⇣),无变化时 undefined。
28
+ */
29
+ export function parseGitStatus(stdout: string): string | undefined {
30
+ let conflicted = false;
31
+ let modified = false;
32
+ let untracked = false;
33
+ let ahead: number | undefined;
34
+ let behind: number | undefined;
35
+ for (const line of stdout.split("\n")) {
36
+ if (line.startsWith("## ")) {
37
+ ahead = Number(line.match(/ahead (\d+)/)?.[1]) || undefined;
38
+ behind = Number(line.match(/behind (\d+)/)?.[1]) || undefined;
39
+ continue;
40
+ }
41
+ const code = line.slice(0, 2);
42
+ if (code === "??") untracked = true;
43
+ else if (CONFLICT_CODES.has(code)) {
44
+ // 冲突文件必然也有未提交内容,! 与 = 同时出现
45
+ conflicted = true;
46
+ modified = true;
47
+ } else if (line.trim()) modified = true;
48
+ }
49
+ let status = "";
50
+ if (conflicted) status += "=";
51
+ if (modified) status += "!";
52
+ if (untracked) status += "?";
53
+ if (ahead) status += `⇡${ahead}`;
54
+ if (behind) status += `⇣${behind}`;
55
+ return status || undefined;
56
+ }
57
+
58
+ /** Read only local refs and tracked files; never fetch or change the index. */
59
+ export async function readGitStats(
60
+ cwd: string,
61
+ mode: FooterGitStatsMode,
62
+ signal?: AbortSignal,
63
+ ): Promise<GitStats | undefined> {
64
+ const git = async (args: string[]) => {
65
+ const { stdout } = await execFileAsync("git", args, {
66
+ cwd,
67
+ timeout: 2000,
68
+ maxBuffer: 16 * 1024 * 1024,
69
+ signal,
70
+ });
71
+ return stdout.trim();
72
+ };
73
+ try {
74
+ let base = "HEAD";
75
+ if (mode === "branch") {
76
+ let baseCommit: string | undefined;
77
+ for (const ref of BRANCH_BASE_REFS) {
78
+ try {
79
+ baseCommit = await git(["rev-parse", "--verify", `${ref}^{commit}`]);
80
+ break;
81
+ } catch {
82
+ if (signal?.aborted) return undefined;
83
+ // Missing/dangling remote HEAD: try the local default branches.
84
+ }
85
+ }
86
+ if (!baseCommit) return undefined;
87
+ base = await git(["merge-base", "HEAD", baseCommit]);
88
+ }
89
+ // One comparison includes committed + staged + unstaged net changes,
90
+ // without double-counting edits that are later reverted. Untracked files are excluded.
91
+ const [numstat, statusOut] = await Promise.all([
92
+ git(["diff", "--numstat", base, "--"]),
93
+ git(["status", "--porcelain", "--branch"]).catch(() => ""),
94
+ ]);
95
+ if (signal?.aborted) return undefined;
96
+ const stats = parseGitStats(numstat);
97
+ const status = parseGitStatus(statusOut);
98
+ return status ? { ...stats, status } : stats;
99
+ } catch {
100
+ // Non-repository, unborn HEAD, unrelated histories, timeout, etc.
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /** Coalesce overlapping refreshes and reject stale results after a mode/branch change. */
106
+ export function createGitStatsRefresher(options: {
107
+ getMode: () => FooterGitStatsMode;
108
+ query: (mode: FooterGitStatsMode, signal: AbortSignal) => Promise<GitStats | undefined>;
109
+ onChange: (stats: GitStats | undefined) => void;
110
+ }): { refresh: (reset?: boolean) => void; dispose: () => void } {
111
+ let stats: GitStats | undefined;
112
+ let mode = options.getMode();
113
+ let generation = 0;
114
+ let running = false;
115
+ let pending = false;
116
+ let disposed = false;
117
+ let controller: AbortController | undefined;
118
+ const publish = (next: GitStats | undefined) => {
119
+ if (stats?.add === next?.add && stats?.del === next?.del && stats?.status === next?.status)
120
+ return;
121
+ stats = next;
122
+ options.onChange(next);
123
+ };
124
+ const refresh = (reset = false) => {
125
+ if (disposed) return;
126
+ const nextMode = options.getMode();
127
+ if (reset || mode !== nextMode) publish(undefined);
128
+ mode = nextMode;
129
+ const request = ++generation;
130
+ if (running) {
131
+ pending = true;
132
+ return;
133
+ }
134
+ running = true;
135
+ const queryController = new AbortController();
136
+ controller = queryController;
137
+ void (async () => {
138
+ let next: GitStats | undefined;
139
+ try {
140
+ next = await options.query(nextMode, queryController.signal);
141
+ } catch {
142
+ // Failed queries clear the old numbers rather than leaving a stale chip.
143
+ } finally {
144
+ if (!disposed && request === generation && nextMode === options.getMode()) publish(next);
145
+ running = false;
146
+ controller = undefined;
147
+ if (pending && !disposed) {
148
+ pending = false;
149
+ refresh();
150
+ }
151
+ }
152
+ })();
153
+ };
154
+ return {
155
+ refresh,
156
+ dispose: () => {
157
+ disposed = true;
158
+ generation++;
159
+ controller?.abort();
160
+ },
161
+ };
162
+ }
@@ -42,6 +42,7 @@ import {
42
42
  isRichDiffComponent,
43
43
  } from "./tool/diff/diff-renderer.ts";
44
44
  import { renderRichToolResult } from "./tool/diff/index.ts";
45
+ import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
45
46
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
46
47
  import { isToolCallHovered } from "./mouse/hover.ts";
47
48
  import { getToolMouseTui } from "./mouse/scroll.ts";
@@ -535,6 +536,12 @@ function isToolComponent(value: any): boolean {
535
536
  return value instanceof ToolExecutionComponent;
536
537
  }
537
538
 
539
+ /** 成员离开回合后不再算外层面板,避免点别的卡时被跳过收起。 */
540
+ function clearRoundAnchor(component: any): void {
541
+ if (!component || Object.getOwnPropertyDescriptor(component, "roundAnchor")?.get) return;
542
+ delete component.roundAnchor;
543
+ }
544
+
538
545
  function detachAssistantExpansion(component: any): void {
539
546
  if (
540
547
  typeof component?.[ASSISTANT_SET_EXPANDED_KEY] !== "function" ||
@@ -546,6 +553,7 @@ function detachAssistantExpansion(component: any): void {
546
553
  delete component[ASSISTANT_SET_EXPANDED_KEY];
547
554
  delete component[ASSISTANT_TOGGLE_ROUND_KEY];
548
555
  delete component[ASSISTANT_REENTRY_KEY];
556
+ clearRoundAnchor(component);
549
557
  }
550
558
 
551
559
  /** 供 mouse-interaction 识别可点击的 compact assistant 行(仅 compact 模式下生效)。 */
@@ -703,8 +711,9 @@ function compactEditWriteLine(
703
711
  // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
704
712
  const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
705
713
  const namePart = theme.fg("toolTitle", name);
706
- const hintText =
707
- options.hint !== false && component.expanded !== true ? ` · ${showMoreHintText()}` : "";
714
+ const clickable = options.hint !== false && component.expanded !== true;
715
+ const clickText = clickable ? showMoreHintText() : "";
716
+ const hintText = clickable ? ` · ${clickText}` : "";
708
717
  const fixedWidth =
709
718
  visibleWidth(iconPart) +
710
719
  visibleWidth(namePart) +
@@ -713,7 +722,11 @@ function compactEditWriteLine(
713
722
  const pathWidth = Math.max(0, width - fixedWidth - (path ? 1 : 0));
714
723
  const pathPart =
715
724
  pathWidth > 0 && path ? ` ${formatDisplayPath(path, component.cwd, pathWidth)}` : "";
716
- const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintText ? theme.fg("dim", hintText) : ""}`;
725
+ // 圆点保持 dim;hover 只高亮可点击文字,与 compact 摘要行一致
726
+ const hintPart = clickable
727
+ ? `${theme.fg("dim", " · ")}${theme.fg(isToolCallHovered(component.toolCallId) ? "text" : "dim", clickText)}`
728
+ : "";
729
+ const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintPart}`;
717
730
  return ["", truncateToWidth(line, width, "")];
718
731
  }
719
732
 
@@ -728,6 +741,7 @@ type CompactEditPaintHit = {
728
741
  theme: unknown;
729
742
  /** 展开卡的背景槽位同样参与命中判断。 */
730
743
  bgSlot: string;
744
+ diffConfigKey: string;
731
745
  expanded: boolean;
732
746
  result: unknown;
733
747
  isPartial: boolean;
@@ -759,6 +773,7 @@ function compactEditWriteLines(
759
773
  const hover = isToolCallHovered(component.toolCallId);
760
774
  const ioHover = compactEditIoHover(component);
761
775
  const theme = themeOf();
776
+ const diffConfigKey = displayConfigCacheKey(getToolDisplayConfig());
762
777
  if (!pending) {
763
778
  const hit = compactEditPaintCache.get(component);
764
779
  if (
@@ -766,6 +781,7 @@ function compactEditWriteLines(
766
781
  hit.width === width &&
767
782
  hit.theme === theme &&
768
783
  hit.bgSlot === config.expandedCardBackground &&
784
+ hit.diffConfigKey === diffConfigKey &&
769
785
  hit.expanded === expanded &&
770
786
  hit.result === component.result &&
771
787
  hit.isPartial === isPartial &&
@@ -782,6 +798,7 @@ function compactEditWriteLines(
782
798
  width,
783
799
  theme,
784
800
  bgSlot: config.expandedCardBackground,
801
+ diffConfigKey,
785
802
  expanded,
786
803
  result: component.result,
787
804
  isPartial,
@@ -949,8 +966,11 @@ function compactAssistantLineComponent(
949
966
  text = `${styleCompactThinkingText(heading, theme)}${plain(tools)}`;
950
967
  }
951
968
  }
952
- const hintColor = hover ? "text" : "dim";
953
- const line = `${text}${hintText ? theme.fg(hintColor, hintText) : ""}`;
969
+ // 圆点保持 dim;hover 只高亮可点击文字。
970
+ const hintPart = hint
971
+ ? `${theme.fg("dim", " · ")}${theme.fg(hover ? "text" : "dim", showMoreHintText())}`
972
+ : "";
973
+ const line = `${text}${hintPart}`;
954
974
  const rendered = `${" ".repeat(pad)}${truncateToWidth(line, available, "")}`;
955
975
  const lines = leadingBlank ? ["", rendered] : [rendered];
956
976
  paint = {
@@ -1695,6 +1715,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1695
1715
  for (const [component, message] of round.messages) {
1696
1716
  component.lastMessage = message;
1697
1717
  ensureAssistantSetExpanded(component);
1718
+ // 尾行用 getter 指向 anchor;成员用同名属性,点内部工具时能认出同一回合。
1719
+ if (!Object.getOwnPropertyDescriptor(component, "roundAnchor")?.get) {
1720
+ component.roundAnchor = round.anchor;
1721
+ }
1698
1722
  component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1699
1723
  const wasExpanded = round.anchor.expanded === true;
1700
1724
  if (expanded && !wasExpanded) capturePanelViewport(round.anchor);
@@ -1946,7 +1970,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1946
1970
  if (hasToolCalls) {
1947
1971
  let round = roundByComponent.get(this);
1948
1972
  if (hasText && (!round || round.anchor !== this)) {
1949
- if (round) round.messages.delete(this);
1973
+ if (round) {
1974
+ round.messages.delete(this);
1975
+ clearRoundAnchor(this);
1976
+ }
1950
1977
  if (activeRound) finishRound(activeRound);
1951
1978
  round = createRound(this, message);
1952
1979
  roundByComponent.set(this, round);
@@ -1966,6 +1993,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1966
1993
  // 最终回答开始后,当前组件恢复原生文本;它已完成的 thinking
1967
1994
  // 留在上一轮摘要中,避免再次生成独立 Thought 行。
1968
1995
  round.messages.delete(this);
1996
+ clearRoundAnchor(this);
1969
1997
  round.detachedMessages.push(previousMessage);
1970
1998
  roundByComponent.delete(this);
1971
1999
  finishRound(round);
@@ -1975,6 +2003,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1975
2003
  // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
1976
2004
  endRound(round, true);
1977
2005
  roundByComponent.delete(this);
2006
+ clearRoundAnchor(this);
1978
2007
  return passThroughAssistant(this, message);
1979
2008
  }
1980
2009
  if (activeRound) finishRound(activeRound);
@@ -85,8 +85,8 @@ type GlobalToolRenderPatch = {
85
85
  prototype: any;
86
86
  active: boolean;
87
87
  mode: () => CompactStyleMode;
88
- wrap: (tool: any) => any;
89
- byDefinition: WeakMap<object, any>;
88
+ wrap: (tool: any, toolName: string) => any;
89
+ byDefinition: WeakMap<object, Map<string, any>>;
90
90
  byName: Map<string, any>;
91
91
  downstream: ToolRenderMethods;
92
92
  installed: ToolRenderMethods;
@@ -217,8 +217,8 @@ function renderExpandedTaskResult(
217
217
  function createCcstyleTool(
218
218
  originalTool: any,
219
219
  writeExecutionMetadata: WriteExecutionMetadataStore,
220
+ toolName: string,
220
221
  ): any {
221
- const toolName = originalTool.name;
222
222
  const label = originalTool.label || toolName;
223
223
  const defaultTitle = label === toolName ? humanizeToolLabel(label) : label;
224
224
  // MCP 工具直接用 adapter 暴露的真实工具名,不做人性化
@@ -459,19 +459,24 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
459
459
 
460
460
  function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
461
461
  const definition = component.toolDefinition ?? component.builtInToolDefinition;
462
+ const name = String(component.toolName || definition?.name || "tool");
462
463
  if (definition && typeof definition === "object") {
463
- 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);
464
470
  if (!wrapped) {
465
- wrapped = patch.wrap(definition);
466
- patch.byDefinition.set(definition, wrapped);
471
+ wrapped = patch.wrap(definition, name);
472
+ byName.set(name, wrapped);
467
473
  }
468
474
  return wrapped;
469
475
  }
470
476
 
471
- const name = String(component.toolName || "tool");
472
477
  let wrapped = patch.byName.get(name);
473
478
  if (!wrapped) {
474
- wrapped = patch.wrap({ name, label: name });
479
+ wrapped = patch.wrap({ name, label: name }, name);
475
480
  patch.byName.set(name, wrapped);
476
481
  }
477
482
  return wrapped;
@@ -568,7 +573,8 @@ function installGlobalToolRendering(
568
573
  prototype,
569
574
  active: true,
570
575
  mode: () => config.mode,
571
- wrap: (tool: any) => createCcstyleTool(tool, writeExecutionMetadata),
576
+ wrap: (tool: any, toolName: string) =>
577
+ createCcstyleTool(tool, writeExecutionMetadata, toolName),
572
578
  byDefinition: new WeakMap(),
573
579
  byName: new Map(),
574
580
  downstream,