opencode-visual-cache 1.2.9 → 1.2.11

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
@@ -44,9 +44,9 @@
44
44
  - **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
45
45
  - **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示估算 Token 占比
46
46
  - **折叠记忆**:折叠状态持久化,重启后保持
47
- - **语言适配**:自动检测系统语言
47
+ - **语言适配**:自动检测系统语言,支持 `/cache-lang` 运行时切换中/英文,偏好持久化
48
48
  - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算
49
- - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` 动态配置面板
49
+ - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板
50
50
 
51
51
  ---
52
52
 
@@ -99,6 +99,7 @@ npm install -g opencode-visual-cache@latest
99
99
  | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 |
100
100
  | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 面板边框的显隐 |
101
101
  | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 |
102
+ | `/cache-lang` | 切换显示语言 | 从列表选择中文或 English,界面即时切换,无需重启 |
102
103
 
103
104
  <div align="center">
104
105
  <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="斜杠命令" width="49%"></img>
@@ -134,6 +135,8 @@ npm install -g opencode-visual-cache@latest
134
135
 
135
136
  通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。
136
137
 
138
+ > **关于 Token 分布数值**:分布面板中"总计"为最后一次 API 调用的精确 token 数,"系统提示"/"用户"等分项为字符级 BPE 估算值。分项之和通常小于总计,差值主要来自 OpenCode 运行时注入的系统提示组成部分,包括环境信息、Skill 目录、工具 Schema 定义等(详见 [`system.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/system.ts)、[`tools.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/tools.ts))。这些内容不在 agent 配置的 `prompt` 字段中,因此插件无法估算,属于预期行为。
139
+
137
140
  ---
138
141
 
139
142
  ## 5. 更新
@@ -162,9 +165,17 @@ rm -rf ~/.cache/opencode/packages/opencode-visual-cache@latest
162
165
 
163
166
  ---
164
167
 
165
- ## 6. 调试
168
+ ## 6. 语言设置
169
+
170
+ 插件支持三种方式控制显示语言,按优先级从高到低排列:
171
+
172
+ ### 6.1 运行时切换(推荐)
173
+
174
+ 在 TUI 中输入 `/cache-lang`,从弹窗选择「中文」或「English」即可即时切换,无需重启。偏好会自动持久化,下次启动自动恢复。
166
175
 
167
- 强制英文:
176
+ ### 6.2 环境变量覆盖
177
+
178
+ 启动前设置 `CACHE_TUI_LANG` 环境变量可强制指定语言:
168
179
 
169
180
  ```powershell
170
181
  # Windows PowerShell
@@ -176,6 +187,10 @@ $env:CACHE_TUI_LANG="en"; opencode
176
187
  CACHE_TUI_LANG=en opencode
177
188
  ```
178
189
 
190
+ ### 6.3 自动检测
191
+
192
+ 默认自动检测系统语言。若不符合预期,用 `/cache-lang` 手动切换一次即可,偏好会被记住。
193
+
179
194
  ---
180
195
 
181
196
  ## 7. 兼容性
package/README_EN.md CHANGED
@@ -44,9 +44,9 @@ If you find this plugin useful, a ⭐ would mean a lot — thank you!<br>
44
44
  - **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from current theme
45
45
  - **Token Distribution**: Per-role (system / user / agent instr / tool call / tool result) estimated token breakdown
46
46
  - **Persistent State**: Fold preferences and config remembered across restarts via api.kv
47
- - **Language**: Auto-detects system locale
47
+ - **Language**: Auto-detects system locale, with `/cache-lang` for runtime switching between Chinese and English — preference persisted across restarts
48
48
  - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time
49
- - **Slash Commands**: `/cache-rate` `/cache-section` `/cache-config` for live panel configuration
49
+ - **Slash Commands**: `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration
50
50
 
51
51
  ---
52
52
 
@@ -99,6 +99,7 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime
99
99
  | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) |
100
100
  | `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, or the panel border |
101
101
  | `/cache-config` | View current config | Displays currency, rate, and section visibility |
102
+ | `/cache-lang` | Switch display language | Pick Chinese or English from the dialog — takes effect immediately, no restart needed |
102
103
 
103
104
  <div align="center">
104
105
  <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="Slash command" width="49%"></img>
@@ -162,9 +163,17 @@ Press **`Ctrl + P`** in OpenCode → `install plugin` → `opencode-visual-cache
162
163
 
163
164
  ---
164
165
 
165
- ## 6. Debug
166
+ ## 6. Language Settings
166
167
 
167
- Force English:
168
+ The plugin provides three ways to control the display language, listed by priority (highest first):
169
+
170
+ ### 6.1 Runtime Switching (recommended)
171
+
172
+ Type `/cache-lang` in the TUI and select **Chinese** or **English** from the dialog. The panel switches immediately without restarting, and your preference is persisted automatically for the next session.
173
+
174
+ ### 6.2 Environment Variable Override
175
+
176
+ Set the `CACHE_TUI_LANG` environment variable before launching to force a specific language:
168
177
 
169
178
  ```powershell
170
179
  # Windows PowerShell
@@ -176,6 +185,10 @@ $env:CACHE_TUI_LANG="en"; opencode
176
185
  CACHE_TUI_LANG=en opencode
177
186
  ```
178
187
 
188
+ ### 6.3 Auto Detection
189
+
190
+ Defaults to the system locale automatically. If it doesn't match, switch once with `/cache-lang` and the preference will be remembered.
191
+
179
192
  ---
180
193
 
181
194
  ## 7. Compatibility
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.9";
1
+ export declare const PLUGIN_VERSION = "1.2.11";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.9";
2
+ export const PLUGIN_VERSION = "1.2.11";
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ const LANG_ZH = DEBUG_LANG
48
48
  return false;
49
49
  }
50
50
  })();
51
- const T = LANG_ZH ? {
51
+ const ZH_T = {
52
52
  title: "缓存统计",
53
53
  hit: "命中率",
54
54
  totalHit: "总命中:",
@@ -77,7 +77,8 @@ const T = LANG_ZH ? {
77
77
  distOut: "输出:",
78
78
  secDetail: "明细",
79
79
  secModel: "模型",
80
- } : {
80
+ };
81
+ const EN_T = {
81
82
  title: "Token Cache",
82
83
  hit: "Hit",
83
84
  totalHit: "Total Hit:",
@@ -216,7 +217,7 @@ function fmt(n) {
216
217
  if (n >= 1_000_000)
217
218
  return (n / 1_000_000).toFixed(1) + "M";
218
219
  if (n >= 10_000)
219
- return Math.round(n / 1_000) + "K";
220
+ return (n / 1_000).toFixed(1) + "K";
220
221
  return n.toLocaleString("en-US");
221
222
  }
222
223
  function num(v) {
@@ -256,16 +257,19 @@ function estimateTokens(text) {
256
257
  else
257
258
  ascii++;
258
259
  }
259
- // Tighten the ASCII ratio for structured content where punctuation is
260
- // token-dense. JSON key-value patterns and code keywords are strong
261
- // signals that the default 4:1 ratio will materially under-estimate.
260
+ // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
261
+ // ASCII chars/token for both JSON and source code close to prose.
262
+ // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
263
+ // payloads, and systematically over-estimated token counts.
262
264
  const trimmed = text.trimStart();
263
- const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("["))
265
+ // Strip markdown code-fence prefix so that ```json … is detected as JSON
266
+ const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "");
267
+ const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
264
268
  && /"[^"]+"\s*:/.test(text);
265
269
  const codeLike = !jsonLike
266
270
  && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
267
- const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
268
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
271
+ const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4;
272
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0));
269
273
  }
270
274
  const CURRENCIES = {
271
275
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
@@ -292,7 +296,9 @@ function TokenCachePanel(props) {
292
296
  const [distOpen, setDistOpen] = createSignal(false);
293
297
  let boxEl;
294
298
  // ── shared signals (de-structured so internal code is unchanged) ──
295
- const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, borderVisible, setBorderVisible, } = props.signals;
299
+ const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, borderVisible, setBorderVisible, } = props.signals;
300
+ // ── reactive translation (follows langZH signal) ──
301
+ const t = createMemo(() => langZH() ? ZH_T : EN_T);
296
302
  // ── scan session messages reactively ──
297
303
  // SolidJS createMemo re-evaluates whenever the underlying
298
304
  // api.state.session state changes — no event listener needed.
@@ -376,6 +382,7 @@ function TokenCachePanel(props) {
376
382
  const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : "";
377
383
  if (sysPrompt)
378
384
  dist.system = estimateTokens(sysPrompt);
385
+ let lastAssMsg;
379
386
  for (const msg of msgs) {
380
387
  if (msg.role === "user") {
381
388
  const um = msg;
@@ -409,14 +416,9 @@ function TokenCachePanel(props) {
409
416
  const tp = p;
410
417
  let rawInput = "";
411
418
  try {
412
- rawInput = tp.state.raw ?? JSON.stringify(tp.state.input);
413
- }
414
- catch {
415
- try {
416
- rawInput = JSON.stringify(tp.state);
417
- }
418
- catch { }
419
+ rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "");
419
420
  }
421
+ catch { }
420
422
  if (rawInput)
421
423
  dist.toolCall += estimateTokens(rawInput);
422
424
  if (tp.state.status === "completed") {
@@ -436,19 +438,23 @@ function TokenCachePanel(props) {
436
438
  const sub = p;
437
439
  dist.agent += estimateTokens(sub.prompt || sub.description || "");
438
440
  }
439
- else if (p.type === "step-finish") {
440
- const sf = p;
441
- dist.apiInput += sf.tokens?.input ?? 0;
442
- dist.apiOutput += sf.tokens?.output ?? 0;
443
- }
444
441
  }
445
442
  }
446
443
  }
447
- const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult;
448
- const overhead = Math.max(0, dist.apiInput - totalInput);
449
- if (overhead >= 50)
450
- dist.system += overhead;
451
- hasDistData = totalInput > 0 || dist.apiOutput > 0 || dist.apiInput > 0;
444
+ // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
445
+ for (let i = msgs.length - 1; i >= 0; i--) {
446
+ if (msgs[i].role !== "assistant")
447
+ continue;
448
+ const t = msgs[i].tokens;
449
+ if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) {
450
+ lastAssMsg = msgs[i];
451
+ break;
452
+ }
453
+ }
454
+ // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
455
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read);
456
+ dist.apiOutput = num(lastAssMsg?.tokens?.output);
457
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0;
452
458
  }
453
459
  catch { }
454
460
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist();
@@ -517,6 +523,11 @@ function TokenCachePanel(props) {
517
523
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
518
524
  const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
519
525
  setBorderVisible(bv !== false);
526
+ // Restore language preference
527
+ const savedLang = props.api.kv.get(`${KV_PREFIX}.lang`);
528
+ if (savedLang === "zh" || savedLang === "en") {
529
+ setLangZH(savedLang === "zh");
530
+ }
520
531
  // Restore distribution snapshot so the token distribution block
521
532
  // doesn't blank out while api.state.part() re-hydrates.
522
533
  const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`);
@@ -600,7 +611,7 @@ function TokenCachePanel(props) {
600
611
  }
601
612
  const barW = createMemo(() => {
602
613
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
603
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
614
+ const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
604
615
  return Math.max(3, panelWidth() - overhead);
605
616
  });
606
617
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
@@ -626,7 +637,7 @@ function TokenCachePanel(props) {
626
637
  // boxEl.width may be undefined before the first measurement — guard with 0
627
638
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
628
639
  setPanelWidth((prev) => (prev === w ? prev : w));
629
- }, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: T.title }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: pal().muted }, children: [" (v", PLUGIN_VERSION, ")"] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] }), _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] })] }), _jsx(Show, { when: open(), children: _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: T.noData })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [T.hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs(Show, { when: sectionDetail(), children: [_jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secDetail }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail)) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.read, fmt(data().read), T.tok) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(data().write), T.tok) }) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(data().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(data().output), T.tok) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: T.saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secModel }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel)) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate())) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(T.provider, data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.distTitle }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle)) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distSys, fmt(data().dist.system), T.tok) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distUser, fmt(data().dist.user), T.tok) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distAgent, fmt(data().dist.agent), T.tok) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distTool, fmt(data().dist.toolCall), T.tok) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distRes, fmt(data().dist.toolResult), T.tok) }) }), _jsx("text", { fg: pal().text, children: justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok) })] })] }) })] }) })] }));
640
+ }, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: t().title }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: pal().muted }, children: [" (v", PLUGIN_VERSION, ")"] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", t().hitFolded] }), _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", t().hitFolded] })] })] })] }), _jsx(Show, { when: open(), children: _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: t().noData })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [t().hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs(Show, { when: sectionDetail(), children: [_jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t().secDetail }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail)) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().read, fmt(data().read), t().tok) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().write, fmt(data().write), t().tok) }) }), _jsx("text", { fg: pal().muted, children: justify(t().miss, fmt(data().freshInput), t().tok) }), _jsx("text", { fg: pal().muted, children: justify(t().out, fmt(data().output), t().tok) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: t().saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t().secModel }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel)) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate())) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(t().provider, data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(t().model, data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t().distTitle }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle)) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().distSys, fmt(data().dist.system), t().tok) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().distUser, fmt(data().dist.user), t().tok) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().distAgent, fmt(data().dist.agent), t().tok) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().distTool, fmt(data().dist.toolCall), t().tok) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(t().distRes, fmt(data().dist.toolResult), t().tok) }) }), _jsx("text", { fg: pal().text, children: justify(t().distTotal, fmt(data().dist.apiInput), t().tok) })] })] }) })] }) })] }));
630
641
  }
631
642
  // ---------------------------------------------------------------------------
632
643
  // Plugin entry
@@ -649,9 +660,11 @@ const tui = async (api) => {
649
660
  const [sectionModel, setSectionModel] = createSignal(true);
650
661
  const [sectionDist, setSectionDist] = createSignal(true);
651
662
  const [borderVisible, setBorderVisible] = createSignal(true);
663
+ const [langZH, setLangZH] = createSignal(LANG_ZH);
652
664
  const signals = {
653
665
  currencySymbol, setCurrencySymbol,
654
666
  exchangeRate, setExchangeRate,
667
+ langZH, setLangZH,
655
668
  sectionDetail, setSectionDetail,
656
669
  sectionModel, setSectionModel,
657
670
  sectionDist, setSectionDist,
@@ -756,6 +769,25 @@ const tui = async (api) => {
756
769
  dialog?.clear();
757
770
  },
758
771
  },
772
+ {
773
+ title: "Cache: Switch Language",
774
+ value: "cache.lang",
775
+ description: "Switch between Chinese and English display",
776
+ slash: { name: "cache-lang" },
777
+ onSelect: (dialog) => {
778
+ const cur = langZH();
779
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Display Language", options: [
780
+ { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
781
+ { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
782
+ ], onSelect: (opt) => {
783
+ const zh = opt.value === "zh";
784
+ api.kv.set(`${KV_PREFIX}.lang`, opt.value);
785
+ setLangZH(zh);
786
+ api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" });
787
+ dialog?.clear();
788
+ } })));
789
+ },
790
+ },
759
791
  ]);
760
792
  };
761
793
  const mod = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.2.9",
3
+ "version": "1.2.11",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.2.9";
2
+ export const PLUGIN_VERSION="1.2.11";
package/src/index.tsx CHANGED
@@ -71,7 +71,7 @@ const LANG_ZH = DEBUG_LANG
71
71
  catch { return false }
72
72
  })()
73
73
 
74
- const T = LANG_ZH ? {
74
+ const ZH_T = {
75
75
  title: "缓存统计",
76
76
  hit: "命中率",
77
77
  totalHit: "总命中:",
@@ -100,7 +100,9 @@ const T = LANG_ZH ? {
100
100
  distOut: "输出:",
101
101
  secDetail: "明细",
102
102
  secModel: "模型",
103
- } as const : {
103
+ } as const
104
+
105
+ const EN_T = {
104
106
  title: "Token Cache",
105
107
  hit: "Hit",
106
108
  totalHit: "Total Hit:",
@@ -241,7 +243,7 @@ function progressBar(percent: number, width: number): string {
241
243
 
242
244
  function fmt(n: number): string {
243
245
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
244
- if (n >= 10_000) return Math.round(n / 1_000) + "K"
246
+ if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
245
247
  return n.toLocaleString("en-US")
246
248
  }
247
249
 
@@ -277,17 +279,20 @@ function estimateTokens(text: string): number {
277
279
  else ascii++
278
280
  }
279
281
 
280
- // Tighten the ASCII ratio for structured content where punctuation is
281
- // token-dense. JSON key-value patterns and code keywords are strong
282
- // signals that the default 4:1 ratio will materially under-estimate.
282
+ // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
283
+ // ASCII chars/token for both JSON and source code close to prose.
284
+ // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
285
+ // payloads, and systematically over-estimated token counts.
283
286
  const trimmed = text.trimStart()
284
- const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("["))
287
+ // Strip markdown code-fence prefix so that ```json … is detected as JSON
288
+ const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
289
+ const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
285
290
  && /"[^"]+"\s*:/.test(text)
286
291
  const codeLike = !jsonLike
287
292
  && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
288
293
 
289
- const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4
290
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5))
294
+ const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
295
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
291
296
  }
292
297
 
293
298
  interface TokenDist {
@@ -314,6 +319,8 @@ interface PanelSignals {
314
319
  setCurrencySymbol: (v: string) => void
315
320
  exchangeRate: () => number
316
321
  setExchangeRate: (v: number) => void
322
+ langZH: () => boolean
323
+ setLangZH: (v: boolean) => void
317
324
  sectionDetail: () => boolean
318
325
  setSectionDetail: (v: boolean) => void
319
326
  sectionModel: () => boolean
@@ -362,12 +369,16 @@ function TokenCachePanel(props: {
362
369
  const {
363
370
  currencySymbol, setCurrencySymbol,
364
371
  exchangeRate, setExchangeRate,
372
+ langZH, setLangZH,
365
373
  sectionDetail, setSectionDetail,
366
374
  sectionModel, setSectionModel,
367
375
  sectionDist, setSectionDist,
368
376
  borderVisible, setBorderVisible,
369
377
  } = props.signals
370
378
 
379
+ // ── reactive translation (follows langZH signal) ──
380
+ const t = createMemo(() => langZH() ? ZH_T : EN_T)
381
+
371
382
  // ── scan session messages reactively ──
372
383
  // SolidJS createMemo re-evaluates whenever the underlying
373
384
  // api.state.session state changes — no event listener needed.
@@ -436,6 +447,7 @@ function TokenCachePanel(props: {
436
447
  const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
437
448
  const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
438
449
  if (sysPrompt) dist.system = estimateTokens(sysPrompt)
450
+ let lastAssMsg: AssistantMessage | undefined
439
451
  for (const msg of msgs) {
440
452
  if (msg.role === "user") {
441
453
  const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system)
@@ -445,24 +457,31 @@ function TokenCachePanel(props: {
445
457
  else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) }
446
458
  }
447
459
  } else if (msg.role === "assistant") {
448
- const am = msg as AssistantMessage; dist.output += num(am.tokens?.output)
460
+ const am = msg as AssistantMessage
461
+ dist.output += num(am.tokens?.output)
449
462
  let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
450
463
  for (const p of parts) {
451
464
  if (p.type === "tool") {
452
465
  const tp = p as any; let rawInput = ""
453
- try { rawInput = tp.state.raw ?? JSON.stringify(tp.state.input) } catch { try { rawInput = JSON.stringify(tp.state) } catch {} }
466
+ try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
454
467
  if (rawInput) dist.toolCall += estimateTokens(rawInput)
455
468
  if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
456
469
  else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
457
470
  } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
458
471
  else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
459
- else if (p.type === "step-finish") { const sf = p as any; dist.apiInput += sf.tokens?.input ?? 0; dist.apiOutput += sf.tokens?.output ?? 0 }
460
472
  }
461
473
  }
462
474
  }
463
- const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult
464
- const overhead = Math.max(0, dist.apiInput - totalInput); if (overhead >= 50) dist.system += overhead
465
- hasDistData = totalInput > 0 || dist.apiOutput > 0 || dist.apiInput > 0
475
+ // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
476
+ for (let i = msgs.length - 1; i >= 0; i--) {
477
+ if (msgs[i].role !== "assistant") continue
478
+ const t = (msgs[i] as AssistantMessage).tokens
479
+ if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
480
+ }
481
+ // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
482
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
483
+ dist.apiOutput = num(lastAssMsg?.tokens?.output)
484
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
466
485
  } catch {}
467
486
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
468
487
  return { finalDist, finalHasDist }
@@ -479,7 +498,7 @@ function TokenCachePanel(props: {
479
498
 
480
499
  const data = createMemo(() => {
481
500
  return dataSignal()
482
- })
501
+ })
483
502
 
484
503
  // Persist the last valid distribution so that data() can fall back
485
504
  // to it while api.state.part() is re-hydrating after a view switch.
@@ -529,6 +548,11 @@ function TokenCachePanel(props: {
529
548
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
530
549
  const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
531
550
  setBorderVisible(bv !== false)
551
+ // Restore language preference
552
+ const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
553
+ if (savedLang === "zh" || savedLang === "en") {
554
+ setLangZH(savedLang === "zh")
555
+ }
532
556
  // Restore distribution snapshot so the token distribution block
533
557
  // doesn't blank out while api.state.part() re-hydrates.
534
558
  const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
@@ -612,7 +636,7 @@ function TokenCachePanel(props: {
612
636
 
613
637
  const barW = createMemo(() => {
614
638
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
615
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
639
+ const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
616
640
  return Math.max(3, panelWidth() - overhead)
617
641
  })
618
642
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
@@ -658,7 +682,7 @@ function TokenCachePanel(props: {
658
682
  <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
659
683
  <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
660
684
  <span style={{ fg: pal().primary }}>
661
- <b>{T.title}</b>
685
+ <b>{t().title}</b>
662
686
  <Show when={open()}>
663
687
  <span style={{ fg: pal().muted }}> (v{PLUGIN_VERSION})</span>
664
688
  </Show>
@@ -666,18 +690,18 @@ function TokenCachePanel(props: {
666
690
  <Show when={!open() && data().hasData}>
667
691
  <Show when={data().hasTrendData}>
668
692
  <span>
669
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
693
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))}
670
694
  </span>
671
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
695
+ <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
672
696
  <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
673
697
  {" "}{trendLabel(data().trend)}
674
698
  </span>
675
699
  </Show>
676
700
  <Show when={!data().hasTrendData}>
677
701
  <span>
678
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
702
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))}
679
703
  </span>
680
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
704
+ <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
681
705
  </Show>
682
706
  </Show>
683
707
  </text>
@@ -688,7 +712,7 @@ function TokenCachePanel(props: {
688
712
  <text fg={pal().muted}>{sep()}</text>
689
713
  <text>
690
714
  <span style={{ fg: pal().muted }}>{"> "}</span>
691
- <span style={{ fg: pal().muted }}>{T.noData}</span>
715
+ <span style={{ fg: pal().muted }}>{t().noData}</span>
692
716
  </text>
693
717
  </>
694
718
  }>
@@ -696,7 +720,7 @@ function TokenCachePanel(props: {
696
720
 
697
721
  {/* hit rate + bar — inline to avoid box spacing */}
698
722
  <text>
699
- <span style={{ fg: pal().text }}>{T.hit} </span>
723
+ <span style={{ fg: pal().text }}>{t().hit} </span>
700
724
  <span style={{ fg: hitColor() }}>[{bar()}] </span>
701
725
  <span style={{ fg: pal().text }}>{pct()}</span>
702
726
  <Show when={data().hasTrendData}>
@@ -708,38 +732,38 @@ function TokenCachePanel(props: {
708
732
 
709
733
  {/* session cumulative hit rate */}
710
734
  <text fg={pal().muted}>
711
- {justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
735
+ {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
712
736
  </text>
713
737
 
714
738
  {/* ── detail section (collapsible, default open) ── */}
715
739
  <Show when={sectionDetail()}>
716
740
  <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
717
741
  <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
718
- <span style={{ fg: pal().primary }}><b>{T.secDetail}</b></span>
719
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail))}</span>
742
+ <span style={{ fg: pal().primary }}><b>{t().secDetail}</b></span>
743
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))}</span>
720
744
  </text>
721
745
 
722
746
  <Show when={detailOpen()}>
723
747
  <Show when={data().read > 0}>
724
748
  <text fg={pal().muted}>
725
- {justify(T.read, fmt(data().read), T.tok)}
749
+ {justify(t().read, fmt(data().read), t().tok)}
726
750
  </text>
727
751
  </Show>
728
752
  <Show when={data().write > 0}>
729
753
  <text fg={pal().muted}>
730
- {justify(T.write, fmt(data().write), T.tok)}
754
+ {justify(t().write, fmt(data().write), t().tok)}
731
755
  </text>
732
756
  </Show>
733
757
  <text fg={pal().muted}>
734
- {justify(T.miss, fmt(data().freshInput), T.tok)}
758
+ {justify(t().miss, fmt(data().freshInput), t().tok)}
735
759
  </text>
736
760
  <text fg={pal().muted}>
737
- {justify(T.out, fmt(data().output), T.tok)}
761
+ {justify(t().out, fmt(data().output), t().tok)}
738
762
  </text>
739
763
  <Show when={data().saved > 0}>
740
764
  <text>
741
- <span style={{ fg: pal().muted }}>{T.saved}</span>
742
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
765
+ <span style={{ fg: pal().muted }}>{t().saved}</span>
766
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
743
767
  <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
744
768
  </text>
745
769
  </Show>
@@ -750,34 +774,34 @@ function TokenCachePanel(props: {
750
774
  <Show when={sectionModel()}>
751
775
  {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
752
776
  <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
753
- <span style={{ fg: pal().primary }}><b>{T.secModel}</b></span>
754
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel))}</span>
777
+ <span style={{ fg: pal().primary }}><b>{t().secModel}</b></span>
778
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))}</span>
755
779
  </text>}
756
780
 
757
781
  <Show when={modelOpen()}>
758
782
  <text fg={pal().text}>
759
- {justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
783
+ {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
760
784
  </text>
761
785
  <Show when={data().providerName}>
762
786
  <text fg={pal().muted}>
763
- {justify(T.provider, data().providerName)}
787
+ {justify(t().provider, data().providerName)}
764
788
  </text>
765
789
  </Show>
766
790
  <text fg={pal().muted}>
767
- {justify(T.model, data().model)}
791
+ {justify(t().model, data().model)}
768
792
  </text>
769
793
  <Show when={data().hasPricing}>
770
794
  <text fg={pal().muted}>
771
- {justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate)}
795
+ {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)}
772
796
  </text>
773
797
  <Show when={data().cacheReadRate > 0}>
774
798
  <text fg={pal().muted}>
775
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate)}
799
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)}
776
800
  </text>
777
801
  </Show>
778
802
  <Show when={data().cacheWriteRate > 0}>
779
803
  <text fg={pal().muted}>
780
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate)}
804
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)}
781
805
  </text>
782
806
  </Show>
783
807
  </Show>
@@ -789,37 +813,37 @@ function TokenCachePanel(props: {
789
813
  <Show when={data().hasDistData}>
790
814
  {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
791
815
  <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
792
- <span style={{ fg: pal().primary }}><b>{T.distTitle}</b></span>
793
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle))}</span>
816
+ <span style={{ fg: pal().primary }}><b>{t().distTitle}</b></span>
817
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))}</span>
794
818
  </text>}
795
819
  <Show when={distOpen()}>
796
820
  <Show when={data().dist.system > 0}>
797
821
  <text fg={pal().muted}>
798
- {justify(T.distSys, fmt(data().dist.system), T.tok)}
822
+ {justify(t().distSys, fmt(data().dist.system), t().tok)}
799
823
  </text>
800
824
  </Show>
801
825
  <Show when={data().dist.user > 0}>
802
826
  <text fg={pal().muted}>
803
- {justify(T.distUser, fmt(data().dist.user), T.tok)}
827
+ {justify(t().distUser, fmt(data().dist.user), t().tok)}
804
828
  </text>
805
829
  </Show>
806
830
  <Show when={data().dist.agent > 0}>
807
831
  <text fg={pal().muted}>
808
- {justify(T.distAgent, fmt(data().dist.agent), T.tok)}
832
+ {justify(t().distAgent, fmt(data().dist.agent), t().tok)}
809
833
  </text>
810
834
  </Show>
811
835
  <Show when={data().dist.toolCall > 0}>
812
836
  <text fg={pal().muted}>
813
- {justify(T.distTool, fmt(data().dist.toolCall), T.tok)}
837
+ {justify(t().distTool, fmt(data().dist.toolCall), t().tok)}
814
838
  </text>
815
839
  </Show>
816
840
  <Show when={data().dist.toolResult > 0}>
817
841
  <text fg={pal().muted}>
818
- {justify(T.distRes, fmt(data().dist.toolResult), T.tok)}
842
+ {justify(t().distRes, fmt(data().dist.toolResult), t().tok)}
819
843
  </text>
820
844
  </Show>
821
845
  <text fg={pal().text}>
822
- {justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok)}
846
+ {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)}
823
847
  </text>
824
848
  </Show>
825
849
  </Show>
@@ -860,10 +884,12 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
860
884
  const [sectionModel, setSectionModel] = createSignal(true)
861
885
  const [sectionDist, setSectionDist] = createSignal(true)
862
886
  const [borderVisible, setBorderVisible] = createSignal(true)
887
+ const [langZH, setLangZH] = createSignal(LANG_ZH)
863
888
 
864
889
  const signals: PanelSignals = {
865
890
  currencySymbol, setCurrencySymbol,
866
891
  exchangeRate, setExchangeRate,
892
+ langZH, setLangZH,
867
893
  sectionDetail, setSectionDetail,
868
894
  sectionModel, setSectionModel,
869
895
  sectionDist, setSectionDist,
@@ -986,6 +1012,31 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
986
1012
  dialog?.clear()
987
1013
  },
988
1014
  },
1015
+ {
1016
+ title: "Cache: Switch Language",
1017
+ value: "cache.lang",
1018
+ description: "Switch between Chinese and English display",
1019
+ slash: { name: "cache-lang" },
1020
+ onSelect: (dialog) => {
1021
+ const cur = langZH()
1022
+ dialog?.replace(() => (
1023
+ <api.ui.DialogSelect
1024
+ title="Display Language"
1025
+ options={[
1026
+ { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1027
+ { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1028
+ ]}
1029
+ onSelect={(opt) => {
1030
+ const zh = opt.value === "zh"
1031
+ api.kv.set(`${KV_PREFIX}.lang`, opt.value)
1032
+ setLangZH(zh)
1033
+ api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" })
1034
+ dialog?.clear()
1035
+ }}
1036
+ />
1037
+ ))
1038
+ },
1039
+ },
989
1040
  ])
990
1041
  }
991
1042