token-stats-timer 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,149 @@
1
+ # token-stats-timer
2
+
3
+ > [简体中文](README.md) | English (current)
4
+
5
+ A modified version of `@liziy/token-stats`.
6
+
7
+ One extension, one footer — run timing plus token usage/quota monitoring in a single plugin.
8
+
9
+ ## Features
10
+
11
+ Footer, top line (left-aligned):
12
+
13
+ ```
14
+ ↑12k ↓3.4k CH87% ⚡77.7 t/s 5.3%/1.0M | 5h: 87% W: 92% ⏱ 2h 15m
15
+ ```
16
+
17
+ - `prev` / `max` —— duration of the previous run / longest run in this session branch (with a 15-char prompt preview)
18
+ - `↑ ↓ Σ CH` —— cumulative input / output / total / cache hit rate
19
+ - `⚡` —— live speed (2s rolling window; falls back to the average speed while idle)
20
+ - Context usage (style configurable)
21
+ - `5h: W: ⏱` —— quota remaining (built-in plans for MiniMax / GLM / Kimi / DeepSeek / OpenCode Go; enable per provider in `/stats`)
22
+
23
+ Footer, bottom line: cwd + git branch + statuses from other extensions.
24
+
25
+ ## Per-model thinking level memory (thinking-memory)
26
+
27
+ No manual setup command: when you **manually switch the thinking level, it is recorded automatically** as the default for the current model, and restored on `session_start` / model switch. Inspired by the per-model preference mechanism of `@tifan/pi-preferred-thinking`, but with your actual switching behavior as the memory source (enabled by default):
28
+
29
+ - Manually change the thinking level (keybinding / `/thinking` / settings UI) → recorded for the current model
30
+ - `session_start` / `model_select` → the remembered level for that model is applied automatically
31
+ - Level changes caused by the extension's own `setThinkingLevel` or by model-switch clamping (e.g. a model that doesn't support `max`) are never misrecorded
32
+
33
+ - `/auto-remember-thinking-level` — no arg shows status; `on` / `off` enable or disable (enabled by default)
34
+
35
+ Config: `~/.pi/agent/extensions/token-stats/auto-remember-thinking-level.json`
36
+
37
+ ```json
38
+ {
39
+ "enabled": true,
40
+ "levels": {
41
+ "opencode-go/deepseek-v4-flash": "max"
42
+ }
43
+ }
44
+ ```
45
+
46
+ > If the original `@tifan/pi-preferred-thinking` is also installed, its session_start auto-apply overrides this feature and vice versa — install only one of them.
47
+
48
+ ## macOS completion notifications
49
+
50
+ A system notification is posted when each run (agent task) finishes, distinguishing two outcomes:
51
+
52
+ - ✅ pi task finished — normal completion (with duration)
53
+ - ⏹ pi aborted — aborted via Esc
54
+
55
+ A separate 👋 notification on session close (optional).
56
+
57
+ ### Delivery channels (automatic fallback, best available wins)
58
+
59
+ 1. **OSC terminal protocol** — writes an escape sequence to the terminal; the **terminal app itself** shows the notification, so the sender is the terminal (no osascript needed):
60
+ - **iTerm2 → OSC 9** (`ESC]9;content`, the notification sequence from iTerm2's official docs, **verified working**)
61
+ - Ghostty / WezTerm / Hyper / rxvt-unicode → OSC 777 (`ESC]777;notify;title;content`)
62
+ > ⚠️ Note: OSC 777 is silently ignored on iTerm2 3.5.x (supported since 3.6.9). The official pi example (notify.ts) only sends OSC 777, which does nothing on iTerm2 — this plugin selects the sequence per terminal.
63
+ 2. **terminal-notifier** — used automatically once `brew install terminal-notifier` is done; most reliable at the system level (`-sender` sets ownership, `-group` dedupes).
64
+ 3. **osascript** — last-resort fallback (when no terminal protocol is available).
65
+
66
+ > Why not pure osascript: since macOS Sequoia, notifications are attributed to the *calling process*. pi is a node process that cannot be authorized in System Settings, so the notification is silently dropped (the script exits 0 but no banner appears). OSC sequences re-attribute the notification to the terminal app, which can be authorized and shows banners normally.
67
+
68
+ ### Configuration
69
+
70
+ Config file: `~/.pi/agent/extensions/token-stats/notify-config.json` (defaults apply if missing)
71
+
72
+ ```json
73
+ {
74
+ "enabled": true,
75
+ "minDurationSec": 0,
76
+ "sound": "Glass",
77
+ "onSuccess": true,
78
+ "onFailure": true,
79
+ "onAbort": true,
80
+ "onSessionEnd": true
81
+ }
82
+ ```
83
+
84
+ - `enabled` — master switch (also togglable via `/notify on|off`)
85
+ - `minDurationSec` — skip notifications for runs shorter than this many seconds (e.g. 30 avoids noise from instant replies)
86
+ - `sound` — notification sound (built-in macOS sounds: Glass/Ping/Sosumi/Hero/Funk…; `""` for silent; note only the osascript/terminal-notifier channels support sound — OSC 777 has no sound parameter)
87
+ - `onSuccess/onFailure/onAbort/onSessionEnd` — per-category switches
88
+
89
+ `/notify` — no arg shows current status; `on`/`off` toggle; `test` immediately sends a test notification to verify the channel.
90
+
91
+ ## Run timing (step-timer)
92
+
93
+ - While working: the spinner text shows `Working... 01:02` (elapsed time, refreshed every second)
94
+ - On completion: an entry is appended at the end of the session showing the total duration:
95
+
96
+ `Total time 01:23`
97
+
98
+ Persisted via `appendEntry`, kept out of the LLM context, and still visible after `/resume`.
99
+
100
+ No separate switch — always on with the package; timing semantics match the run timer (one run = first `agent_start` → `agent_settled`, including retries, compaction recovery and queued prompts).
101
+
102
+ ## Commands
103
+
104
+ - `/stats` — no arg shows **today's token stats** (same as `/stats day`)
105
+ - `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` — stats queries
106
+ - `/stats limit` — quota plan config (pick/disable the plan shown for the current provider; picking GLM asks whether to configure team credentials)
107
+ - `/stats config` — display style / display items / quota refresh interval / GLM team credentials
108
+ - `/notify [on|off|test]` — notifications toggle / test (no arg shows status)
109
+ - `/auto-remember-thinking-level [on|off]` — thinking-level memory toggle (no arg shows status)
110
+
111
+ ## GLM Team Plan
112
+
113
+ Personal and team plans share `GET /api/monitor/usage/quota/limit`; the only difference is the request headers — the team plan requires the `Bigmodel-Organization` / `Bigmodel-Project` headers plus `?type=2` (api_key + organization ID + project ID, all three required; the team tier only exists on the China endpoint `open.bigmodel.cn`).
114
+
115
+ This plugin uses the team query **only when both the organization ID and the project ID are configured**, otherwise it falls back to the personal query:
116
+
117
+ - After enabling the GLM plan (`/stats limit` → GLM), a team-credential prompt appears automatically — "✏️ Configure / Edit" or "Skip"
118
+ - Change or clear it any time via `/stats config` → "GLM team credentials"
119
+ - Credentials are stored in the `teamCredential` field of `~/.pi/agent/extensions/token-stats/config.json`:
120
+
121
+ ```json
122
+ {
123
+ "providerPlans": { "zai-coding-cn": "glm" },
124
+ "teamCredential": { "organization": "your-org-id", "project": "your-project-id" },
125
+ "ttl": 60
126
+ }
127
+ ```
128
+
129
+ > Organization ID / Project ID can be found on the "Team Coding Plan" page of the GLM Coding Plan team console; if you already configure them as environment variables for Claude Code / Cursor or similar tools, you can also put the same values into config.json directly.
130
+
131
+ ## OpenCode Go balance
132
+
133
+ The OpenCode Go subscription (`opencode-go` provider, baseUrl `https://opencode.ai/zen/go/v1`) uses the official quota endpoint `GET /zen/go/v1/usage` (`Authorization: Bearer <key>` — the `key` of `opencode-go` in auth.json or the `OPENCODE_API_KEY` env var), returning the used percentage of three rolling windows: 5 hours / week / month.
134
+
135
+ The footer shows `5h: X% W: Y% M: Z% ⏱ ...` (remaining = 100 − used); each of the last three items can be toggled via `/stats config` → Display items → "5h quota / Week quota / Month quota / Refresh time".
136
+
137
+ ## Compatibility with the original packages
138
+
139
+ - Display/quota config reuses `~/.pi/agent/extensions/token-stats/` (existing token-stats config takes effect as-is)
140
+ - Stats logs reuse `~/.pi/agent/extensions/token-stats-logs/` (historical data queryable via `/stats`)
141
+
142
+ ## Installation (replaces the original packages)
143
+
144
+ ```bash
145
+ pi remove npm:@liziy/token-stats
146
+ pi install npm:token-stats-timer
147
+ ```
148
+
149
+ Run `/reload` or restart pi to activate.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # token-stats-timer
2
2
 
3
+ > [English](README.en.md) | 简体中文(当前)
4
+
3
5
  `@liziy/token-stats` 的修改版本。
4
6
 
5
7
  一个扩展、一个 footer,同时提供 run 计时与 token 用量/配额监控。
package/index.ts CHANGED
@@ -25,6 +25,7 @@ import { createTokenStats, formatUserPath, type SharedState } from "./token-stat
25
25
  import { createNotifier } from "./notify.ts";
26
26
  import { createStepTimer } from "./step-timer.ts";
27
27
  import { createThinkingMemory } from "./thinking-memory.ts";
28
+ import { createUserLanguage } from "./user-language.ts";
28
29
 
29
30
  const shared: SharedState = {
30
31
  sessionActive: false,
@@ -33,6 +34,10 @@ const shared: SharedState = {
33
34
 
34
35
  export default function runTokenStatsExtension(pi: ExtensionAPI) {
35
36
  const stats = createTokenStats(pi, shared);
37
+
38
+ // 语言判断最先初始化:其他模块的文案随用户语言切换
39
+ createUserLanguage(pi);
40
+
36
41
  // macOS 完成通知(成功/失败/中止),配置见 notify-config.json
37
42
  createNotifier(pi);
38
43
  // 每步耗时:Thinking.../Working... 实时耗时 + 每 turn/总耗时会话摘要
package/notify.ts CHANGED
@@ -28,8 +28,7 @@
28
28
  // 便于排查"没弹通知"是触发问题还是投递问题。
29
29
 
30
30
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
- import { spawn, spawnSync } from "node:child_process";
32
- import {
31
+ import { spawn, spawnSync } from "node:child_process";import {
33
32
  appendFileSync,
34
33
  existsSync,
35
34
  mkdirSync,
@@ -38,6 +37,7 @@ import {
38
37
  } from "node:fs";
39
38
  import { homedir } from "node:os";
40
39
  import { join } from "node:path";
40
+ import { t } from "./user-language.ts";
41
41
 
42
42
  const CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
43
43
  const CONFIG_FILE = join(CONFIG_DIR, "notify-config.json");
@@ -238,15 +238,16 @@ export function createNotifier(pi: ExtensionAPI): void {
238
238
  if (runAborted) {
239
239
  if (config.onAbort) {
240
240
  notify(
241
- "⏹ pi 已中止",
242
- `耗时:${dur}\n${truncate("执行被中止")}`,
241
+ t("⏹ pi 已中止", "⏹ pi aborted"),
242
+ t(`耗时:${dur}`, `Duration: ${dur}`) +
243
+ `\n${truncate(t("执行被中止", "Execution aborted"))}`,
243
244
  config.sound,
244
245
  );
245
246
  }
246
247
  } else if (config.onSuccess) {
247
248
  notify(
248
- "✅ pi 执行完成",
249
- `耗时:${dur}`,
249
+ t("✅ pi 执行完成", "✅ pi task finished"),
250
+ t(`耗时:${dur}`, `Duration: ${dur}`),
250
251
  config.sound,
251
252
  );
252
253
  }
@@ -258,42 +259,57 @@ export function createNotifier(pi: ExtensionAPI): void {
258
259
  // 会话关闭时提示(可选)
259
260
  pi.on("session_shutdown", () => {
260
261
  if (config.enabled && config.onSessionEnd) {
261
- notify("👋 pi 会话结束", "session 已关闭", config.sound);
262
+ notify(t("👋 pi 会话结束", "👋 pi session ended"), t("session 已关闭", "Session closed"), config.sound);
262
263
  }
263
264
  });
264
265
 
265
266
  // ── /notify 命令 ─────────────────────────────────────
266
267
  pi.registerCommand("notify", {
267
- description: "macOS 完成通知: on | off | status | test(详细配置见 notify-config.json)",
268
+ description: t(
269
+ "macOS 完成通知: on | off | status | test(详细配置见 notify-config.json)",
270
+ "macOS completion notifications: on | off | status | test (see notify-config.json)",
271
+ ),
268
272
  handler: async (args, ctx) => {
269
273
  const arg = args.trim();
270
274
  if (arg === "on") {
271
275
  config = { ...config, enabled: true };
272
276
  saveConfig(config);
273
- ctx.ui.notify("完成通知已开启", "info");
277
+ ctx.ui.notify(t("完成通知已开启", "Completion notifications enabled"), "info");
274
278
  } else if (arg === "off") {
275
279
  config = { ...config, enabled: false };
276
280
  saveConfig(config);
277
- ctx.ui.notify("完成通知已关闭", "info");
281
+ ctx.ui.notify(t("完成通知已关闭", "Completion notifications disabled"), "info");
278
282
  } else if (arg === "test") {
279
283
  // 立即发一条测试通知,验证当前投递通道是否可达
280
- const title = "🔔 pi 通知测试";
284
+ const title = t("🔔 pi 通知测试", "🔔 pi notification test");
281
285
  const channel = isITerm2()
282
- ? "osc9 终端协议"
286
+ ? t("osc9 终端协议", "osc9 terminal protocol")
283
287
  : supportsOSC777()
284
- ? "osc777 终端协议"
288
+ ? t("osc777 终端协议", "osc777 terminal protocol")
285
289
  : existsSync("/opt/homebrew/bin/terminal-notifier") ||
286
290
  existsSync("/usr/local/bin/terminal-notifier")
287
291
  ? "terminal-notifier"
288
292
  : "osascript";
289
- notify(title, `投递通道:${channel}\n时间:${new Date().toLocaleTimeString()}`, config.sound);
290
- ctx.ui.notify("测试通知已发送,请查看系统通知栏", "info");
293
+ notify(
294
+ title,
295
+ t(`投递通道:${channel}\n时间:${new Date().toLocaleTimeString()}`,
296
+ `Channel: ${channel}\nTime: ${new Date().toLocaleTimeString()}`),
297
+ config.sound,
298
+ );
299
+ ctx.ui.notify(t("测试通知已发送,请查看系统通知栏", "Test notification sent, check Notification Center"), "info");
291
300
  } else {
292
- const sound = config.sound ? `, 声音=${config.sound}` : ", 静音";
301
+ const sound = config.sound
302
+ ? t(`, 声音=${config.sound}`, `, sound=${config.sound}`)
303
+ : t(", 静音", ", silent");
293
304
  ctx.ui.notify(
294
- `完成通知: ${config.enabled ? "开" : "关"}, 最短时长=${config.minDurationSec}s, ` +
295
- `成功=${config.onSuccess ? "开" : "关"}, ` +
296
- `中止=${config.onAbort ? "开" : "关"}, 会话结束=${config.onSessionEnd ? "开" : "关"}${sound}`,
305
+ t(
306
+ `完成通知: ${config.enabled ? "开" : "关"}, 最短时长=${config.minDurationSec}s, ` +
307
+ `成功=${config.onSuccess ? "开" : "关"}, ` +
308
+ `中止=${config.onAbort ? "开" : "关"}, 会话结束=${config.onSessionEnd ? "开" : "关"}${sound}`,
309
+ `Notifications: ${config.enabled ? "on" : "off"}, min duration=${config.minDurationSec}s, ` +
310
+ `success=${config.onSuccess ? "on" : "off"}, ` +
311
+ `abort=${config.onAbort ? "on" : "off"}, session end=${config.onSessionEnd ? "on" : "off"}${sound}`,
312
+ ),
297
313
  "info",
298
314
  );
299
315
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "token-stats-timer",
3
- "version": "1.1.1",
4
- "description": "pi extension to display token usage and time spend.",
3
+ "version": "1.1.2",
4
+ "description": "pi extension to display token usage and time spend. Persist preferred thinking level per model in pi",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension"
@@ -17,7 +17,9 @@
17
17
  "notify.ts",
18
18
  "step-timer.ts",
19
19
  "thinking-memory.ts",
20
- "README.md"
20
+ "user-language.ts",
21
+ "README.md",
22
+ "README.en.md"
21
23
  ],
22
24
  "scripts": {
23
25
  "test": "echo \"Error: no test specified\" && exit 1"
@@ -27,6 +29,11 @@
27
29
  "./index.ts"
28
30
  ]
29
31
  },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/i7guokui/token-stats-timer.git"
35
+ },
36
+ "homepage": "https://github.com/i7guokui/token-stats-timer/tree/main#readme",
30
37
  "peerDependencies": {
31
38
  "@earendil-works/pi-ai": "*",
32
39
  "@earendil-works/pi-coding-agent": "*",
package/step-timer.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { Box, Text } from "@earendil-works/pi-tui";
13
+ import { t } from "./user-language.ts";
13
14
 
14
15
  /** 一次 run 的总耗时汇总(appendEntry "timing-final") */
15
16
  export interface FinalTimingData {
@@ -95,7 +96,7 @@ export function createStepTimer(pi: ExtensionAPI): void {
95
96
  pi.registerEntryRenderer<FinalTimingData>(FINAL_TYPE, (entry, _opts, theme) => {
96
97
  const d = entry.data;
97
98
  if (!d) return undefined;
98
- const title = theme.fg("accent", "总耗时");
99
+ const title = theme.fg("accent", t("总耗时", "Total time"));
99
100
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
100
101
  box.addChild(new Text(`${title} ${formatDuration(d.totalMs)}`, 0, 0));
101
102
  return box;
@@ -18,6 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
+ import { t } from "./user-language.ts";
21
22
 
22
23
  type ThinkingLevel = Parameters<ExtensionAPI["setThinkingLevel"]>[0];
23
24
 
@@ -93,15 +94,30 @@ export function createThinkingMemory(pi: ExtensionAPI): void {
93
94
  }
94
95
 
95
96
  pi.registerCommand("auto-remember-thinking-level", {
96
- description: "自动记忆思考强度: on | off(无参查看状态)",
97
+ description: t(
98
+ "自动记忆思考强度: on | off(无参查看状态)",
99
+ "Auto-remember thinking level: on | off (no arg shows status)",
100
+ ),
97
101
  handler: (args, ctx) => {
98
102
  const arg = args.trim();
99
103
  if (arg === "on" || arg === "off") {
100
104
  cfg = { ...cfg, enabled: arg === "on" };
101
105
  saveConfig(cfg);
102
- ctx.ui.notify(`自动记忆思考强度已${arg === "on" ? "开启" : "关闭"}`, "info");
106
+ ctx.ui.notify(
107
+ t(
108
+ `自动记忆思考强度已${arg === "on" ? "开启" : "关闭"}`,
109
+ `Auto-remember thinking level ${arg === "on" ? "enabled" : "disabled"}`,
110
+ ),
111
+ "info",
112
+ );
103
113
  } else {
104
- ctx.ui.notify(`自动记忆思考强度: ${cfg.enabled ? "开" : "关"}`, "info");
114
+ ctx.ui.notify(
115
+ t(
116
+ `自动记忆思考强度: ${cfg.enabled ? "开" : "关"}`,
117
+ `Auto-remember thinking level: ${cfg.enabled ? "on" : "off"}`,
118
+ ),
119
+ "info",
120
+ );
105
121
  }
106
122
  },
107
123
  });
package/token-stats.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import { join } from "node:path";
27
27
  import { homedir } from "node:os";
28
28
  import { existsSync, readFileSync } from "node:fs";
29
+ import { t } from "./user-language.ts";
29
30
 
30
31
  // ── 共享状态(由 index.ts 注入,footer 渲染与模块解耦)──
31
32
 
@@ -301,7 +302,7 @@ interface ModelAgg {
301
302
 
302
303
  /** 按模型生成 markdown 用量表(模型行按总token降序,末尾合计) */
303
304
  function renderModelBreakdown(records: RawRecord[]): string[] {
304
- if (records.length === 0) return ["", "> 按模型:该范围暂无明细数据"];
305
+ if (records.length === 0) return ["", t("> 按模型:该范围暂无明细数据", "> By model: no detail data in this range")];
305
306
 
306
307
  const byModel = new Map<string, ModelAgg>();
307
308
  for (const r of records) {
@@ -357,14 +358,14 @@ function renderModelBreakdown(records: RawRecord[]): string[] {
357
358
 
358
359
  return [
359
360
  "",
360
- "**按模型**",
361
+ "**" + t("按模型", "By model") + "**",
361
362
  ...renderTable(
362
- ["模型", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
363
+ [t("模型", "Model"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
363
364
  body,
364
365
  {
365
366
  aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
366
367
  totalRow: [
367
- "合计",
368
+ t("合计", "Total"),
368
369
  String(total.count),
369
370
  formatTokens(total.input),
370
371
  formatTokens(total.cacheRead),
@@ -486,7 +487,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
486
487
  });
487
488
  const data = await r.json();
488
489
  if (data.base_resp?.status_code === 0) return data;
489
- throw new Error(data.base_resp?.status_msg || "MiniMax 返回错误");
490
+ throw new Error(data.base_resp?.status_msg || t("MiniMax 返回错误", "MiniMax returned an error"));
490
491
  },
491
492
  format: (data: any) => {
492
493
  const models = data.model_remains || [];
@@ -536,7 +537,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
536
537
  headers,
537
538
  signal: AbortSignal.timeout(5000),
538
539
  });
539
- if (!r.ok) throw new Error("GLM 配额查询 HTTP " + r.status);
540
+ if (!r.ok) throw new Error(t("GLM 配额查询 HTTP " + r.status, "GLM quota query HTTP " + r.status));
540
541
  return await r.json();
541
542
  },
542
543
  format: (data: any) => {
@@ -607,7 +608,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
607
608
  headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
608
609
  signal: AbortSignal.timeout(5000),
609
610
  });
610
- if (!r.ok) throw new Error("Kimi 配额查询 HTTP " + r.status);
611
+ if (!r.ok) throw new Error(t("Kimi 配额查询 HTTP " + r.status, "Kimi quota query HTTP " + r.status));
611
612
  return await r.json();
612
613
  },
613
614
  format: (data: any) => {
@@ -658,7 +659,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
658
659
  headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
659
660
  signal: AbortSignal.timeout(5000),
660
661
  });
661
- if (!r.ok) throw new Error("DeepSeek 配额查询 HTTP " + r.status);
662
+ if (!r.ok) throw new Error(t("DeepSeek 配额查询 HTTP " + r.status, "DeepSeek quota query HTTP " + r.status));
662
663
  return await r.json();
663
664
  },
664
665
  format: (data: any) => {
@@ -690,7 +691,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
690
691
  },
691
692
  signal: AbortSignal.timeout(5000),
692
693
  });
693
- if (!r.ok) throw new Error("OpenCode Go 配额查询 HTTP " + r.status);
694
+ if (!r.ok) throw new Error(t("OpenCode Go 配额查询 HTTP " + r.status, "OpenCode Go quota query HTTP " + r.status));
694
695
  return await r.json();
695
696
  },
696
697
  format: (data: any) => {
@@ -1383,19 +1384,22 @@ export function createTokenStats(
1383
1384
  * 把 quotaState.error 格式化为人类可读提示。
1384
1385
  */
1385
1386
  function formatQuotaError(state: QuotaDisplayState | null | undefined): string {
1386
- if (!state || !state.error) return "未知错误";
1387
+ if (!state || !state.error) return t("未知错误", "Unknown error");
1387
1388
  const e = state.error;
1388
1389
  switch (e.kind) {
1389
1390
  case "no_plan":
1390
- return "该 provider 未配置套餐";
1391
+ return t("该 provider 未配置套餐", "No quota plan configured for this provider");
1391
1392
  case "key_missing":
1392
- return `未设置环境变量 ${e.envVar} 或 ~/.pi/agent/auth.json 中 ${e.provider} 的 key 字段`;
1393
+ return t(
1394
+ `未设置环境变量 ${e.envVar} 或 ~/.pi/agent/auth.json 中 ${e.provider} 的 key 字段`,
1395
+ `Missing env var ${e.envVar} or key field for ${e.provider} in ~/.pi/agent/auth.json`,
1396
+ );
1393
1397
  case "api_error":
1394
- return `API 返回错误: ${e.message}`;
1398
+ return `API ${t("返回错误", "error")}: ${e.message}`;
1395
1399
  case "network_error":
1396
- return `网络/超时: ${e.message}`;
1400
+ return `${t("网络/超时", "Network timeout")}: ${e.message}`;
1397
1401
  case "no_data":
1398
- return "接口返回无数据";
1402
+ return t("接口返回无数据", "API returned no data");
1399
1403
  }
1400
1404
  }
1401
1405
 
@@ -1501,16 +1505,22 @@ export function createTokenStats(
1501
1505
  const curLabel =
1502
1506
  cur?.organization && cur?.project
1503
1507
  ? `${cur.organization} / ${cur.project}`
1504
- : "未配置(按个人版查询)";
1508
+ : t("未配置(按个人版查询)", "not configured (personal query)");
1509
+ const prompts = [t("✏️ 配置/修改", "✏️ Configure / Edit"), t("跳过", "Skip")];
1505
1510
  const choice = await ctx.ui.select(
1506
- `GLM 团队套餐凭证?当前:${curLabel}\n填写组织 ID + 项目 ID(二者齐全才走团队查询 ?type=2),跳过则维持个人版查询`,
1507
- ["✏️ 配置/修改", "跳过"],
1511
+ t(
1512
+ `GLM 团队套餐凭证?当前:${curLabel}\n填写组织 ID + 项目 ID(二者齐全才走团队查询 ?type=2),跳过则维持个人版查询`,
1513
+ `GLM team plan credentials? Current: ${curLabel}\nEnter organization ID + project ID (team query ?type=2 needs both), skip to keep personal query`,
1514
+ ),
1515
+ prompts,
1508
1516
  );
1509
- if (!choice || choice === "跳过") {
1517
+ if (!choice || choice === prompts[1]) {
1510
1518
  await forceRefreshQuota(ctx);
1511
1519
  const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1512
1520
  ctx.ui.notify(
1513
- quotaState?.error ? `GLM 配额查询失败:${errMsg}` : "GLM 配额已启用(个人版查询)",
1521
+ quotaState?.error
1522
+ ? t(`GLM 配额查询失败:${errMsg}`, `GLM quota query failed: ${errMsg}`)
1523
+ : t("GLM 配额已启用(个人版查询)", "GLM quota enabled (personal query)"),
1514
1524
  "info",
1515
1525
  );
1516
1526
  return;
@@ -1521,7 +1531,7 @@ export function createTokenStats(
1521
1531
  const org = organization?.trim() ?? "";
1522
1532
  const proj = project?.trim() ?? "";
1523
1533
  if (!org || !proj) {
1524
- ctx.ui.notify("组织/项目 ID 不能为空,团队凭证未保存", "warning");
1534
+ ctx.ui.notify(t("组织/项目 ID 不能为空,团队凭证未保存", "Organization/Project ID cannot be empty, credentials not saved"), "warning");
1525
1535
  return;
1526
1536
  }
1527
1537
 
@@ -1534,7 +1544,9 @@ export function createTokenStats(
1534
1544
  await forceRefreshQuota(ctx);
1535
1545
  const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1536
1546
  ctx.ui.notify(
1537
- quotaState?.error ? `GLM 团队配额查询失败:${errMsg}` : "GLM 团队套餐配额已启用",
1547
+ quotaState?.error
1548
+ ? t(`GLM 团队配额查询失败:${errMsg}`, `GLM team quota query failed: ${errMsg}`)
1549
+ : t("GLM 团队套餐配额已启用", "GLM team quota enabled"),
1538
1550
  "info",
1539
1551
  );
1540
1552
  }
@@ -1563,15 +1575,15 @@ export function createTokenStats(
1563
1575
  const cacheHitRate = weightedCacheHitRate(d);
1564
1576
 
1565
1577
  return renderTable(
1566
- ["指标", "数值"],
1578
+ [t("指标", "Metric"), t("数值", "Value")],
1567
1579
  [
1568
- ["对话次数", String(d.count)],
1569
- ["新增输入", `${formatTokens(d.sumInput)}(平均 ${formatTokens(avgInput)}/次,未命中缓存)`],
1570
- ["缓存输入", formatTokens(d.sumCacheRead)],
1571
- ["总输出", `${formatTokens(d.sumOutput)}(平均 ${formatTokens(avgOutput)}/次)`],
1572
- ["总token", `${formatTokens(totalPrompt)}(新增 + 缓存)`],
1573
- ["缓存命中率", `${cacheHitRate.toFixed(1)}%`],
1574
- ["平均速率", `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
1580
+ [t("对话次数", "Sessions"), String(d.count)],
1581
+ [t("新增输入", "New input"), `${formatTokens(d.sumInput)}(${t("平均", "avg")} ${formatTokens(avgInput)}/次,${t("未命中缓存", "uncached")})`],
1582
+ [t("缓存输入", "Cached input"), formatTokens(d.sumCacheRead)],
1583
+ [t("总输出", "Total output"), `${formatTokens(d.sumOutput)}(${t("平均", "avg")} ${formatTokens(avgOutput)}/次)`],
1584
+ [t("总token", "Total tokens"), `${formatTokens(totalPrompt)}(${t("新增 + 缓存", "new + cached")})`],
1585
+ [t("缓存命中率", "Cache hit rate"), `${cacheHitRate.toFixed(1)}%`],
1586
+ [t("平均速率", "Avg speed"), `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
1575
1587
  ],
1576
1588
  );
1577
1589
  }
@@ -1602,13 +1614,13 @@ export function createTokenStats(
1602
1614
  const daily = records.find((r) => r.date === date) || null;
1603
1615
 
1604
1616
  if (!daily) {
1605
- ctx.ui.notify(`${date} 暂无统计数据`, "info");
1617
+ ctx.ui.notify(t(`${date} 暂无统计数据`, `No stats for ${date}`), "info");
1606
1618
  return;
1607
1619
  }
1608
1620
 
1609
1621
  await showStats(
1610
1622
  [...renderDaySummary(daily), ...renderModelBreakdown(await readRawRecordsForDates([date]))],
1611
- `Token 统计 | ${date}`,
1623
+ t(`Token 统计 | ${date}`, `Token stats | ${date}`),
1612
1624
  ctx,
1613
1625
  );
1614
1626
  }
@@ -1625,14 +1637,14 @@ export function createTokenStats(
1625
1637
  }
1626
1638
 
1627
1639
  if (records.length === 0) {
1628
- ctx.ui.notify(`${date} 暂无按小时统计`, "info");
1640
+ ctx.ui.notify(t(`${date} 暂无按小时统计`, `No hourly stats for ${date}`), "info");
1629
1641
  return;
1630
1642
  }
1631
1643
 
1632
1644
  records.sort((a, b) => a.hour - b.hour);
1633
1645
 
1634
1646
  const lines = renderTable(
1635
- ["时间", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1647
+ [t("时间", "Time"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
1636
1648
  records.map((r) => {
1637
1649
  const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1638
1650
  return [
@@ -1674,12 +1686,12 @@ export function createTokenStats(
1674
1686
  .sort((a, b) => a.date.localeCompare(b.date));
1675
1687
 
1676
1688
  if (weekRecords.length === 0) {
1677
- ctx.ui.notify("本周暂无统计数据", "info");
1689
+ ctx.ui.notify(t("本周暂无统计数据", "No stats for this week"), "info");
1678
1690
  return;
1679
1691
  }
1680
1692
 
1681
1693
  const lines = renderTable(
1682
- ["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1694
+ [t("日期", "Date"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
1683
1695
  weekRecords.map((r) => {
1684
1696
  const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1685
1697
  return [
@@ -1700,7 +1712,7 @@ export function createTokenStats(
1700
1712
 
1701
1713
  await showStats(
1702
1714
  [...lines, ...renderModelBreakdown(await readRawRecordsInRange(sevenDaysAgo, today))],
1703
- "本周每天汇总",
1715
+ t("本周每天汇总", "Week summary by day"),
1704
1716
  ctx,
1705
1717
  );
1706
1718
  }
@@ -1726,7 +1738,7 @@ export function createTokenStats(
1726
1738
  .sort((a, b) => a.date.localeCompare(b.date));
1727
1739
 
1728
1740
  if (monthRecords.length === 0) {
1729
- ctx.ui.notify(`${month} 暂无统计数据`, "info");
1741
+ ctx.ui.notify(t(`${month} 暂无统计数据`, `No stats for ${month}`), "info");
1730
1742
  return;
1731
1743
  }
1732
1744
 
@@ -1747,7 +1759,7 @@ export function createTokenStats(
1747
1759
  const cacheHitRate = weightedCacheHitRate(total);
1748
1760
 
1749
1761
  const lines = renderTable(
1750
- ["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1762
+ [t("日期", "Date"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
1751
1763
  monthRecords.map((r) => {
1752
1764
  const tp = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1753
1765
  return [
@@ -1764,7 +1776,7 @@ export function createTokenStats(
1764
1776
  {
1765
1777
  aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
1766
1778
  totalRow: [
1767
- "合计",
1779
+ t("合计", "Total"),
1768
1780
  String(total.count),
1769
1781
  formatTokens(total.sumInput),
1770
1782
  formatTokens(total.sumCacheRead),
@@ -2007,7 +2019,10 @@ export function createTokenStats(
2007
2019
  // ── /stats 命令 ─────────────────────────────────────
2008
2020
 
2009
2021
  pi.registerCommand("stats", {
2010
- description: "Token 统计 (day | hour | week | month | config | limit) 无参默认显示当天统计;limit 进入套餐配置",
2022
+ description: t(
2023
+ "Token 统计 (day | hour | week | month | config | limit) 无参默认显示当天统计;limit 进入套餐配置",
2024
+ "Token stats (day | hour | week | month | config | limit) No arg shows today; limit enters quota plan config",
2025
+ ),
2011
2026
  handler: async (args, ctx) => {
2012
2027
  const arg = args.trim();
2013
2028
 
@@ -2021,19 +2036,19 @@ export function createTokenStats(
2021
2036
  if (arg === "limit") {
2022
2037
  const provider = ctx.model?.provider;
2023
2038
  if (!provider) {
2024
- ctx.ui.notify("无法获取当前供应商,请先切换对话", "warning");
2039
+ ctx.ui.notify(t("无法获取当前供应商,请先切换对话", "Cannot get current provider, switch conversation first"), "warning");
2025
2040
  return;
2026
2041
  }
2027
2042
  // 套餐用量选择菜单
2028
- const options = ["关闭", ...BUILTIN_PLANS.map(p => p.name)];
2043
+ const options = [t("关闭", "Off"), ...BUILTIN_PLANS.map(p => p.name)];
2029
2044
  const choice = await ctx.ui.select(
2030
- "选择 " + provider + " 要显示配额的套餐(选中后退出)",
2045
+ t("选择 " + provider + " 要显示配额的套餐(选中后退出)", "Select quota plan to show for " + provider + " (select to exit)"),
2031
2046
  options,
2032
2047
  );
2033
2048
 
2034
2049
  const defaults: TokenConfig = { providerPlans: {}, ttl: 60 };
2035
2050
 
2036
- if (!choice || choice === "关闭") {
2051
+ if (!choice || choice === options[0]) {
2037
2052
  tokenConfig = tokenConfig
2038
2053
  ? { ...tokenConfig, providerPlans: { ...tokenConfig.providerPlans, [provider]: null } }
2039
2054
  : { ...defaults, providerPlans: { [provider]: null } };
@@ -2049,7 +2064,7 @@ export function createTokenStats(
2049
2064
  shared.requestRender?.();
2050
2065
  }, (tokenConfig?.ttl || 60) * 1000);
2051
2066
  shared.requestRender?.();
2052
- ctx.ui.notify(provider + " 的套餐用量已关闭", "info");
2067
+ ctx.ui.notify(t(provider + " 的套餐用量已关闭", "Quota display for " + provider + " is off"), "info");
2053
2068
  return;
2054
2069
  }
2055
2070
  const plan = BUILTIN_PLANS.find(p => p.name === choice);
@@ -2077,50 +2092,52 @@ export function createTokenStats(
2077
2092
  if (quotaState?.error) {
2078
2093
  // 仅当 quotaState 带有 error 字段时(key 缺失 / API 错误 / 网络错误 / 无数据)才提示"查询失败"
2079
2094
  const errMsg = formatQuotaError(quotaState);
2080
- ctx.ui.notify(`${plan.name} 配额查询失败:${errMsg}`, "info");
2095
+ ctx.ui.notify(t(`${plan.name} 配额查询失败:${errMsg}`, `${plan.name} quota query failed: ${errMsg}`), "info");
2081
2096
  } else {
2082
- ctx.ui.notify(plan.name + " 配额已启用", "info");
2097
+ ctx.ui.notify(t(plan.name + " 配额已启用", plan.name + " quota enabled"), "info");
2083
2098
  }
2084
2099
  }
2085
2100
  return;
2086
2101
  }
2087
2102
 
2088
2103
  if (arg === "config") {
2089
- const subChoice = await ctx.ui.select("配置", [
2090
- "显示样式",
2091
- "显示内容",
2092
- "刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)",
2093
- "GLM 团队凭证",
2094
- ]);
2104
+ const cfgOpts = [
2105
+ t("显示样式", "Display style"),
2106
+ t("显示内容", "Display items"),
2107
+ t(`刷新时间 (当前 ${tokenConfig?.ttl || 60}s)`, `Refresh interval (current ${tokenConfig?.ttl || 60}s)`),
2108
+ t("GLM 团队凭证", "GLM team credentials"),
2109
+ ];
2110
+ const subChoice = await ctx.ui.select(t("配置", "Settings"), cfgOpts);
2095
2111
  if (!subChoice) return;
2096
2112
 
2097
- if (subChoice === "GLM 团队凭证") {
2113
+ if (subChoice === cfgOpts[3]) {
2098
2114
  const cur = tokenConfig?.teamCredential;
2099
2115
  const label =
2100
2116
  cur?.organization && cur?.project
2101
2117
  ? `${cur.organization} / ${cur.project}`
2102
- : "未配置";
2103
- const action = await ctx.ui.select("GLM 团队凭证(当前: " + label + ")", [
2104
- "✏️ 配置/修改",
2105
- "清除",
2106
- "返回",
2107
- ]);
2108
- if (!action || action === "返回") return;
2109
- if (action === "清除") {
2118
+ : t("未配置", "not configured");
2119
+ const actions = [
2120
+ t("✏️ 配置/修改", "✏️ Configure / Edit"),
2121
+ t("清除", "Clear"),
2122
+ t("返回", "Back"),
2123
+ ];
2124
+ const action = await ctx.ui.select(t("GLM 团队凭证(当前: " + label + ")", "GLM team credentials (current: " + label + ")"), actions);
2125
+ if (!action || action === actions[2]) return;
2126
+ if (action === actions[1]) {
2110
2127
  tokenConfig = {
2111
2128
  ...baseTokenConfig(),
2112
2129
  teamCredential: { organization: "", project: "" },
2113
2130
  };
2114
2131
  await saveTokenConfig(tokenConfig);
2115
2132
  await forceRefreshQuota(ctx);
2116
- ctx.ui.notify("GLM 团队凭证已清除(恢复个人版查询)", "info");
2133
+ ctx.ui.notify(t("GLM 团队凭证已清除(恢复个人版查询)", "GLM team credentials cleared (back to personal query)"), "info");
2117
2134
  } else {
2118
2135
  const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
2119
2136
  const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
2120
2137
  const org = organization?.trim() ?? "";
2121
2138
  const proj = project?.trim() ?? "";
2122
2139
  if (!org || !proj) {
2123
- ctx.ui.notify("组织/项目 ID 不能为空,未保存", "warning");
2140
+ ctx.ui.notify(t("组织/项目 ID 不能为空,未保存", "Organization/Project ID cannot be empty, not saved"), "warning");
2124
2141
  return;
2125
2142
  }
2126
2143
  tokenConfig = {
@@ -2131,22 +2148,20 @@ export function createTokenStats(
2131
2148
  await forceRefreshQuota(ctx);
2132
2149
  const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
2133
2150
  if (quotaState?.error) {
2134
- ctx.ui.notify(`GLM 团队配额查询失败:${errMsg}`, "info");
2151
+ ctx.ui.notify(t(`GLM 团队配额查询失败:${errMsg}`, `GLM team quota query failed: ${errMsg}`), "info");
2135
2152
  } else {
2136
- ctx.ui.notify("GLM 团队凭证已保存(团队查询已生效)", "info");
2153
+ ctx.ui.notify(t("GLM 团队凭证已保存(团队查询已生效)", "GLM team credentials saved (team query active)"), "info");
2137
2154
  }
2138
2155
  }
2139
2156
  return;
2140
2157
  }
2141
2158
 
2142
- if (subChoice === "显示样式") {
2143
- const catChoice = await ctx.ui.select("选择要配置的样式类别", [
2144
- "上下文样式",
2145
- "⚡ 速率样式",
2146
- ]);
2159
+ if (subChoice === cfgOpts[0]) {
2160
+ const catOpts = [t("上下文样式", "Context style"), t("⚡ 速率样式", "⚡ Speed style")];
2161
+ const catChoice = await ctx.ui.select(t("选择要配置的样式类别", "Select style category to configure"), catOpts);
2147
2162
  if (!catChoice) return;
2148
2163
 
2149
- if (catChoice === "上下文样式") {
2164
+ if (catChoice === catOpts[0]) {
2150
2165
  const items: { label: string; value: ContextStyle; preview: string }[] = [
2151
2166
  { label: "pct-window", value: "pct-window", preview: `5.3%/1.0M` },
2152
2167
  { label: "used-window", value: "used-window", preview: `256k/1.0M` },
@@ -2155,7 +2170,7 @@ export function createTokenStats(
2155
2170
  { label: "bar", value: "bar", preview: `[██░░░░░░] 25%` },
2156
2171
  ];
2157
2172
  const choice = await ctx.ui.select(
2158
- "上下文样式(当前: " + displayConfig.contextStyle + ")",
2173
+ t("上下文样式(当前: " + displayConfig.contextStyle + ")", "Context style (current: " + displayConfig.contextStyle + ")"),
2159
2174
  items.map(i =>
2160
2175
  (displayConfig.contextStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview
2161
2176
  ),
@@ -2175,10 +2190,10 @@ export function createTokenStats(
2175
2190
  { label: "t/s", value: "t/s", preview: `⚡77.7 t/s` },
2176
2191
  { label: "tok/s", value: "tok/s", preview: `⚡77.7 tok/s` },
2177
2192
  { label: "T/s", value: "T/s", preview: `⚡77.7 T/s` },
2178
- { label: "live@速率", value: "liveAt", preview: `⚡1.2k@77.7` },
2193
+ { label: t("live@速率", "live@rate"), value: "liveAt", preview: `⚡1.2k@77.7` },
2179
2194
  ];
2180
2195
  const choice = await ctx.ui.select(
2181
- "⚡ 速率样式(当前: " + displayConfig.speedStyle + ")",
2196
+ t("⚡ 速率样式(当前: " + displayConfig.speedStyle + ")", "⚡ Speed style (current: " + displayConfig.speedStyle + ")"),
2182
2197
  items.map(i =>
2183
2198
  (displayConfig.speedStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview
2184
2199
  ),
@@ -2194,24 +2209,24 @@ export function createTokenStats(
2194
2209
  }
2195
2210
  }
2196
2211
  }
2197
- ctx.ui.notify("显示样式已保存", "info");
2198
- } else if (subChoice === "显示内容") {
2212
+ ctx.ui.notify(t("显示样式已保存", "Display style saved"), "info");
2213
+ } else if (subChoice === cfgOpts[1]) {
2199
2214
  const itemLabels: DisplayKey[] = [
2200
2215
  "input", "output", "totalTokens", "cacheHit", "speed", "context",
2201
2216
  "quota5h", "quotaWeek", "quotaMonth", "quotaClock",
2202
2217
  ];
2203
2218
  const itemNames: Record<DisplayKey, string> = {
2204
- input: "输入", output: "输出", totalTokens: "总token",
2205
- cacheHit: "缓存命中", speed: "速度", context: "容量",
2206
- quota5h: "5h额度", quotaWeek: "周额度", quotaMonth: "月额度", quotaClock: "刷新时间",
2219
+ input: t("输入", "Input"), output: t("输出", "Output"), totalTokens: t("总token", "Total tokens"),
2220
+ cacheHit: t("缓存命中", "Cache hit"), speed: t("速度", "Speed"), context: t("容量", "Context"),
2221
+ quota5h: t("5h额度", "5h quota"), quotaWeek: t("周额度", "Week quota"), quotaMonth: t("月额度", "Month quota"), quotaClock: t("刷新时间", "Refresh time"),
2207
2222
  };
2208
2223
  while (true) {
2209
2224
  const options = itemLabels.map(k =>
2210
2225
  `${displayConfig.items[k] ? "✅" : "⬜"} ${itemNames[k]}`,
2211
2226
  );
2212
- options.push("🔙 完成");
2213
- const choice = await ctx.ui.select("选择要切换显示的项目", options);
2214
- if (!choice || choice === "🔙 完成") break;
2227
+ options.push(t("🔙 完成", "🔙 Done"));
2228
+ const choice = await ctx.ui.select(t("选择要切换显示的项目", "Select items to toggle"), options);
2229
+ if (!choice || choice === options[options.length - 1]) break;
2215
2230
  const idx = options.indexOf(choice);
2216
2231
  if (idx >= 0 && idx < itemLabels.length) {
2217
2232
  const key = itemLabels[idx];
@@ -2223,13 +2238,13 @@ export function createTokenStats(
2223
2238
  shared.requestRender?.();
2224
2239
  }
2225
2240
  }
2226
- ctx.ui.notify("状态栏显示配置已保存", "info");
2227
- } else if (subChoice === "刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)") {
2228
- const input = await ctx.ui.input("输入刷新间隔(秒)", String(tokenConfig?.ttl || 60));
2241
+ ctx.ui.notify(t("状态栏显示配置已保存", "Status bar display config saved"), "info");
2242
+ } else if (subChoice === cfgOpts[2]) {
2243
+ const input = await ctx.ui.input(t("输入刷新间隔(秒)", "Refresh interval in seconds"), String(tokenConfig?.ttl || 60));
2229
2244
  if (input) {
2230
2245
  const sec = parseInt(input, 10);
2231
2246
  if (Number.isNaN(sec) || sec < 10) {
2232
- ctx.ui.notify("刷新时间必须 >= 10 秒", "warning");
2247
+ ctx.ui.notify(t("刷新时间必须 >= 10 秒", "Refresh interval must be >= 10s"), "warning");
2233
2248
  } else {
2234
2249
  tokenConfig = tokenConfig
2235
2250
  ? { ...tokenConfig, ttl: sec }
@@ -2244,7 +2259,7 @@ export function createTokenStats(
2244
2259
  } catch { /* ctx 已失效(session 被替换),忽略 */ }
2245
2260
  shared.requestRender?.();
2246
2261
  }, sec * 1000);
2247
- ctx.ui.notify("刷新时间已设为 " + sec + " 秒", "info");
2262
+ ctx.ui.notify(t("刷新时间已设为 " + sec + " 秒", "Refresh interval set to " + sec + "s"), "info");
2248
2263
  }
2249
2264
  }
2250
2265
  }
@@ -2258,7 +2273,7 @@ export function createTokenStats(
2258
2273
  if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
2259
2274
  await showDay(date, ctx);
2260
2275
  } else {
2261
- ctx.ui.notify("用法: /stats day YYYY-MM-DD", "warning");
2276
+ ctx.ui.notify(t("用法: /stats day YYYY-MM-DD", "Usage: /stats day YYYY-MM-DD"), "warning");
2262
2277
  }
2263
2278
  } else if (arg === "hour") {
2264
2279
  await showHourly(getDateStr(), ctx);
@@ -2267,7 +2282,7 @@ export function createTokenStats(
2267
2282
  if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
2268
2283
  await showHourly(date, ctx);
2269
2284
  } else {
2270
- ctx.ui.notify("用法: /stats hour YYYY-MM-DD", "warning");
2285
+ ctx.ui.notify(t("用法: /stats hour YYYY-MM-DD", "Usage: /stats hour YYYY-MM-DD"), "warning");
2271
2286
  }
2272
2287
  } else if (arg === "week") {
2273
2288
  await showWeek(ctx);
@@ -2278,11 +2293,11 @@ export function createTokenStats(
2278
2293
  if (/^\d{4}-\d{2}$/.test(ms)) {
2279
2294
  await showMonth(ms, ctx);
2280
2295
  } else {
2281
- ctx.ui.notify("用法: /stats month YYYY-MM", "warning");
2296
+ ctx.ui.notify(t("用法: /stats month YYYY-MM", "Usage: /stats month YYYY-MM"), "warning");
2282
2297
  }
2283
2298
  } else {
2284
2299
  ctx.ui.notify(
2285
- "用法: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]",
2300
+ t("用法: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]", "Usage: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]"),
2286
2301
  "warning",
2287
2302
  );
2288
2303
  }
@@ -0,0 +1,85 @@
1
+ // user-language 模块 —— 自动判断用户中英文,输出文案随语言切换
2
+ // =============================================================================
3
+ // pi 没有内置的用户语言信息(无 locale/language API/settings 字段),
4
+ // 采用启发式判断:
5
+ // 1. LANG 环境变量初始化(zh* → 中文,其余 → 英文)
6
+ // 2. 监听 message_start 的 user 消息,滑动窗口(最近 10 条)统计
7
+ // 每条消息的 CJK 汉字占比:≥50% 消息判定为中文 → 总语言切到中文
8
+ // 判错时窗口会随后续消息自动纠偏;样本为零时保持 LANG 初始猜测。
9
+ //
10
+ // 使用:文案处用 t("中文", "English") 取当前语言版本。
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+
14
+ /** 滑动窗口大小(用户消息条数) */
15
+ const WINDOW_SIZE = 10;
16
+ /** 单条消息汉字占(汉字+英文字母)比例 ≥ 该值时判定为中文 */
17
+ const CJK_RATIO_THRESHOLD = 0.2;
18
+ /** 窗口内中文消息占比 ≥ 50% 时总语言取中文 */
19
+ const ZH_MAJORITY = 0.5;
20
+
21
+ let current: "zh" | "en" = "en";
22
+ let samples: ("zh" | "en")[] = [];
23
+
24
+ /** LANG 环境变量初始猜测:zh_* → 中文,其余 → 英文 */
25
+ function guessFromLang(): "zh" | "en" {
26
+ const lang = (process.env.LANG || "").trim();
27
+ return /^zh/i.test(lang) ? "zh" : "en";
28
+ }
29
+
30
+ /**
31
+ * 单条文本的语言判定:按 CJK 汉字与英文字母的相对占比。
32
+ * 无有效特征(空/纯数字/纯标点/纯符号)时返回 undefined(不参与统计)。
33
+ */
34
+ export function detectLanguage(text: string): "zh" | "en" | undefined {
35
+ const cjk = (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
36
+ const asciiLetters = (text.match(/[A-Za-z]/g) ?? []).length;
37
+ const meaningful = cjk + asciiLetters;
38
+ if (meaningful === 0) return undefined;
39
+ return cjk / meaningful >= CJK_RATIO_THRESHOLD ? "zh" : "en";
40
+ }
41
+
42
+ /** 当前推断的用户语言 */
43
+ export function getUserLanguage(): "zh" | "en" {
44
+ return current;
45
+ }
46
+
47
+ /** 按当前语言取文案 */
48
+ export function t(zh: string, en: string): string {
49
+ return current === "zh" ? zh : en;
50
+ }
51
+
52
+ /** 从 message.content 提取纯文本(兼容 string 与 content 块数组) */
53
+ function contentText(content: unknown): string {
54
+ if (typeof content === "string") return content;
55
+ if (Array.isArray(content)) {
56
+ return content
57
+ .filter(
58
+ (c): c is { type: string; text: string } =>
59
+ !!c && typeof c === "object" &&
60
+ (c as { type?: unknown }).type === "text" &&
61
+ typeof (c as { text?: unknown }).text === "string",
62
+ )
63
+ .map((c) => c.text)
64
+ .join(" ");
65
+ }
66
+ return "";
67
+ }
68
+
69
+ export function createUserLanguage(pi: ExtensionAPI): void {
70
+ current = guessFromLang();
71
+ samples = [];
72
+
73
+ pi.on("message_start", (event) => {
74
+ const msg = event.message;
75
+ if (!msg || msg.role !== "user") return;
76
+ const lang = detectLanguage(contentText(msg.content));
77
+ if (!lang) return;
78
+
79
+ samples.push(lang);
80
+ if (samples.length > WINDOW_SIZE) samples.shift();
81
+
82
+ const zhCount = samples.filter((s) => s === "zh").length;
83
+ current = zhCount / samples.length >= ZH_MAJORITY ? "zh" : "en";
84
+ });
85
+ }