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.
- package/README.en.md +8 -8
- package/README.md +8 -8
- package/extensions/config/config.ts +46 -16
- package/extensions/config/panel.ts +173 -43
- package/extensions/feature/compact-thinking.ts +12 -12
- package/extensions/feature/shell/footer-currency.ts +29 -0
- package/extensions/feature/shell/footer.ts +40 -51
- package/extensions/feature/shell/git-stats.ts +162 -0
- package/extensions/renderer/compact-mode.ts +35 -6
- package/extensions/renderer/default-mode.ts +15 -9
- package/extensions/renderer/mouse/interaction.ts +27 -25
- package/extensions/renderer/tool/codemode.ts +4 -1
- package/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
- package/extensions/renderer/tool/names.ts +2 -2
- package/package.json +1 -1
|
@@ -324,7 +324,7 @@ function hiddenPreviewHint(
|
|
|
324
324
|
return undefined;
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
const expandedThinking = new Set<
|
|
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.
|
|
376
|
+
return expandedThinking.has(`${this.messageTimestamp}:${this.runStartIndex}`);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
setExpanded(expanded: boolean): void {
|
|
380
|
-
|
|
381
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
415
|
+
if (!this.text || (config.previewLines <= 0 && !this.expanded)) {
|
|
416
416
|
return { lines: [], hiddenLines: 0 };
|
|
417
417
|
}
|
|
418
|
-
if (this.
|
|
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.
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
//
|
|
300
|
+
// 分支变化时重新选择基准,并拒绝旧分支的异步查询结果。
|
|
318
301
|
const unsubBranch = footerData.onBranchChange(() => {
|
|
319
|
-
|
|
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(
|
|
423
|
-
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
const
|
|
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)
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
576
|
+
wrap: (tool: any, toolName: string) =>
|
|
577
|
+
createCcstyleTool(tool, writeExecutionMetadata, toolName),
|
|
572
578
|
byDefinition: new WeakMap(),
|
|
573
579
|
byName: new Map(),
|
|
574
580
|
downstream,
|