@yalieny/pi-better-cost-display-footer 2.1.2 → 2.3.0

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 CHANGED
@@ -6,6 +6,7 @@ pi 扩展:脚本化多段计价动态列表 + footer 增强。
6
6
  - **计费自愈**:每轮比对期望 cost(配置 × 当前档位)与实际参与计费的 `ctx.model.cost`,不一致则重注册纠正;连续 3 次未收敛则停止重试并在 footer 显式告警(金额前 `≈`、标签加 `?`),不再静默错价.
7
7
  - **档位标签**:价格后紧跟当前档位标签(如 `¥0.047(梁文峰)`),按档位 id 配置文案与颜色;未配置标签的档位不显示。
8
8
  - **CH 精度**:footer 缓存命中率小数位可配(默认 1 位)。
9
+ - **tok/s 速率(思考/正文分相位)**:footer 常显 —— 流式中只显示当前相位的实时值 `⚡45.2t/s`(思考中/出字中都是这个形式,无前缀,2s 滚动窗口);assistant 消息结束(工具执行前)后冻结,只在冻结态带前缀,固定三档 `⚡(175.1/r31.8/o613.4)t/s` = 总吞吐 / 思考(r标识) / 正文(o标识),无值打 `-` 占位:本条无思考就是 `⚡(334.8/r-/o336.2)t/s`(无思考时总与正文本就重合)。分母均从各相位首 delta 起算,不含首 token 延迟与工具执行时长。实时值为估算(字符折算 token),思考/正文各自的折算比由真实 `usage.output`/`usage.reasoning` EMA 自校准(初始 4,限幅 0.5~8);异常速率(>1000 t/s)丢弃。注:DeepSeek 在工具循环的续写消息里经常不吐思考,所以 r 档会时有时无(显示为 `-`)。
9
10
  - **货币符号**:按 provider 配置计费金额符号(默认 `$`)。
10
11
  - **配置升级**:旧版顶层计价配置与 `peakWindows` 首次启动自动迁移为 provider 分层 + `tierFn` 格式(语义不变)。
11
12
 
@@ -108,6 +109,40 @@ node --experimental-strip-types test/drift-probe.ts # 端到端:守卫重
108
109
  - 模型缺少当前档位价格时保持原价并打日志。
109
110
  - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
110
111
 
112
+ ## footer 与 pi 升级
113
+
114
+ 本扩展不再复刻 pi 的 footer。它在 `session_start` 加载已安装 pi 自己的
115
+ `dist/modes/interactive/components/footer.js`(路径由 `pi` 的 bin 反推),把内置的
116
+ `FooterComponent` 包一层,只在**渲染结果上打补丁**:
117
+
118
+ 1. 金额符号(`currencySymbol`,内置硬编码 `$`)。
119
+ 2. CH 小数位(`cacheHitRatePrecision`,内置硬编码 1 位)。
120
+ 3. context 占用条(`█░░░░░░░░░ 10.5%/1.0M`;外观抄 `pi-cc-extensions` 的 `barGauge`,≥80% 转 warning、≥95% 转 error)。
121
+ 4. 档位标签(内置没有)。
122
+ 5. tok/s 速率段(内置没有)。
123
+ 6. pwd 行:cwd 只显示最底层目录(抄 cc),分支换成 `⎇ 分支` 底色 chip(图标抄 look pack),后跟 git 变更行数 `+12 −3`(数据与刷新抄 cc:`git diff --numstat HEAD`,启动查一次 + 10s 轮询 + 分支变化重查,跳过二进制行)。
124
+ 7. 右侧:模型(含 `(provider)` 前缀)上底色 chip(抄 look pack),thinking 级别上色(抄 cc;分隔符沿用内置的 `•`)。
125
+
126
+ pwd 行、统计段布局、对齐、截断、状态行、以及 pi 以后新增的字段,全部用内置渲染:
127
+ pi 改 footer 会自动跟随,不会用旧版布局覆盖。补丁只改行的文本,并按补白收缩保证行宽不变。
128
+ 空间不够时按 速率 → 标签 → 占用条 的顺序舍弃。
129
+
130
+ 降级(都不会弄坏 footer):
131
+
132
+ - 拿不到内置组件(单文件二进制、源码运行、构造失败)→ 不替换,直接用 pi 自带 footer,并提醒一次。
133
+ - 内置 stats 行结构不认识(补丁打不上)→ 内置原样渲染,提醒一次;`/cost-tier` 的 `footer:` 一行给出状态。
134
+
135
+ ## footer 控制权(与其他扩展共存)
136
+
137
+ pi 没有“叠加 footer”的扩展点。两家扩展都调 `setFooter` 时,后调用的直接覆盖前一个
138
+ (顺序 = `settings.packages` 顺序)。已知的竞争者:`pi-cc-extensions`(`enableCustomFooter: true`,
139
+ 两行 chips 状态栏)与 `awesome-pi-themes` 的 look pack。
140
+
141
+ 本插件是通用插件,不做针对某个扩展的特例,总是接管 footer。要同时用对方的 footer,
142
+ 就关掉一个(cc:`/ccstyle`,或 `"enableCustomFooter": false`)。
143
+
111
144
  ## 变更记录
112
145
 
146
+ - **2.3.0** footer 改为“增量补丁”而非整段复刻:加载并包装 pi 自己的 `FooterComponent`,只在渲染结果上换金额符号、改 CH 小数位、加 context 占用条、加档位标签与 tok/s;pwd 行改为“`⎇ 分支` 底色 chip + git 变更行数”,右侧模型上 chip、thinking 上色(分隔符沿用内置)。占用条抄 `pi-cc-extensions` 的 `barGauge`,分支图标与分隔符抄 look pack,git 变更行数(含 10s 轮询与分支变化重查)抄 cc。pi 升级 footer(新字段、新布局)自动跟随,不再覆盖。拿不到内置组件或布局变化时降级为 pi 自带 footer 并提醒一次。
147
+ - **2.2.0** footer 新增 tok/s 速率(思考/正文分相位):流式中显示当前相位实时值 `⚡45.2t/s`(无前缀,2s 滚动窗口),assistant `message_end` 用真实 `usage` 冻结为固定三档 `⚡(175.1/r31.8/o613.4)t/s`(总/思考/正文,无值打 `-` 占位);分母从各相位首 delta 起算,不含首 token 延迟与工具执行时长(定稿用 message_end 而非 turn_end)。实时值为估算,思考/正文各自的折算比由真实 token EMA 自校准(初始 4,限幅 0.5~8);异常速率(>1000 t/s)丢弃;无效 `usage`(失败/中止)不覆盖定稿值。/cost-tier 新增两档折算比。
113
148
  - **2.1.2** 修复"档位标签正确但计费金额按错档结算":注册守卫改为按实际计费 cost 判定漂移(不再只看档位字符串),注册失败不再写入守卫(下一轮自动重试);漂移 3 次未收敛转为 footer 可见告警(`≈` + 标签 `?`);新增 `/cost-tier` 诊断命令与注册失败一次性提示。方案见 `docs/fix-plan-stale-cost-tier.md`。
@@ -35,12 +35,15 @@
35
35
  // (不再限定 deepseek 官方,其余 provider 同样适用)。
36
36
  // - 自定义 footer 与 CH 精度:所有 provider 适用;未配置模型仅修改 CH,其余保持内置默认。
37
37
  //
38
- // 计费显示:用 ctx.ui.setFooter 替换内置 footer(升级安全,扩展代码不随 pi 更新)。
39
- // 复刻内置 footer 渲染(footer.js),并加两处增强:
38
+ // 计费显示:复用 pi 自己的 footer 组件(ctx.ui.setFooter 包一层),只在渲染结果上打补丁:
40
39
  // 1. 激活模型有动态计价配置时,价格后紧跟档位标签(labels 按档位 id 查,未配置则不显示标签):
41
40
  // 如 ¥0.047(梁文峰)。color 支持主题色名或 #rrggbb。
42
41
  // 2. CH 缓存命中率小数位由 cacheHitRatePrecision 控制(内置硬编码 1 位),所有 provider 适用。
43
42
  // 3. 计费金额符号由各 provider 的 currencySymbol 控制(内置硬编码 $)。
43
+ // 4. tok/s 速率段(本插件新增)。
44
+ // 布局、对齐、截断、pi 新增字段全部沿用内置渲染:pi 升级 footer 自动跟随,不会用旧版布局覆盖。
45
+ // 与其他扩展抢 footer:pi 没有“叠加”扩展点,两家都调 setFooter 时后调用的直接覆盖前一个;
46
+ // 本插件总是接管 footer(通用插件,不针对某个扩展写特例)。
44
47
  //
45
48
  // 注意:
46
49
  // - 旧版顶层计价字段与 provider 级 peakWindows 首次读取时自动迁移并写回配置文件。
@@ -48,15 +51,17 @@
48
51
  // - provider 必须在 models.json 中有定义:扩展读取其完整模型定义,仅替换 cost 后整体重注册
49
52
  // (registerProvider 的 models 是整体替换语义)。未列出的模型保持原价。
50
53
  // - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
51
- // - ponytail: 自定义 footer 是内置 modes/interactive/components/footer.js 的复刻,
52
- // 全部数据来自公开 API(ctx.sessionManager / ctx.getContextUsage / ctx.model /
53
- // footerData),内置的 xp 实验标记与 auto-compact 开关状态未复刻。
54
- // pi 调整 footer 布局需对照同步;上限 = 内置无"统计段追加"扩展点,
55
- // 升级路径 = 给内置 footer 增加 stats 追加 hook。
56
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
57
- import { join, relative, resolve, sep, isAbsolute } from "node:path";
54
+ // - ponytail: footer 用“加载 pi 自己的 FooterComponent + 打补丁”,不再整段复刻布局。
55
+ // 依赖 3 个内部约定:<pi>/dist/modes/interactive/components/footer.js 路径、
56
+ // FooterComponent(session, footerData) 构造、stats = dim(统计段) + dim(补白 + 右侧)。
57
+ // 任一不成立 → 补丁不生效(一次性提醒),footer 仍由 pi 自己渲染;主题经 globalThis Proxy 共享,颜色一致。
58
+ // 上限 = 内置无“统计段追加”扩展点;升级路径 = 给内置 footer 增加 stats 追加 hook。
59
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync } from "node:fs";
60
+ import { execFile } from "node:child_process";
61
+ import { join, dirname } from "node:path";
58
62
  import { homedir } from "node:os";
59
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
63
+ import { pathToFileURL } from "node:url";
64
+ import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
60
65
  import type {
61
66
  ExtensionAPI,
62
67
  ExtensionContext,
@@ -194,6 +199,201 @@ let footerLabel: FooterLabel | null = null;
194
199
  /** ponytail: 漂移重试预算固定 3 次,不做指数退避;真出现抖动再把预算做成配置项 */
195
200
  const DRIFT_RETRY_LIMIT = 3;
196
201
 
202
+ /* ---------- tok/s 实时速率(思考 / 正文分相位) ---------- */
203
+
204
+ /** ponytail: 窗口与折算上限固定;窗口 2s 是 GUI 可读性的取舍,不做配置项 */
205
+ const SPEED_WINDOW_MS = 2000;
206
+ /** 初始“字符/token”比:与 pi 自身(pi-ai estimate)的 4 一致,随后用真实 token 校准 */
207
+ const SPEED_CHARS_PER_TOKEN_DEFAULT = 4;
208
+ /** 校准比限幅:0.5(CJK 密集)~ 8(思考/结构化短词) */
209
+ const SPEED_RATIO_MIN = 0.5;
210
+ const SPEED_RATIO_MAX = 8;
211
+ /** 校准 EMA 系数:每条消息往实测值靠 30%,数条内收敛 */
212
+ const SPEED_CALIBRATION_ALPHA = 0.3;
213
+ /** 超过该速率视为异常样本(t/s),丢弃并沿用上次值 */
214
+ const SPEED_MAX = 1000;
215
+
216
+ /** 相位:r = 思考(reasoning),o = 正文(output − reasoning,含工具调用参数) */
217
+ type SpeedPhaseKind = "r" | "o";
218
+
219
+ /** 单相位的 2s 滚动窗口累计 */
220
+ interface SpeedPhase {
221
+ /** 本相位首个 delta 时刻;null = 本相位无产出 */
222
+ firstMs: number | null;
223
+ /** 本相位最近一个 delta 时刻(相位时长 = last − first) */
224
+ lastMs: number;
225
+ chars: number;
226
+ /** 已计入窗口的估算 token(单调,与窗口裁剪解耦,避免裁剪后重复计数) */
227
+ tokens: number;
228
+ samples: { t: number; n: number }[];
229
+ /** 本相位最近一次实时速率 */
230
+ rate: number | null;
231
+ }
232
+
233
+ /** 定稿三档速率;null = 该档无法计算 */
234
+ interface SpeedFinal {
235
+ /** 总吞吐 = output / (首 delta → message_end) */
236
+ total: number | null;
237
+ /** 思考 = reasoning / 思考段时长 */
238
+ r: number | null;
239
+ /** 正文 = (output − reasoning) / 正文段时长 */
240
+ o: number | null;
241
+ }
242
+
243
+ function newPhase(): SpeedPhase {
244
+ return { firstMs: null, lastMs: 0, chars: 0, tokens: 0, samples: [], rate: null };
245
+ }
246
+
247
+ /** 各相位“字符/token”校准比:跨消息保留 */
248
+ const speedRatio: Record<SpeedPhaseKind, number> = {
249
+ r: SPEED_CHARS_PER_TOKEN_DEFAULT,
250
+ o: SPEED_CHARS_PER_TOKEN_DEFAULT,
251
+ };
252
+
253
+ /**
254
+ * 速率状态:每个 delta 按相位进各自的滚动窗口 → assistant message_end 用真实 usage
255
+ * (output / reasoning)定稿三档并校准折算比。流式中显示当前相位实时值,空闲显示定稿三档。
256
+ *
257
+ * 为何实时值只能估算:没有任何 provider 在流中回传逐块累计 output token(Anthropic 的
258
+ * message_delta / Bedrock 的 metadata / OpenAI 的 include_usage 都只在末尾给一次),
259
+ * 只能由文本长度推;真值用于定稿与校准。
260
+ *
261
+ * 为何用 message_end 而非 turn_end 定稿:agent-loop 顺序是
262
+ * turn_start → 流式 message_* → 执行工具 → turn_end,用 turn_end 会把工具执行时长算进分母
263
+ * (流 200 token 后跑 60s 构建 → 报 3 t/s)。message_end 就在流式结束、工具开始前。
264
+ *
265
+ * 为何分相位:两段速度差一个数量级(实测 deepseek-flash:思考 ~32 t/s、正文 ~613 t/s),
266
+ * 混在一起的平均既不代表“模型在跑”也不代表“字出来的速度”。
267
+ */
268
+ const speed = {
269
+ think: newPhase(),
270
+ text: newPhase(),
271
+ /** 当前正在产出的相位;null = 空闲(显示定稿值) */
272
+ active: null as SpeedPhaseKind | null,
273
+ /** 本条消息首 delta 时刻(总口径分母起点) */
274
+ firstMs: null as number | null,
275
+ /** 最近一条消息的定稿三档 */
276
+ final: null as SpeedFinal | null,
277
+ };
278
+
279
+ /** 速率合理区间(t/s);异常样本返回 null */
280
+ function reasonableRate(rate: number): number | null {
281
+ return Number.isFinite(rate) && rate > 0 && rate <= SPEED_MAX ? rate : null;
282
+ }
283
+
284
+ /** 清空单条消息的累计,保留定稿值与校准比(assistant 消息开始时调用) */
285
+ export function resetSpeedTurn(): void {
286
+ speed.think = newPhase();
287
+ speed.text = newPhase();
288
+ speed.active = null;
289
+ speed.firstMs = null;
290
+ }
291
+
292
+ /** 会话开始/切换:全部清空(含定稿值与校准比,避免沿用上个会话的数字) */
293
+ export function resetSpeedSession(): void {
294
+ speed.final = null;
295
+ speedRatio.r = SPEED_CHARS_PER_TOKEN_DEFAULT;
296
+ speedRatio.o = SPEED_CHARS_PER_TOKEN_DEFAULT;
297
+ resetSpeedTurn();
298
+ }
299
+
300
+ /** 相位实时速率:窗口内 token / 实际耗时(窗口不满时按相位首 delta 起算) */
301
+ function phaseLiveRate(p: SpeedPhase, nowMs: number): number | null {
302
+ if (p.firstMs === null) return null;
303
+ const cutoff = nowMs - SPEED_WINDOW_MS;
304
+ p.samples = p.samples.filter((s) => s.t >= cutoff);
305
+ const elapsedSec = (nowMs - Math.max(cutoff, p.firstMs)) / 1000;
306
+ if (elapsedSec <= 0) return null;
307
+ const tokens = p.samples.reduce((sum, s) => sum + s.n, 0);
308
+ const measured = reasonableRate(tokens / elapsedSec);
309
+ if (measured !== null) p.rate = measured;
310
+ return p.rate;
311
+ }
312
+
313
+ /**
314
+ * 消费一片流式文本(按相位累计),返回该相位的实时速率
315
+ * @param kind - "r" 思考 / "o" 正文(含工具调用参数)
316
+ */
317
+ export function pushSpeedDelta(kind: SpeedPhaseKind, delta: string, nowMs: number): number | null {
318
+ const p = kind === "r" ? speed.think : speed.text;
319
+ if (delta.length > 0) {
320
+ speed.active = kind;
321
+ if (speed.firstMs === null) speed.firstMs = nowMs;
322
+ if (p.firstMs === null) p.firstMs = nowMs;
323
+ p.lastMs = nowMs;
324
+ p.chars += delta.length;
325
+ // 字符按折算比换算 token,只把新增量入窗口(累计量单调,裁剪后不会重复计数)
326
+ const target = Math.round(p.chars / speedRatio[kind]);
327
+ if (target > p.tokens) {
328
+ p.samples.push({ t: nowMs, n: target - p.tokens });
329
+ p.tokens = target;
330
+ }
331
+ }
332
+ return phaseLiveRate(p, nowMs);
333
+ }
334
+
335
+ /** 用真实 token 校准某相位的“字符/token”比;样本无效时保持原值 */
336
+ function calibrate(kind: SpeedPhaseKind, chars: number, realTokens: number): void {
337
+ if (realTokens <= 0 || chars <= 0) return;
338
+ const measured = Math.min(SPEED_RATIO_MAX, Math.max(SPEED_RATIO_MIN, chars / realTokens));
339
+ speedRatio[kind] =
340
+ speedRatio[kind] * (1 - SPEED_CALIBRATION_ALPHA) + measured * SPEED_CALIBRATION_ALPHA;
341
+ }
342
+
343
+ /** 相位时长(首 → 末 delta,秒);样本不足返回 null */
344
+ function phaseSpanSec(p: SpeedPhase): number | null {
345
+ if (p.firstMs === null || p.lastMs <= p.firstMs) return null;
346
+ return (p.lastMs - p.firstMs) / 1000;
347
+ }
348
+
349
+ /**
350
+ * 消息结束(流式刚结束、工具还没开始):用真实 usage 定稿三档速率并校准折算比。
351
+ * 分母 = 相位首 delta → 相位末 delta(总口径为首 delta → message_end),
352
+ * 不含首 token 延迟与工具执行时长。
353
+ * @param output - usage.output(含思考)
354
+ * @param reasoning - usage.reasoning(思考 token,output 的子集;provider 不报时为 0)
355
+ */
356
+ export function finishSpeedTurn(output: number, nowMs: number, reasoning = 0): SpeedFinal | null {
357
+ const { think, text, firstMs } = speed;
358
+ const reasoningTokens = Math.max(0, Math.min(reasoning, output));
359
+ const textTokens = output - reasoningTokens;
360
+ calibrate("r", think.chars, reasoningTokens);
361
+ calibrate("o", text.chars, textTokens);
362
+ const totalSec = firstMs === null ? null : (nowMs - firstMs) / 1000;
363
+ const thinkSec = phaseSpanSec(think);
364
+ const textSec = phaseSpanSec(text);
365
+ const final: SpeedFinal = {
366
+ total: output > 0 && totalSec !== null && totalSec > 0 ? reasonableRate(output / totalSec) : null,
367
+ r: reasoningTokens > 0 && thinkSec !== null ? reasonableRate(reasoningTokens / thinkSec) : null,
368
+ o: textTokens > 0 && textSec !== null ? reasonableRate(textTokens / textSec) : null,
369
+ };
370
+ resetSpeedTurn();
371
+ // 有真值才覆盖:失败/中止的消息 usage 可能全 0
372
+ if (final.total !== null || final.r !== null || final.o !== null) speed.final = final;
373
+ return speed.final;
374
+ }
375
+
376
+ /**
377
+ * footer 的 tok/s 片段:流式中 `⚡12.3t/s`(当前相位实时值,不加前缀);空闲固定三档
378
+ * `⚡(175.1/r31.8/o613.4)t/s`(总/思考/正文,总档靠位置识别,r/o 带标识),无值打 `-` 占位
379
+ */
380
+ export function formatSpeedPart(): string {
381
+ if (speed.active !== null) {
382
+ const p = speed.active === "r" ? speed.think : speed.text;
383
+ if (p.rate !== null) return `⚡${p.rate.toFixed(1)}t/s`;
384
+ }
385
+ const f = speed.final;
386
+ if (!f) return "";
387
+ const cell = (tag: string, v: number | null) => `${tag}${v === null ? "-" : v.toFixed(1)}`;
388
+ const total = f.total === null ? "-" : f.total.toFixed(1);
389
+ return `⚡(${total}/${cell("r", f.r)}/${cell("o", f.o)})t/s`;
390
+ }
391
+
392
+ /** 当前折算比,供 /cost-tier 诊断 */
393
+ export function speedRatios(): { r: number; o: number } {
394
+ return { r: speedRatio.r, o: speedRatio.o };
395
+ }
396
+
197
397
  /** 一次性提示:TUI 下扩展的 console.error 不落盘也不可见,错误必须走 ui.notify */
198
398
  function notifyOnce(ctx: ExtensionContext, text: string): void {
199
399
  if (text === lastErrorText) return;
@@ -423,26 +623,172 @@ function patchProviderConfig(
423
623
  return { ...provider, models };
424
624
  }
425
625
 
426
- // ---------- 自定义 footer:复刻内置 footer.js + 档位标签 + CH 精度 ----------
626
+ /* ---------- 复用内置 footer(增量补丁,不整段复刻) ---------- */
627
+
628
+ /**
629
+ * pi 的内置 footer 组件(dist/modes/interactive/components/footer.js)。
630
+ * 不再复刻它的布局:直接加载 pi 自己的实现,只在渲染结果上打补丁
631
+ * (金额符号 / CH 精度 / 档位标签 / tok/s)。pi 改 footer 布局时自动跟随。
632
+ * 主题在 globalThis 上以 Proxy 共享,拿到的就是当前主题,颜色一致。
633
+ */
634
+ interface BaseFooterModule {
635
+ FooterComponent: new (
636
+ session: unknown,
637
+ footerData: unknown,
638
+ ) => { render(width: number): string[] };
639
+ }
640
+
641
+ /** 加载结果:null = 未加载成功(footer 保持 pi 内置) */
642
+ let baseFooter: BaseFooterModule | null = null;
643
+ /** 只尝试加载一次 */
644
+ let baseFooterLoaded = false;
645
+ /** 未加载成功的原因,供 /cost-tier 与一次性提醒 */
646
+ let baseFooterError: string | null = null;
647
+ /** 补丁失效(内置布局不认识)已提醒过 */
648
+ let footerPatchWarned = false;
649
+ /** 内置组件渲染失败已处理过 */
650
+ let footerDisabled = false;
651
+
652
+ /**
653
+ * 定位已安装 pi 的内置 footer 源码。pi 的 bin 指向 <pkg>/dist/bundle/cli.js,
654
+ * 由此向上最多 6 层找 dist/modes/interactive/components/footer.js。
655
+ * 找不到(单文件二进制、源码运行)→ null。
656
+ */
657
+ function upstreamFooterFile(): string | null {
658
+ const entry = process.argv[1];
659
+ if (!entry) return null;
660
+ try {
661
+ let dir = dirname(realpathSync(entry));
662
+ for (let i = 0; i < 6; i++) {
663
+ const candidate = join(dir, "dist", "modes", "interactive", "components", "footer.js");
664
+ if (existsSync(candidate)) return candidate;
665
+ dir = dirname(dir);
666
+ }
667
+ } catch {
668
+ // argv[1] 不可解析 → 不定位
669
+ }
670
+ return null;
671
+ }
672
+
673
+ /** 加载 pi 内置 footer 组件;失败不影响计价,只退化为 pi 自带 footer */
674
+ async function loadBaseFooter(): Promise<BaseFooterModule | null> {
675
+ if (baseFooterLoaded) return baseFooter;
676
+ baseFooterLoaded = true;
677
+ const file = upstreamFooterFile();
678
+ if (!file) {
679
+ baseFooterError = "未定位到已安装 pi 的 footer.js(单文件二进制或源码运行)";
680
+ return null;
681
+ }
682
+ try {
683
+ const mod = (await import(pathToFileURL(file).href)) as unknown as BaseFooterModule;
684
+ if (typeof mod?.FooterComponent === "function") baseFooter = mod;
685
+ else baseFooterError = `${file} 未导出 FooterComponent`;
686
+ } catch (err) {
687
+ baseFooterError = `加载 ${file} 失败:${err instanceof Error ? err.message : String(err)}`;
688
+ }
689
+ return baseFooter;
690
+ }
427
691
 
428
- function formatTokens(count: number): string {
429
- if (count < 1000) return count.toString();
430
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
431
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
432
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
433
- return `${Math.round(count / 1000000)}M`;
692
+ function baseFooterStatusText(): string {
693
+ if (baseFooter) return "复用内置 footer;补丁:context 占用条 / 金额符号 / CH 精度 / 档位标签 / tok/s";
694
+ return `未复用内置 footer(${baseFooterError ?? "未尝试加载"})`;
434
695
  }
435
696
 
436
- function formatCwdForFooter(cwd: string, home: string | undefined): string {
437
- if (!home) return cwd;
438
- const rel = relative(resolve(home), resolve(cwd));
439
- const inside =
440
- rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
441
- return inside ? (rel === "" ? "~" : `~${sep}${rel}`) : cwd;
697
+ /**
698
+ * 内置 FooterComponent 需要的 session 视图,数据全部来自扩展 ctx。
699
+ * modelRuntime 未对扩展暴露 → 订阅制保留内置的 kimi-coding 特例。
700
+ */
701
+ function footerSessionView(ctx: ExtensionContext) {
702
+ const c = () => activeCtx ?? ctx;
703
+ return {
704
+ get state() {
705
+ return { model: c().model, thinkingLevel: c().thinkingLevel };
706
+ },
707
+ sessionManager: {
708
+ getEntries: () => c().sessionManager.getEntries(),
709
+ getCwd: () => c().sessionManager.getCwd(),
710
+ getSessionName: () => c().sessionManager.getSessionName(),
711
+ },
712
+ getContextUsage: () => c().getContextUsage(),
713
+ modelRuntime: { isUsingSubscription: (providerId: string) => providerId === "kimi-coding" },
714
+ };
442
715
  }
443
716
 
444
- function sanitizeStatusText(text: string): string {
445
- return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
717
+ // ---------- footer 补丁:占用条 / 金额符号 / CH 精度 / 档位标签 / tok/s / pwd 行 ----------
718
+
719
+ /** 去掉 ANSI 转义,只留可见文本 */
720
+ function stripAnsi(text: string): string {
721
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
722
+ }
723
+
724
+ /** 分支图标(抄 look pack 的 `⎇`,不依赖 Nerd Font) */
725
+ const BRANCH_ICON = "⎇";
726
+ /** 段分隔符(抄 look pack) */
727
+ const SEG_SEP = " │ ";
728
+ /** git 变更行数刷新间隔(抄 pi-cc-extensions) */
729
+ const GIT_REFRESH_INTERVAL_MS = 10_000;
730
+
731
+ /**
732
+ * 解析 `git diff --numstat HEAD` 输出:每行前两列是增/删行数,二进制行为 `-\t-` → 跳过。
733
+ * 抄 pi-cc-extensions 的 parseGitStats。
734
+ */
735
+ export function parseGitStats(stdout: string): { add: number; del: number } {
736
+ let add = 0;
737
+ let del = 0;
738
+ for (const line of stdout.split("\n")) {
739
+ const match = /^(\d+)\s+(\d+)/.exec(line);
740
+ if (!match) continue;
741
+ add += Number(match[1]);
742
+ del += Number(match[2]);
743
+ }
744
+ return { add, del };
745
+ }
746
+
747
+ /** 工作区变更行数;undefined = 未查到(非 git 仓库 / 查询失败) */
748
+ let gitStats: { add: number; del: number } | undefined;
749
+ let gitRefreshRunning = false;
750
+
751
+ /** 查一次 `git diff --numstat HEAD`;结果变化时回调(用于重绘) */
752
+ function refreshGitStats(cwd: string, onChanged: () => void): void {
753
+ if (gitRefreshRunning) return;
754
+ gitRefreshRunning = true;
755
+ execFile("git", ["diff", "--numstat", "HEAD"], { cwd, timeout: 2000 }, (err, stdout) => {
756
+ gitRefreshRunning = false;
757
+ if (err) return;
758
+ const next = parseGitStats(stdout);
759
+ if (gitStats?.add === next.add && gitStats.del === next.del) return;
760
+ gitStats = next;
761
+ onChanged();
762
+ });
763
+ }
764
+
765
+ /** 分支标签:`⎇ branch` 做成 accent 底色 chip,后跟 git 变更行数(success/error) */
766
+ function branchLabel(branch: string, stats: { add: number; del: number } | null | undefined, theme: Theme): string {
767
+ // 带尾空格的 chip 用于后接 ± 数字;无数字时不带尾空格,避免行末多一个空格
768
+ const chip = (text: string) => theme.bg("selectedBg", theme.fg("accent", text));
769
+ const icon = `${BRANCH_ICON} ${branch}`;
770
+ if (!stats) return chip(icon);
771
+ return `${chip(`${icon} `)}${theme.fg("success", `+${stats.add}`)} ${theme.fg("error", `−${stats.del}`)}`;
772
+ }
773
+
774
+ /** 内置金额格式 `$0.047` / `$0.047 (sub)`;只改符号,不改数值 */
775
+ const COST_RE = /\$(\d+\.\d+)( \(sub\))?/;
776
+
777
+ /** context 占用条宽度(格) */
778
+ const BAR_WIDTH = 10;
779
+ /** ≥ 80% 转 warning 色,≥ 95% 转 error 色(同 pi-cc-extensions) */
780
+ const BAR_WARNING = 80;
781
+ const BAR_ERROR = 95;
782
+
783
+ /**
784
+ * context 占用条。外观抄 pi-cc-extensions 的 barGauge(其注释:pi-cc-status 风格):
785
+ * accent 填充 + dim 空槽,高占用转 warning/error 色。
786
+ */
787
+ export function barGauge(theme: Theme, percent: number): string {
788
+ const clamped = Math.max(0, Math.min(100, percent));
789
+ const filled = Math.round((clamped / 100) * BAR_WIDTH);
790
+ const color = clamped >= BAR_ERROR ? "error" : clamped >= BAR_WARNING ? "warning" : "accent";
791
+ return theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(BAR_WIDTH - filled));
446
792
  }
447
793
 
448
794
  /** 标签着色:ThemeColor 名走主题,#rrggbb 走 truecolor 直出(SGR 38;2;r;g;b) */
@@ -455,19 +801,182 @@ export function colorize(theme: Theme, color: string, text: string): string {
455
801
  return theme.fg(color as ThemeColor, text);
456
802
  }
457
803
 
458
- /** 整段包 dim,但跳过 marker(档位标签)保持原色;未找到 marker 时整段 dim */
459
- function dimSkip(text: string, marker: string, theme: Theme): string {
460
- if (!marker) return theme.fg("dim", text);
461
- const idx = text.indexOf(marker);
462
- if (idx < 0) return theme.fg("dim", text);
804
+ /** 漂移时标签加问号:宁可显示“可疑”,不静默显示错数字 */
805
+ function driftLabelText(text: string): string {
806
+ return text.endsWith(")") ? `${text.slice(0, -1)}?)` : `${text}?`;
807
+ }
808
+
809
+ /** 补丁内容:金额符号 / CH 精度来自配置,档位标签 / tok/s 是本插件新增 */
810
+ interface FooterPatchOptions {
811
+ /** 配置了 currencySymbol 才替换内置的 `$` */
812
+ symbol?: string;
813
+ /** 配置了 cacheHitRatePrecision 才改 CH 小数位(内置硬编码 1 位) */
814
+ precision?: number;
815
+ /** 计费与档位不符 → 金额前加 `≈` */
816
+ approx?: boolean;
817
+ /** tok/s 片段 */
818
+ speed?: string;
819
+ label?: FooterLabel;
820
+ /** context 占用条:true 时把 `10.5%/1.0M` 换成 `████░░░░░░ 10.5%/1.0M` */
821
+ contextBar?: boolean;
822
+ /** pwd 行:分支图标 + 变更行数;null = 无 git 数据 */
823
+ branchStats?: { add: number; del: number } | null;
824
+ /** 右侧:模型 id 上底色 chip,thinking 级别上色(抄 look pack / cc) */
825
+ modelId?: string;
826
+ thinkingLevel?: string;
827
+ }
828
+
829
+ /**
830
+ * 把内置 stats 行切成 `stats + dim(补白 + 右侧模型名)` 三段。
831
+ * 内置结构不认识(行里没有两段 dim)→ null。
832
+ */
833
+ function splitStatsLine(
834
+ line: string,
835
+ theme: Theme,
836
+ ): { stats: string; padding: string; right: string; dimOpen: string } | null {
837
+ const dimOpen = theme.getFgAnsi("dim");
838
+ const cut = line.lastIndexOf(dimOpen);
839
+ if (cut <= 0 || !line.slice(0, cut).includes(dimOpen)) return null;
840
+ const tail = line.slice(cut + dimOpen.length);
841
+ const padding = /^ */.exec(tail)?.[0] ?? "";
842
+ return { stats: line.slice(0, cut), padding, right: tail.slice(padding.length), dimOpen };
843
+ }
844
+
845
+ /**
846
+ * 增量补丁:只改统计段内的 context 占用条、金额符号、CH 精度,
847
+ * 并按剩余补白追加速率与档位标签;行宽、对齐、截断、pi 新增的字段全部沿用内置结果。
848
+ * 空间不够时按 速率 → 标签 → 占用条 的顺序舍弃;结构不认识 → 返回原行(补丁可丢,布局不坏)。
849
+ */
850
+ function patchFooterStatsLine(
851
+ line: string,
852
+ theme: Theme,
853
+ opts: FooterPatchOptions,
854
+ ): { line: string; patched: boolean } {
855
+ const parts = splitStatsLine(line, theme);
856
+ if (!parts) return { line, patched: false };
857
+ const { stats: original, padding, right, dimOpen } = parts;
858
+ let stats = original;
859
+ // CH 精度先改:它在金额前,改完再定位金额,下标才准
860
+ if (opts.precision !== undefined) {
861
+ stats = stats.replace(
862
+ /CH(\d+(?:\.\d+)?)%/,
863
+ (_m, v: string) => `CH${Number(v).toFixed(opts.precision)}%`,
864
+ );
865
+ }
866
+ // 金额:`$数字` 换成配置的符号,可加 ≈;前后插入点一并算好
867
+ const cost = COST_RE.exec(stats);
868
+ let costHead = -1;
869
+ let costTail = -1;
870
+ if (cost) {
871
+ const prefix = `${opts.approx ? "≈" : ""}${opts.symbol ?? "$"}`;
872
+ const numStart = cost.index + 1; // `$` 之后
873
+ stats = stats.slice(0, cost.index) + prefix + stats.slice(numStart);
874
+ costHead = cost.index;
875
+ costTail = cost.index + prefix.length + cost[1].length + (cost[2]?.length ?? 0);
876
+ }
877
+ const speed = opts.speed ? `${opts.speed} ` : "";
878
+ const label = opts.label
879
+ ? ` ${colorize(theme, opts.label.color, opts.label.text)}${dimOpen}`
880
+ : "";
881
+ // context 占用条:插在百分比前(在金额之后,不影响 costHead/costTail 下标);`?` 时没有百分比,跳过
882
+ const pct = opts.contextBar ? /(\d+(?:\.\d+)?)%\//.exec(stats) : null;
883
+ const bar = pct ? `${barGauge(theme, Number(pct[1]))} ` : "";
884
+ const barAt = pct ? pct.index : -1;
885
+ const build = (withBar: boolean, withSpeed: boolean, withLabel: boolean): string => {
886
+ let out = stats;
887
+ // 占用条先插:它落在金额之后,再按原下标插速率/标签才不错位
888
+ if (withBar && barAt >= 0) out = out.slice(0, barAt) + bar + out.slice(barAt);
889
+ const s = withSpeed ? speed : "";
890
+ const l = withLabel ? label : "";
891
+ if (costTail < 0) {
892
+ // 无计费段(cost 为 0 且非订阅)→ 追到统计段末;tok/s 与统计段同色,标签自带颜色
893
+ const extras = [withSpeed ? theme.fg("dim", speed.trim()) : "", l.trim()].filter(Boolean);
894
+ return extras.length ? `${out} ${extras.join(" ")}` : out;
895
+ }
896
+ return out.slice(0, costHead) + s + out.slice(costHead, costTail) + l + out.slice(costTail);
897
+ };
898
+ const base = visibleWidth(original);
899
+ // 右段:模型(连同内置的 `(provider) ` 前缀)上底色 chip、thinking 级别上色;chip 多占 2 格从补白扣
900
+ const prefix = /^\(([^)]*)\) /.exec(stripAnsi(right));
901
+ const chipTarget = opts.modelId
902
+ ? prefix
903
+ ? `${prefix[0]}${opts.modelId}`
904
+ : opts.modelId
905
+ : "";
906
+ const chip = chipTarget ? theme.bg("selectedBg", theme.fg("muted", ` ${chipTarget}`)) : "";
907
+ let patchedRight = right;
908
+ if (chipTarget && patchedRight.includes(chipTarget)) patchedRight = patchedRight.replace(chipTarget, chip);
909
+ if (opts.thinkingLevel) {
910
+ const levelText = opts.thinkingLevel === "off" ? "thinking off" : opts.thinkingLevel;
911
+ // 分隔符沿用内置的 ` • `,只把级别上色(宽度不变)
912
+ patchedRight = patchedRight.replace(
913
+ ` • ${levelText}`,
914
+ ` • ${theme.getThinkingBorderColor(opts.thinkingLevel)(levelText)}`,
915
+ );
916
+ }
917
+ const rightDelta = patchedRight === right ? 0 : visibleWidth(stripAnsi(patchedRight)) - visibleWidth(stripAnsi(right));
918
+ // 优先顺序:占用条(本插件新增的显示)> 标签 > 速率;补白用于吸收宽度差
919
+ for (const [withBar, withLabel, withSpeed] of [
920
+ [true, true, true],
921
+ [true, true, false],
922
+ [true, false, false],
923
+ [false, false, false],
924
+ ] as const) {
925
+ const patchedStats = build(withBar, withLabel, withSpeed);
926
+ const delta = visibleWidth(patchedStats) - base;
927
+ const shrink = Math.max(0, delta) + Math.max(0, rightDelta);
928
+ if (shrink > padding.length) continue;
929
+ // 宽度差为负(改短了)时补回空格,保持行宽不变
930
+ const pad =
931
+ padding.slice(shrink) + " ".repeat(Math.max(0, -delta) + Math.max(0, -rightDelta));
932
+ return { line: patchedStats + dimOpen + pad + patchedRight, patched: true };
933
+ }
934
+ return { line, patched: false };
935
+ }
936
+
937
+ /**
938
+ * pwd 行补丁(样式抄 cc 与 look pack):分支换成 `⎇ branch` 底色 chip,后跟 git 变更行数。
939
+ * 路径沿用内置的完整显示;无分支时原样返回(调用方靠引用判断)。
940
+ */
941
+ export function patchFooterPwdLine(line: string, theme: Theme, opts: FooterPatchOptions): string {
942
+ const text = stripAnsi(line); // 内置 pwd 行只有 dim 一种颜色
943
+ const branch = /\(([^()]*)\)(?= •|$)/.exec(text);
944
+ if (!branch) return line;
945
+ const head = text.slice(0, branch.index).trim();
946
+ const tail = text.slice(branch.index + branch[0].length); // " • 会话名" 或空
463
947
  return (
464
- theme.fg("dim", text.slice(0, idx)) +
465
- "\x1b[22m" + // 重置亮度,标签不被 dim 压暗
466
- marker +
467
- theme.fg("dim", text.slice(idx + marker.length))
948
+ theme.fg("dim", head + SEG_SEP) +
949
+ branchLabel(branch[1], opts.branchStats, theme) +
950
+ (tail ? theme.fg("dim", tail) : "")
468
951
  );
469
952
  }
470
953
 
954
+ /**
955
+ * 在整组 footer 行里打补丁:stats 行(补丁主体)+ 第一行(pwd)。
956
+ * patched 只反映 stats 行,因为它是布局变化的探测器(pwd 行变了不算布局变)。
957
+ */
958
+ export function patchFooterLines(
959
+ lines: string[],
960
+ theme: Theme,
961
+ opts: FooterPatchOptions,
962
+ width = 0,
963
+ ): { lines: string[]; patched: boolean } {
964
+ const next = [...lines];
965
+ let statsPatched = false;
966
+ for (let i = 0; i < lines.length; i++) {
967
+ const result = patchFooterStatsLine(lines[i], theme, opts);
968
+ if (!result.patched) continue;
969
+ next[i] = result.line;
970
+ statsPatched = true;
971
+ break;
972
+ }
973
+ if (lines.length > 0) {
974
+ const pwd = patchFooterPwdLine(lines[0], theme, opts);
975
+ if (pwd !== lines[0]) next[0] = width > 0 ? truncateToWidth(pwd, width, theme.fg("dim", "...")) : pwd;
976
+ }
977
+ return { lines: next, patched: statsPatched };
978
+ }
979
+
471
980
  /** 本插件视角下的模型对象子集(pi 的 Model 结构兼容,cost 只看四段扁平价) */
472
981
  interface ModelLike {
473
982
  provider: string;
@@ -564,141 +1073,93 @@ function computeFooterLabel(st: PricingState): FooterLabel | null {
564
1073
 
565
1074
  function installCustomFooter(ctx: ExtensionContext): void {
566
1075
  if (ctx.mode !== "tui") return; // 非 TUI(rpc/json)无 footer 可替换
567
- ctx.ui.setFooter((_tui, theme, footerData) => ({
568
- invalidate() {},
569
- render(width: number) {
570
- const c = activeCtx;
571
- const sm = c?.sessionManager;
572
- // 累计用量:镜像内置 addUsageToTotals 的入口(assistant / toolResult / 摘要与压缩)
573
- const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
574
- let latestCacheHitRate: number | undefined;
575
- for (const entry of sm?.getEntries() ?? []) {
576
- let usage;
577
- if (entry.type === "message" && entry.message.role === "assistant") {
578
- usage = entry.message.usage;
579
- const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
580
- latestCacheHitRate =
581
- promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : undefined;
582
- } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
583
- usage = entry.message.usage;
584
- } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
585
- usage = entry.usage;
1076
+ const Base = baseFooter;
1077
+ if (!Base) {
1078
+ // 拿不到内置组件 → 不覆盖,让 pi 自己渲染 footer(档位标签等增强本会话不生效)
1079
+ ctx.ui.setFooter(undefined);
1080
+ notifyOnce(
1081
+ ctx,
1082
+ `[pi-better-cost-display-footer] footer 增强未启用:${baseFooterError ?? "内置 footer 未加载"}`,
1083
+ );
1084
+ return;
1085
+ }
1086
+ ctx.ui.setFooter((tui, theme, footerData) => {
1087
+ const base = new Base.FooterComponent(footerSessionView(ctx), footerData);
1088
+ // git 变更行数:启动查一次 + 10s 轮询 + 分支变化重查(抄 pi-cc-extensions)
1089
+ const refreshGit = () => refreshGitStats(ctx.cwd, () => tui.requestRender());
1090
+ const unsubBranch = footerData.onBranchChange(() => {
1091
+ refreshGit();
1092
+ tui.requestRender();
1093
+ });
1094
+ refreshGit();
1095
+ const gitTimer = setInterval(refreshGit, GIT_REFRESH_INTERVAL_MS);
1096
+ gitTimer.unref?.();
1097
+ return {
1098
+ invalidate() {},
1099
+ dispose() {
1100
+ clearInterval(gitTimer);
1101
+ unsubBranch?.();
1102
+ },
1103
+ render(width: number) {
1104
+ // 主题可能被运行时切换;ctx.ui.theme 是实时值,footer 工厂入参是装载时的快照
1105
+ const live = activeCtx?.ui.theme ?? theme;
1106
+ let lines: string[];
1107
+ try {
1108
+ lines = base.render(width);
1109
+ } catch (err) {
1110
+ disableFooter(ctx, err);
1111
+ return [];
586
1112
  }
587
- if (!usage) continue;
588
- totals.input += usage.input;
589
- totals.output += usage.output;
590
- totals.cacheRead += usage.cacheRead;
591
- totals.cacheWrite += usage.cacheWrite;
592
- totals.cost += usage.cost.total;
593
- }
594
- // 上下文占用(内置逻辑:压缩后 tokens 未知 "?")
595
- const contextUsage = c?.getContextUsage();
596
- const contextWindow = contextUsage?.contextWindow ?? c?.model?.contextWindow ?? 0;
597
- const contextPercentValue = contextUsage?.percent ?? 0;
598
- const contextPercent =
599
- contextUsage?.percent == null ? "?" : contextPercentValue.toFixed(1);
600
- const autoIndicator = " (auto)"; // ponytail: 内置 auto-compact 关闭时此处仍显示
601
- const contextPercentDisplay =
602
- contextPercent === "?"
603
- ? `?/${formatTokens(contextWindow)}${autoIndicator}`
604
- : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
605
- let contextPercentStr: string;
606
- if (contextPercentValue > 90) contextPercentStr = theme.fg("error", contextPercentDisplay);
607
- else if (contextPercentValue > 70) contextPercentStr = theme.fg("warning", contextPercentDisplay);
608
- else contextPercentStr = contextPercentDisplay;
609
- // pwd + git 分支 + 会话名
610
- let pwd = formatCwdForFooter(sm?.getCwd() ?? "", process.env.HOME || process.env.USERPROFILE);
611
- const branch = footerData.getGitBranch();
612
- if (branch) pwd = `${pwd} (${branch})`;
613
- const sessionName = sm?.getSessionName();
614
- if (sessionName) pwd = `${pwd} • ${sessionName}`;
615
- // 统计段
616
- const statsParts: string[] = [];
617
- if (totals.input) statsParts.push(`↑${formatTokens(totals.input)}`);
618
- if (totals.output) statsParts.push(`↓${formatTokens(totals.output)}`);
619
- if (totals.cacheRead) statsParts.push(`R${formatTokens(totals.cacheRead)}`);
620
- if (totals.cacheWrite) statsParts.push(`W${formatTokens(totals.cacheWrite)}`);
621
- if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
622
- const precision = currentCfg?.cacheHitRatePrecision ?? 1;
623
- statsParts.push(`CH${latestCacheHitRate.toFixed(precision)}%`);
624
- }
625
- // 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
626
- const usingSubscription = c?.model?.provider === "kimi-coding";
627
- // 未配置模型只改 CH;金额符号和档位标签保持内置默认
628
- const customPricing = getActiveModelPricing(currentCfg, c?.model);
629
- const labelInfo = customPricing ? footerLabel : null;
630
- // 漂移时标签转 warning 并加问号,金额前缀 ≈:宁可显示“可疑”,不静默显示错数字
631
- const drift = customPricing ? footerDrift : false;
632
- const labelText = labelInfo
633
- ? drift
634
- ? labelInfo.text.endsWith(")")
635
- ? `${labelInfo.text.slice(0, -1)}?)`
636
- : `${labelInfo.text}?`
637
- : labelInfo.text
638
- : "";
639
- const labelStr = labelInfo ? colorize(theme, drift ? "warning" : labelInfo.color, labelText) : "";
640
- if (totals.cost || usingSubscription) {
641
- const symbol = customPricing?.currencySymbol ?? "$";
642
- const costStr = `${drift ? "≈" : ""}${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
643
- statsParts.push(costStr + labelStr);
644
- }
645
- statsParts.push(contextPercentStr);
646
- // 兜底:无计费段时标签置于统计段末尾
647
- if (!(totals.cost || usingSubscription) && labelStr) statsParts.push(labelStr);
648
- let statsLeft = statsParts.join(" ");
649
- // 右侧:模型名 + thinking 档位 + 多 provider 前缀
650
- const modelName = c?.model?.id || "no-model";
651
- let statsLeftWidth = visibleWidth(statsLeft);
652
- if (statsLeftWidth > width) {
653
- statsLeft = truncateToWidth(statsLeft, width, "...");
654
- statsLeftWidth = visibleWidth(statsLeft);
655
- }
656
- const minPadding = 2;
657
- let rightSide: string = modelName;
658
- if (c?.model?.reasoning) {
659
- const thinkingLevel = c.thinkingLevel || "off";
660
- rightSide =
661
- thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
662
- }
663
- if (footerData.getAvailableProviderCount() > 1 && c?.model) {
664
- const withProvider = `(${c.model.provider}) ${rightSide}`;
665
- if (statsLeftWidth + minPadding + visibleWidth(withProvider) <= width) rightSide = withProvider;
666
- }
667
- const rightSideWidth = visibleWidth(rightSide);
668
- let statsLine: string;
669
- if (statsLeftWidth + minPadding + rightSideWidth <= width) {
670
- const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
671
- statsLine = statsLeft + padding + rightSide;
672
- } else {
673
- const availableForRight = width - statsLeftWidth - minPadding;
674
- if (availableForRight > 0) {
675
- const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
676
- const padding = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight)));
677
- statsLine = statsLeft + padding + truncatedRight;
678
- } else {
679
- statsLine = statsLeft;
1113
+ try {
1114
+ const result = patchFooterLines(lines, live, footerPatchOptions(), width);
1115
+ if (!result.patched && !footerPatchWarned) {
1116
+ // 内置布局变了(补丁打不上)→ 提醒一次,footer 本身仍由内置渲染
1117
+ footerPatchWarned = true;
1118
+ notifyOnce(
1119
+ ctx,
1120
+ "[pi-better-cost-display-footer] 内置 footer 布局已变,档位标签 / CH 精度等补丁未生效(其余显示正常)",
1121
+ );
1122
+ }
1123
+ return result.lines;
1124
+ } catch {
1125
+ return lines; // 补丁出错 用内置原样,绝不让 footer 变空
680
1126
  }
681
- }
682
- // 弱化:与内置一致,统计段与右侧分别包 dim;标签段用 \x1b[22m 退出 dim 保持原色
683
- // ponytail: 依赖 SGR 22 重置亮度,若主题实现变化需同步
684
- const dimStatsLeft = dimSkip(statsLeft, labelStr, theme);
685
- const remainder = statsLine.slice(statsLeft.length);
686
- const dimRemainder = theme.fg("dim", remainder);
687
- const statsLineFinal = dimStatsLeft + dimRemainder;
688
- const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
689
- const lines = [pwdLine, statsLineFinal];
690
- // 扩展状态行(MCP 等),按键名排序
691
- const extensionStatuses = footerData.getExtensionStatuses();
692
- if (extensionStatuses.size > 0) {
693
- const statusLine = Array.from(extensionStatuses.entries())
694
- .sort(([a], [b]) => a.localeCompare(b))
695
- .map(([, text]) => sanitizeStatusText(text))
696
- .join(" ");
697
- lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
698
- }
699
- return lines;
700
- },
701
- }));
1127
+ },
1128
+ };
1129
+ });
1130
+ }
1131
+
1132
+ /** 内置组件渲染抛错:一次性退化为 pi 自带 footer,不在每帧重试 */
1133
+ function disableFooter(ctx: ExtensionContext, err: unknown): void {
1134
+ if (footerDisabled) return;
1135
+ footerDisabled = true;
1136
+ notifyOnce(
1137
+ ctx,
1138
+ `[pi-better-cost-display-footer] 复用内置 footer 失败,改用 pi 自带 footer:${
1139
+ err instanceof Error ? err.message : String(err)
1140
+ }`,
1141
+ );
1142
+ setTimeout(() => ctx.ui.setFooter(undefined), 0);
1143
+ }
1144
+
1145
+ /** 当前帧的补丁内容(档位标签 / tok/s / 金额符号 / CH 精度) */
1146
+ function footerPatchOptions(): FooterPatchOptions {
1147
+ const model = activeCtx?.model;
1148
+ const customPricing = getActiveModelPricing(currentCfg, model);
1149
+ const drift = customPricing ? footerDrift : false;
1150
+ const info = customPricing ? footerLabel : null;
1151
+ const text = info ? (drift ? driftLabelText(info.text) : info.text) : undefined;
1152
+ return {
1153
+ symbol: customPricing?.currencySymbol,
1154
+ precision: currentCfg?.cacheHitRatePrecision,
1155
+ approx: drift,
1156
+ speed: formatSpeedPart(),
1157
+ label: info && text ? { text, color: drift ? "warning" : info.color } : undefined,
1158
+ contextBar: true,
1159
+ branchStats: gitStats ?? null, // 抄 cc:分支后跟 git 变更行数
1160
+ modelId: model?.id,
1161
+ thinkingLevel: activeCtx?.thinkingLevel,
1162
+ };
702
1163
  }
703
1164
 
704
1165
  function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
@@ -828,6 +1289,8 @@ export default function (pi: ExtensionAPI): void {
828
1289
  `tierFn: ${fnStatus}`,
829
1290
  `守卫: ${appliedKey ? "已置位(配置与档位未变则不重注册)" : "未置位(下一轮重注册)"}`,
830
1291
  `漂移重试: ${driftRetries}/${DRIFT_RETRY_LIMIT}`,
1292
+ `速率折算: 思考 ${speedRatios().r.toFixed(2)} / 正文 ${speedRatios().o.toFixed(2)} 字符/token(真实 token 校准,初始 4)`,
1293
+ `footer: ${baseFooterStatusText()}`,
831
1294
  `最近注册: ${lastRegisterResult}`,
832
1295
  ];
833
1296
  const text = `[pi-better-cost-display-footer]\n${lines.join("\n")}`;
@@ -835,10 +1298,33 @@ export default function (pi: ExtensionAPI): void {
835
1298
  else console.log(text);
836
1299
  },
837
1300
  });
838
- pi.on("session_start", (_e, ctx) => {
1301
+ pi.on("session_start", async (_e, ctx) => {
1302
+ resetSpeedSession();
1303
+ footerPatchWarned = false;
1304
+ footerDisabled = false;
1305
+ gitStats = undefined; // 会话可能换了仓库
1306
+ // 加载 pi 自己的 footer 组件:拿不到就不覆盖(用 pi 自带 footer),不当“旧版复刻”卡着新 footer
1307
+ await loadBaseFooter();
839
1308
  installCustomFooter(ctx);
840
1309
  apply(pi, ctx);
841
1310
  });
1311
+ // 速率事件:message_start 清累计(一次 LLM 调用内的估算不跨消息),
1312
+ // message_update 累积流式增量,assistant message_end(工具执行前)用实际 output token 定稿
1313
+ pi.on("message_start", (e) => {
1314
+ if (e.message.role === "assistant") resetSpeedTurn();
1315
+ });
1316
+ // 定稿用 message_end(流式刚结束、工具还没跑):用 turn_end 会把工具执行时长算进分母
1317
+ pi.on("message_end", (e) => {
1318
+ if (e.message.role !== "assistant") return;
1319
+ const u = e.message.usage;
1320
+ finishSpeedTurn(u?.output ?? 0, Date.now(), u?.reasoning ?? 0);
1321
+ });
1322
+ pi.on("message_update", (e) => {
1323
+ const ev = e.assistantMessageEvent;
1324
+ if (e.message.role !== "assistant" || !("delta" in ev)) return;
1325
+ // 思考与正文分开入窗口:两段速度差一个数量级,混合平均没参考价值
1326
+ pushSpeedDelta(ev.type === "thinking_delta" ? "r" : "o", ev.delta ?? "", Date.now());
1327
+ });
842
1328
  pi.on("session_shutdown", () => {
843
1329
  // Pi invalidates session-bound ctx before the next footer render.
844
1330
  activeCtx = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalieny/pi-better-cost-display-footer",
3
- "version": "2.1.2",
3
+ "version": "2.3.0",
4
4
  "description": "pi extension: scriptable peak/off-peak tier rules per provider with an enhanced footer (tier label, cache-hit-rate precision, custom currency symbol)",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,7 +9,7 @@
9
9
  "license": "MIT",
10
10
  "type": "module",
11
11
  "scripts": {
12
- "test": "node --test test/tier.test.ts"
12
+ "test": "node --test test/tier.test.ts test/speed.test.ts test/footer-patch.test.ts"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "@earendil-works/pi-coding-agent": "*",