@herbertgao/pi-extensions 2026.10.4 → 2026.10.6
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 +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +13 -9
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +13 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +72 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +262 -48
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/core.ts +2 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +131 -28
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +2 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/subagent.ts +3 -0
- 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 +96 -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 +204 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -86
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +212 -74
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +50 -362
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +35 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +16 -83
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +28 -26
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +450 -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/ccstyle-tool.ts +467 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +5 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +1 -24
- 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-limits.ts +1 -1
- 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-text.ts +2 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +36 -10
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +140 -51
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/message-display.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +44 -4
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +34 -803
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-io.ts +735 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/tool-state.ts +138 -0
- 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 +5 -4
- package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
- package/node_modules/@herbertgao/sol-pi/README.md +3 -3
- package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
- package/node_modules/@herbertgao/sol-pi/agents-install.md +3 -3
- package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +1 -1
- package/package.json +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/show-more-hint.ts +0 -48
|
@@ -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,204 @@
|
|
|
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
|
+
/** 不足 1 秒用毫秒,否则用一位小数的秒。 */
|
|
186
|
+
export function formatTtft(ttftMs: number): string {
|
|
187
|
+
if (ttftMs < 1000) return `${Math.round(ttftMs)}ms`;
|
|
188
|
+
return `${(ttftMs / 1000).toFixed(1)}s`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `avg 1.2k tok/s (TTFT: 812ms)`;无数据显示空串,未出首 token 或 showTtft 为 false 时无括号段。 */
|
|
192
|
+
export function formatTokenRate(
|
|
193
|
+
rate: number,
|
|
194
|
+
ttftMs = 0,
|
|
195
|
+
mode: TokenRateMode = "average",
|
|
196
|
+
showTtft = true,
|
|
197
|
+
): string {
|
|
198
|
+
if (!Number.isFinite(rate) || rate <= 0) return "";
|
|
199
|
+
const value = rate >= 1000 ? `${(rate / 1000).toFixed(1)}k` : `${Math.round(rate)}`;
|
|
200
|
+
const ttft =
|
|
201
|
+
showTtft && Number.isFinite(ttftMs) && ttftMs > 0 ? ` (TTFT: ${formatTtft(ttftMs)})` : "";
|
|
202
|
+
const label = mode === "live" ? "live" : "avg";
|
|
203
|
+
return `${label} ${value} tok/s${ttft}`;
|
|
204
|
+
}
|
package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts
CHANGED
|
@@ -1,47 +1,26 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { formatDuration, formatTokenCount } from "../../utils/format.ts";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
resetTokenRate,
|
|
5
|
+
carryTokenRate,
|
|
6
|
+
pauseTokenRate,
|
|
7
|
+
resumeTokenRate,
|
|
8
|
+
markFirstToken,
|
|
9
|
+
sampleTokenDelta,
|
|
10
|
+
recordProviderUsage,
|
|
11
|
+
startMessageSegment,
|
|
12
|
+
totalTokens,
|
|
13
|
+
} from "./token-rate.ts";
|
|
9
14
|
|
|
10
15
|
const REFRESH_INTERVAL_MS = 1_000;
|
|
11
16
|
/** Elapsed time is only shown once the turn has run this long. */
|
|
12
17
|
const SHOW_TIMER_AFTER_MS = 3_000;
|
|
13
18
|
|
|
14
|
-
function formatCount(value: number): string {
|
|
15
|
-
return new Intl.NumberFormat("en-US").format(value);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
type ContentBlock = {
|
|
19
|
-
type?: unknown;
|
|
20
|
-
text?: unknown;
|
|
21
|
-
thinkingSignature?: { body?: unknown };
|
|
22
|
-
};
|
|
23
|
-
|
|
24
19
|
type StreamMessage = {
|
|
25
20
|
content?: unknown;
|
|
26
21
|
usage?: { output?: unknown };
|
|
27
22
|
};
|
|
28
23
|
|
|
29
|
-
/** 每个 content index 的可见文本/思考长度;无对应块的 index 保持稀疏洞。 */
|
|
30
|
-
function textBlockLengths(message: StreamMessage): number[] {
|
|
31
|
-
const content = message.content;
|
|
32
|
-
if (!Array.isArray(content)) return [];
|
|
33
|
-
const lengths: number[] = [];
|
|
34
|
-
for (let index = 0; index < content.length; index++) {
|
|
35
|
-
const block = content[index] as ContentBlock;
|
|
36
|
-
if (block?.type === "text" && typeof block.text === "string") {
|
|
37
|
-
lengths[index] = block.text.length;
|
|
38
|
-
} else if (block?.type === "thinking" && block.thinkingSignature?.body) {
|
|
39
|
-
lengths[index] = (block.thinkingSignature.body as string).length;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return lengths;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
24
|
function outputUsage(message: StreamMessage): number {
|
|
46
25
|
const value = Number(message?.usage?.output);
|
|
47
26
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
|
|
@@ -55,60 +34,30 @@ type WorkingUi = {
|
|
|
55
34
|
* Extend Pi's footer working row while preserving its spinner and "Working...":
|
|
56
35
|
* `⠋ Working... (↓ 1,234 tokens · 12s)`
|
|
57
36
|
*
|
|
58
|
-
*
|
|
59
|
-
* provider `usage.output` whenever the stream exposes an actual count.
|
|
60
|
-
*/
|
|
37
|
+
* token 数直接读 token-rate 的累计(估算 → provider usage 同源),不再本地重数。 */
|
|
61
38
|
export default function (pi: ExtensionAPI): void {
|
|
62
39
|
let turnActive = false;
|
|
63
40
|
let agentStartTime = 0;
|
|
64
41
|
let turnStartTime = 0;
|
|
65
|
-
|
|
66
|
-
let
|
|
67
|
-
let providerOutputTokens = 0;
|
|
42
|
+
/** 当前用户指令的起点;工具调用后的 turn 复用它,直到下一条指令。 */
|
|
43
|
+
let rateRunStartTime = 0;
|
|
68
44
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
45
|
let lastMessage: string | null = null;
|
|
70
46
|
let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
|
|
71
47
|
|
|
72
48
|
function tokenCount(): number {
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function setTextBlockLength(index: number, length: number): void {
|
|
77
|
-
const previous = responseTextBlockLengths[index] ?? 0;
|
|
78
|
-
responseTextBlockLengths[index] = Math.max(0, length);
|
|
79
|
-
responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function resetResponseTracking(message?: StreamMessage): void {
|
|
83
|
-
responseTextBlockLengths = message ? textBlockLengths(message) : [];
|
|
84
|
-
responseLength = responseTextBlockLengths.reduce((sum, length) => sum + length, 0);
|
|
85
|
-
providerOutputTokens = message ? outputUsage(message) : 0;
|
|
49
|
+
return totalTokens();
|
|
86
50
|
}
|
|
87
51
|
|
|
88
52
|
function updateProviderUsage(message: StreamMessage): void {
|
|
89
53
|
const output = outputUsage(message);
|
|
90
|
-
if (output > 0)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 摘要行滚出视口才镜像:仅 fullscreen 且已离开 transcript 底部。
|
|
95
|
-
* regular 没有“离开底部”信号(transcript 在终端回滚区),保持 Pi 默认文案。
|
|
96
|
-
*/
|
|
97
|
-
function compactMirrorText(): string | undefined {
|
|
98
|
-
const tui = getToolMouseTui();
|
|
99
|
-
if (!tui || !fullscreenLazyTui(tui) || isFullscreenAtBottom(tui)) return undefined;
|
|
100
|
-
return getCompactRunStatusText();
|
|
54
|
+
if (output > 0) recordProviderUsage(output);
|
|
101
55
|
}
|
|
102
56
|
|
|
103
57
|
function buildWorkingMessage(): string {
|
|
104
|
-
// turn_end 后迟到的 provider 事件不得再驱动 footer(否则渲染出 epoch 级计时)。
|
|
105
|
-
if (!turnActive) return "";
|
|
106
58
|
const parts: string[] = [];
|
|
107
59
|
const tokens = tokenCount();
|
|
108
|
-
if (tokens > 0) parts.push(`↓ ${
|
|
109
|
-
// compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
|
|
110
|
-
const compactStatus = compactMirrorText();
|
|
111
|
-
if (compactStatus) return [compactStatus, ...parts].join(" · ");
|
|
60
|
+
if (tokens > 0) parts.push(`↓ ${formatTokenCount(tokens)} tokens`);
|
|
112
61
|
const elapsed = Date.now() - (agentStartTime || turnStartTime);
|
|
113
62
|
if (elapsed >= SHOW_TIMER_AFTER_MS || tokens > 0) {
|
|
114
63
|
// formatDuration 低于 1 秒返回 "",此处回退 "0s" 保持计时器连续跳动。
|
|
@@ -181,7 +130,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
181
130
|
stopRefreshLoop();
|
|
182
131
|
agentStartTime = 0;
|
|
183
132
|
turnStartTime = 0;
|
|
184
|
-
|
|
133
|
+
// 不重置 token-rate:footer 定格显示上一回合速率
|
|
185
134
|
restoreDefaultWorkingMessage();
|
|
186
135
|
}
|
|
187
136
|
|
|
@@ -189,12 +138,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
189
138
|
if (!agentStartTime) agentStartTime = Date.now();
|
|
190
139
|
});
|
|
191
140
|
|
|
192
|
-
pi.on("turn_start", async (
|
|
141
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
193
142
|
turnActive = true;
|
|
194
143
|
activeCtx = ctx;
|
|
195
|
-
|
|
144
|
+
// 事件时间戳在 handler 之前产生;用 Date.now() 会和紧随的采样落在同一毫秒,分母变成 0。
|
|
145
|
+
turnStartTime = event?.timestamp || Date.now();
|
|
196
146
|
if (!agentStartTime) agentStartTime = turnStartTime;
|
|
197
|
-
|
|
147
|
+
// 工具调用后的 turn 属于同一条用户指令:计时、TTFT 与累计 token 保持到下一条指令。
|
|
148
|
+
// 速率起点用本指令第一个 turn 的时间戳;before_agent_start 只当指令 id。
|
|
149
|
+
if (rateRunStartTime !== agentStartTime) {
|
|
150
|
+
rateRunStartTime = agentStartTime;
|
|
151
|
+
resetTokenRate(turnStartTime);
|
|
152
|
+
}
|
|
153
|
+
startMessageSegment(turnStartTime);
|
|
198
154
|
syncWorkingMessage(true);
|
|
199
155
|
scheduleRefreshTick();
|
|
200
156
|
});
|
|
@@ -205,25 +161,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
205
161
|
if (!evt) return;
|
|
206
162
|
|
|
207
163
|
if (evt.type === "start") {
|
|
208
|
-
|
|
209
|
-
} else if (
|
|
210
|
-
|
|
164
|
+
// 新 assistant 消息起点:速率分段已在 turn_start 处理。
|
|
165
|
+
} else if (
|
|
166
|
+
evt.type === "thinking_start" ||
|
|
167
|
+
evt.type === "text_start" ||
|
|
168
|
+
evt.type === "toolcall_start"
|
|
169
|
+
) {
|
|
170
|
+
// TTFT 的首块含 toolcall_start:模型直接发起 tool call(thinking/text 省略)时也算首 token。
|
|
171
|
+
// 分段锚点只在 turn_start 重置。这里再重置会把累计 usage.output 当成新的首报。
|
|
172
|
+
markFirstToken();
|
|
211
173
|
updateProviderUsage(evt.partial);
|
|
212
174
|
} else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
|
|
213
|
-
|
|
214
|
-
setTextBlockLength(evt.contentIndex, (responseTextBlockLengths[evt.contentIndex] ?? 0) + add);
|
|
175
|
+
if (typeof evt.delta === "string") sampleTokenDelta(evt.delta);
|
|
215
176
|
updateProviderUsage(evt.partial);
|
|
216
|
-
} else if (evt.type === "text_end") {
|
|
217
|
-
setTextBlockLength(
|
|
218
|
-
evt.contentIndex,
|
|
219
|
-
typeof evt.content === "string" ? evt.content.length : 0,
|
|
220
|
-
);
|
|
177
|
+
} else if (evt.type === "text_end" || evt.type === "thinking_end") {
|
|
221
178
|
updateProviderUsage(evt.partial);
|
|
222
179
|
} else if (evt.type === "done") {
|
|
223
|
-
|
|
180
|
+
updateProviderUsage(evt.message);
|
|
224
181
|
} else if (evt.type === "error") {
|
|
225
|
-
|
|
226
|
-
resetResponseTracking();
|
|
182
|
+
updateProviderUsage(evt.error);
|
|
227
183
|
} else {
|
|
228
184
|
updateProviderUsage(evt.partial);
|
|
229
185
|
}
|
|
@@ -232,11 +188,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
232
188
|
scheduleRefreshTick();
|
|
233
189
|
});
|
|
234
190
|
|
|
191
|
+
pi.on("tool_execution_start", async () => {
|
|
192
|
+
pauseTokenRate();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("tool_execution_end", async () => {
|
|
196
|
+
resumeTokenRate();
|
|
197
|
+
});
|
|
198
|
+
|
|
235
199
|
pi.on("turn_end", async (_event, ctx) => {
|
|
236
200
|
turnActive = false;
|
|
237
201
|
activeCtx = ctx;
|
|
238
202
|
stopRefreshLoop();
|
|
239
|
-
|
|
203
|
+
// 本段 token 结转进累计,下一段 turn 继续累加;此处不清速率。
|
|
204
|
+
carryTokenRate();
|
|
240
205
|
// No completion message: return immediately to Pi's default idle state.
|
|
241
206
|
restoreDefaultWorkingMessage();
|
|
242
207
|
});
|