@herbertgao/pi-extensions 2026.10.4 → 2026.10.5
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.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
- package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
- package/package.json +2 -2
|
@@ -14,13 +14,14 @@
|
|
|
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
|
+
import { formatTokenCount } from "../../utils/format.ts";
|
|
24
25
|
import {
|
|
25
26
|
PI_USAGE_KEY,
|
|
26
27
|
isSkippedFooterStatusKey,
|
|
@@ -28,7 +29,11 @@ import {
|
|
|
28
29
|
visibleFooterPluginTexts,
|
|
29
30
|
type FooterChipLayout,
|
|
30
31
|
} from "./footer-layout.ts";
|
|
32
|
+
import { createGitStatsRefresher, readGitStats, type GitStats } from "./git-stats.ts";
|
|
31
33
|
import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
|
|
34
|
+
import { currentTtftMs, displayTokenRate, formatTokenRate } from "./token-rate.ts";
|
|
35
|
+
|
|
36
|
+
export { parseGitStats } from "./git-stats.ts";
|
|
32
37
|
|
|
33
38
|
const GIT_REFRESH_INTERVAL_MS = 10_000;
|
|
34
39
|
const USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
@@ -48,7 +53,45 @@ export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string;
|
|
|
48
53
|
: { git: "", cache: "", mcp: "" };
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
/** 思考等级的填充圆标:等级越高填得越满。 */
|
|
57
|
+
const EFFORT_SYMBOL: Record<string, string> = {
|
|
58
|
+
minimal: "\u25cc",
|
|
59
|
+
low: "\u25cb",
|
|
60
|
+
medium: "\u25d4",
|
|
61
|
+
high: "\u25d1",
|
|
62
|
+
xhigh: "\u25d5",
|
|
63
|
+
max: "\u25cf",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** 模型名后直接接圆标和等级,中间不用分隔点。 */
|
|
67
|
+
export function modelChip(
|
|
68
|
+
theme: { fg: (color: string, text: string) => string },
|
|
69
|
+
modelLabel: string,
|
|
70
|
+
level: string,
|
|
71
|
+
thinkingColor: (text: string) => string,
|
|
72
|
+
): string {
|
|
73
|
+
const symbol = EFFORT_SYMBOL[level];
|
|
74
|
+
if (!symbol) return theme.fg("accent", modelLabel);
|
|
75
|
+
return `${theme.fg("accent", modelLabel)} ${thinkingColor(symbol)} ${theme.fg("dim", level)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** git 状态符号按职能分色:冲突/落后 error,修改 warning,未跟踪 muted,领先 success。 */
|
|
79
|
+
export function colorGitStatus(
|
|
80
|
+
theme: { fg: (color: string, text: string) => string },
|
|
81
|
+
status: string,
|
|
82
|
+
): string {
|
|
83
|
+
let out = "";
|
|
84
|
+
let color = "dim";
|
|
85
|
+
for (const ch of status) {
|
|
86
|
+
if (ch === "=" || ch === "\u21e3") color = "error";
|
|
87
|
+
else if (ch === "!") color = "warning";
|
|
88
|
+
else if (ch === "?") color = "muted";
|
|
89
|
+
else if (ch === "\u21e1") color = "success";
|
|
90
|
+
else if (!/[0-9]/.test(ch)) color = "dim";
|
|
91
|
+
out += theme.fg(color, ch);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
52
95
|
|
|
53
96
|
type XaiFooterReport = {
|
|
54
97
|
buckets?: Array<{
|
|
@@ -61,19 +104,6 @@ type XaiFooterReport = {
|
|
|
61
104
|
metrics?: Array<{ id?: string; value?: unknown }>;
|
|
62
105
|
};
|
|
63
106
|
|
|
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
107
|
export function formatXaiFooterChip(report: XaiFooterReport): string | undefined {
|
|
78
108
|
const included = report.buckets?.find((b) => b.id === "included-allowance");
|
|
79
109
|
if (included?.unit === "percent" && typeof included.used === "number") {
|
|
@@ -180,7 +210,12 @@ function colorUsageChip(theme: any, text: string): string {
|
|
|
180
210
|
}
|
|
181
211
|
|
|
182
212
|
let currentTui: any = undefined;
|
|
183
|
-
let refreshCurrentGitStats: (() => void) | undefined;
|
|
213
|
+
let refreshCurrentGitStats: ((reset?: boolean) => void) | undefined;
|
|
214
|
+
|
|
215
|
+
/** Apply a /ccstyle Git mode change immediately, without recreating the footer. */
|
|
216
|
+
export function refreshFooterGitStats(): void {
|
|
217
|
+
refreshCurrentGitStats?.(true);
|
|
218
|
+
}
|
|
184
219
|
let refreshCurrentUsage: (() => void) | undefined;
|
|
185
220
|
let refreshCurrentMcpChip: (() => void) | undefined;
|
|
186
221
|
/** 工具/命令列表在 pi API 上,而 footer 工厂只拿得到 ctx,加载时记下来。 */
|
|
@@ -192,33 +227,23 @@ const createCustomFooterFactory =
|
|
|
192
227
|
const sep = theme.fg("muted", " · ");
|
|
193
228
|
const joinChips = (parts: string[]) => parts.filter(Boolean).join(sep);
|
|
194
229
|
let gitStats: GitStats | undefined;
|
|
195
|
-
let gitRefreshRunning = false;
|
|
196
230
|
let localUsageChip = "";
|
|
197
231
|
let usageGeneration = 0;
|
|
198
232
|
let usageAbort: AbortController | undefined;
|
|
199
233
|
let disposed = false;
|
|
200
234
|
|
|
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);
|
|
235
|
+
// 查询在 render 外执行;模式/分支变化清旧值,普通轮询只在统计变化时重绘。
|
|
236
|
+
const gitRefresher = createGitStatsRefresher({
|
|
237
|
+
getMode: () => config.footerGitStatsMode,
|
|
238
|
+
query: (mode, signal) => readGitStats(ctx.cwd, mode, signal),
|
|
239
|
+
onChange: (next) => {
|
|
240
|
+
gitStats = next;
|
|
241
|
+
tui.requestRender();
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
refreshCurrentGitStats = gitRefresher.refresh;
|
|
245
|
+
gitRefresher.refresh();
|
|
246
|
+
const gitRefreshTimer = setInterval(() => gitRefresher.refresh(), GIT_REFRESH_INTERVAL_MS);
|
|
222
247
|
gitRefreshTimer.unref?.();
|
|
223
248
|
|
|
224
249
|
const refreshUsage = () => {
|
|
@@ -314,19 +339,13 @@ const createCustomFooterFactory =
|
|
|
314
339
|
const mcpRefreshTimer = setInterval(refreshMcp, MCP_REFRESH_INTERVAL_MS);
|
|
315
340
|
mcpRefreshTimer.unref?.();
|
|
316
341
|
|
|
317
|
-
//
|
|
342
|
+
// 分支变化时重新选择基准,并拒绝旧分支的异步查询结果。
|
|
318
343
|
const unsubBranch = footerData.onBranchChange(() => {
|
|
319
|
-
|
|
344
|
+
gitRefresher.refresh(true);
|
|
320
345
|
tui.requestRender();
|
|
321
346
|
});
|
|
322
347
|
|
|
323
|
-
const fmt =
|
|
324
|
-
if (n < 1000) return `${n}`;
|
|
325
|
-
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
326
|
-
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
327
|
-
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
328
|
-
return `${Math.round(n / 1000000)}M`;
|
|
329
|
-
};
|
|
348
|
+
const fmt = formatTokenCount;
|
|
330
349
|
|
|
331
350
|
// ---- pi-cc-status 风格:上下文条形图 ----
|
|
332
351
|
const BAR_WIDTH = 10;
|
|
@@ -417,16 +436,26 @@ const createCustomFooterFactory =
|
|
|
417
436
|
const glyphs = footerGlyphs(config.footerNerdIcons);
|
|
418
437
|
const cacheLabel =
|
|
419
438
|
cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
|
|
439
|
+
const rateLabel = config.footerTokenRate
|
|
440
|
+
? formatTokenRate(
|
|
441
|
+
displayTokenRate(config.footerTokenRateMode),
|
|
442
|
+
currentTtftMs(),
|
|
443
|
+
config.footerTokenRateMode,
|
|
444
|
+
)
|
|
445
|
+
: "";
|
|
446
|
+
const rateChip = rateLabel ? theme.fg("dim", rateLabel) : "";
|
|
420
447
|
const costChip =
|
|
421
448
|
cost || usingSubscription
|
|
422
|
-
? theme.fg(
|
|
423
|
-
|
|
449
|
+
? theme.fg(
|
|
450
|
+
"dim",
|
|
451
|
+
formatFooterCost(cost, config.footerCurrency, config.footerCurrencyRate),
|
|
452
|
+
) + (usingSubscription ? theme.fg("warning", " sub") : "")
|
|
424
453
|
: "";
|
|
425
454
|
const line1 = joinChips([
|
|
426
|
-
theme
|
|
427
|
-
model?.reasoning ? thinkingColor(thinkingLevelStr) : "",
|
|
455
|
+
modelChip(theme, modelLabel, model?.reasoning ? thinkingLevelStr : "", thinkingColor),
|
|
428
456
|
barGauge(percent ?? 0) + theme.fg("dim", ` ${pctLabel}/${fmt(contextWindow)}`),
|
|
429
457
|
cacheLabel ? theme.fg("dim", cacheLabel) : "",
|
|
458
|
+
rateChip,
|
|
430
459
|
costChip,
|
|
431
460
|
...line1Plugins,
|
|
432
461
|
]);
|
|
@@ -439,14 +468,17 @@ const createCustomFooterFactory =
|
|
|
439
468
|
}
|
|
440
469
|
const branch = footerData.getGitBranch();
|
|
441
470
|
if (branch) {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
471
|
+
const s = gitStats;
|
|
472
|
+
let stats = "";
|
|
473
|
+
if (s && s.status)
|
|
474
|
+
stats = theme.fg("dim", " [") + colorGitStatus(theme, s.status) + theme.fg("dim", "]");
|
|
475
|
+
if (s && (s.add || s.del))
|
|
476
|
+
stats +=
|
|
477
|
+
theme.fg("dim", "(") +
|
|
478
|
+
theme.fg("success", `+${s.add}`) +
|
|
479
|
+
" " +
|
|
480
|
+
theme.fg("error", `−${s.del}`) +
|
|
481
|
+
theme.fg("dim", ")");
|
|
450
482
|
const gitLabel = glyphs.git ? `${glyphs.git} ${branch}` : branch;
|
|
451
483
|
place += theme.fg("muted", " on ") + gitColor(gitLabel) + stats;
|
|
452
484
|
}
|
|
@@ -467,6 +499,7 @@ const createCustomFooterFactory =
|
|
|
467
499
|
},
|
|
468
500
|
dispose(): void {
|
|
469
501
|
disposed = true;
|
|
502
|
+
gitRefresher.dispose();
|
|
470
503
|
usageAbort?.abort();
|
|
471
504
|
usageAbort = undefined;
|
|
472
505
|
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
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footer 输出速率的共享状态:working-message 负责采集,footer 负责显示。
|
|
3
|
+
*
|
|
4
|
+
* average:一次用户指令的墙钟平均(工具等待计入分母),显示 `avg`。
|
|
5
|
+
* live:最近 1 秒的瞬时速率,生成期暂停时钟,显示 `live`;指令结束定格为整段平均。
|
|
6
|
+
* token 与 working-message 同源:优先 provider `usage.output`,否则按 delta 增量估算
|
|
7
|
+
* (CJK 1 token,其余 chars/4)。provider 值回填到本段消息窗口。
|
|
8
|
+
* Pi 的 turn 是单次 assistant 消息,工具调用结束会开新 turn。token 跨 turn 累加,
|
|
9
|
+
* 计时与 TTFT 直到下一条用户指令才重置。
|
|
10
|
+
* TTFT:用户指令开始到第一个内容块(text/thinking/toolcall)开始的毫秒数,
|
|
11
|
+
* 同一指令内只记第一次,定格后保留到下一条指令覆盖。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TokenRateMode = "average" | "live";
|
|
15
|
+
|
|
16
|
+
const LIVE_WINDOW_MS = 1_000;
|
|
17
|
+
const MIN_SPAN_MS = 250;
|
|
18
|
+
const CJK_CHARS =
|
|
19
|
+
/[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
|
|
20
|
+
|
|
21
|
+
interface TokenSample {
|
|
22
|
+
t: number;
|
|
23
|
+
cum: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let runStartTime = 0;
|
|
27
|
+
let carriedTokens = 0;
|
|
28
|
+
let currentTokens = 0;
|
|
29
|
+
let pauseStartedAt = 0;
|
|
30
|
+
/** 并行工具调用数;>0 时瞬时采样暂停。 */
|
|
31
|
+
let activeTools = 0;
|
|
32
|
+
let lastRate = 0;
|
|
33
|
+
let lastLiveRate = 0;
|
|
34
|
+
let firstTokenAt = 0;
|
|
35
|
+
let samples: TokenSample[] = [];
|
|
36
|
+
let usageAnchorTime = 0;
|
|
37
|
+
let usageAnchorTokens = 0;
|
|
38
|
+
let countedUsage = 0;
|
|
39
|
+
|
|
40
|
+
/** 新的用户指令:重置计时、累计 token、定格值与 TTFT。 */
|
|
41
|
+
export function resetTokenRate(startAt = 0): void {
|
|
42
|
+
runStartTime = startAt;
|
|
43
|
+
carriedTokens = 0;
|
|
44
|
+
currentTokens = 0;
|
|
45
|
+
pauseStartedAt = 0;
|
|
46
|
+
activeTools = 0;
|
|
47
|
+
lastRate = 0;
|
|
48
|
+
lastLiveRate = 0;
|
|
49
|
+
firstTokenAt = 0;
|
|
50
|
+
samples = [];
|
|
51
|
+
usageAnchorTime = 0;
|
|
52
|
+
usageAnchorTokens = 0;
|
|
53
|
+
countedUsage = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 一段 assistant 回复结束:保留计时与 TTFT,把本段已计 token 结算进累计。
|
|
58
|
+
* 下一段的 token 从 0 重计,采样时加回来。
|
|
59
|
+
*/
|
|
60
|
+
export function carryTokenRate(): void {
|
|
61
|
+
if (currentTokens > 0) carriedTokens += currentTokens;
|
|
62
|
+
currentTokens = 0;
|
|
63
|
+
countedUsage = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 工具开始执行:计数 +1,第一个工具才真正暂停瞬时采样。 */
|
|
67
|
+
export function pauseTokenRate(now = Date.now()): void {
|
|
68
|
+
if (!runStartTime) return;
|
|
69
|
+
activeTools += 1;
|
|
70
|
+
if (activeTools === 1 && !pauseStartedAt) pauseStartedAt = now;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 工具执行结束:计数 -1,最后一个工具结束才恢复采样。 */
|
|
74
|
+
export function resumeTokenRate(): void {
|
|
75
|
+
activeTools = Math.max(0, activeTools - 1);
|
|
76
|
+
if (activeTools === 0 && pauseStartedAt) {
|
|
77
|
+
pauseStartedAt = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 本指令第一个内容块开始时调用;同一指令后续消息不覆盖。 */
|
|
82
|
+
export function markFirstToken(now = Date.now()): void {
|
|
83
|
+
if (runStartTime && !firstTokenAt) firstTokenAt = now;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function estimateTokens(text: string): number {
|
|
87
|
+
if (!text) return 0;
|
|
88
|
+
const cjk = text.match(CJK_CHARS)?.length ?? 0;
|
|
89
|
+
return Math.ceil(cjk + (text.length - cjk) / 4);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pushSample(time: number, cum: number): void {
|
|
93
|
+
samples.push({ t: time, cum });
|
|
94
|
+
const cutoff = time - LIVE_WINDOW_MS;
|
|
95
|
+
while (samples.length > 1 && samples[0]!.t < cutoff) samples.shift();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function updateRates(now: number): void {
|
|
99
|
+
const total = carriedTokens + currentTokens;
|
|
100
|
+
const elapsed = (now - runStartTime) / 1000;
|
|
101
|
+
if (elapsed > 0) lastRate = total / elapsed;
|
|
102
|
+
if (samples.length < 2) return;
|
|
103
|
+
const first = samples[0]!;
|
|
104
|
+
const last = samples[samples.length - 1]!;
|
|
105
|
+
const span = last.t - first.t;
|
|
106
|
+
if (span >= MIN_SPAN_MS) {
|
|
107
|
+
lastLiveRate = ((last.cum - first.cum) * 1000) / span;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 每个文本增量采样一次。工具暂停期的增量只累计 token,不进窗口。 */
|
|
112
|
+
export function sampleTokenDelta(delta: string, now = Date.now()): void {
|
|
113
|
+
if (!runStartTime) return;
|
|
114
|
+
const added = estimateTokens(delta);
|
|
115
|
+
if (added <= 0) return;
|
|
116
|
+
currentTokens += added;
|
|
117
|
+
if (pauseStartedAt) return;
|
|
118
|
+
pushSample(now, carriedTokens + currentTokens);
|
|
119
|
+
updateRates(now);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 记录本段 provider `usage.output`。usage 按锚点摊回窗口:
|
|
124
|
+
* 首次报告从消息起点铺到现在,之后每次把增量摊在上次报告之后。
|
|
125
|
+
*/
|
|
126
|
+
export function recordProviderUsage(output: number, now = Date.now()): void {
|
|
127
|
+
if (!runStartTime || output <= 0) return;
|
|
128
|
+
const firstReport = countedUsage === 0;
|
|
129
|
+
const jump = output - countedUsage;
|
|
130
|
+
countedUsage = output;
|
|
131
|
+
// provider 是权威计数;首次报告以它为准,之后增量接管。
|
|
132
|
+
currentTokens = output;
|
|
133
|
+
if (firstReport) {
|
|
134
|
+
samples = samples.filter((sample) => sample.t < usageAnchorTime);
|
|
135
|
+
pushSample(usageAnchorTime, carriedTokens + usageAnchorTokens);
|
|
136
|
+
}
|
|
137
|
+
const span = Math.max(now - usageAnchorTime, 1);
|
|
138
|
+
const count = Math.min(Math.max(Math.ceil(jump / 16), 1), 32);
|
|
139
|
+
const base = firstReport
|
|
140
|
+
? carriedTokens + usageAnchorTokens
|
|
141
|
+
: carriedTokens + currentTokens - jump;
|
|
142
|
+
for (let i = 1; i <= count; i++) {
|
|
143
|
+
pushSample(usageAnchorTime + (span * i) / count, base + (jump * i) / count);
|
|
144
|
+
}
|
|
145
|
+
usageAnchorTime = now;
|
|
146
|
+
usageAnchorTokens = currentTokens;
|
|
147
|
+
updateRates(now);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 本段消息开始:重置 provider 锚点。 */
|
|
151
|
+
export function startMessageSegment(now = Date.now()): void {
|
|
152
|
+
if (!runStartTime) return;
|
|
153
|
+
usageAnchorTime = now;
|
|
154
|
+
usageAnchorTokens = currentTokens;
|
|
155
|
+
countedUsage = 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 整段指令的累计 token 数:已结转段 + 当前段。 */
|
|
159
|
+
export function totalTokens(): number {
|
|
160
|
+
return carriedTokens + currentTokens;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** average 的当前值:指令中为实时平均,指令外为上一段定格值。 */
|
|
164
|
+
export function currentTokenRate(): number {
|
|
165
|
+
return lastRate;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 按显示模式取值。live 在生成中取窗口速率,窗口数据不足或指令结束后回到整段平均。
|
|
170
|
+
* 工具暂停期间时钟不动,显示上一条窗口值。
|
|
171
|
+
*/
|
|
172
|
+
export function displayTokenRate(mode: TokenRateMode): number {
|
|
173
|
+
if (mode === "live" && samples.length >= 2 && lastLiveRate > 0) {
|
|
174
|
+
return lastLiveRate;
|
|
175
|
+
}
|
|
176
|
+
return lastRate;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 本指令 TTFT(毫秒);未出首 token 或无活动指令返回 0。定格后保留。 */
|
|
180
|
+
export function currentTtftMs(): number {
|
|
181
|
+
if (!runStartTime || !firstTokenAt) return 0;
|
|
182
|
+
return Math.max(0, firstTokenAt - runStartTime);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `avg 1.2k tok/s (TTFT: 812ms)`;无数据显示空串,未出首 token 时无括号段。 */
|
|
186
|
+
export function formatTokenRate(rate: number, ttftMs = 0, mode: TokenRateMode = "average"): string {
|
|
187
|
+
if (!Number.isFinite(rate) || rate <= 0) return "";
|
|
188
|
+
const value = rate >= 1000 ? `${(rate / 1000).toFixed(1)}k` : `${Math.round(rate)}`;
|
|
189
|
+
const ttft = Number.isFinite(ttftMs) && ttftMs > 0 ? ` (TTFT: ${Math.round(ttftMs)}ms)` : "";
|
|
190
|
+
const label = mode === "live" ? "live" : "avg";
|
|
191
|
+
return `${label} ${value} tok/s${ttft}`;
|
|
192
|
+
}
|