opencode-visual-cache 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
2
2
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js";
3
3
  import { PLUGIN_VERSION } from "./_version";
4
+ import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider } from "./balance-providers";
5
+ import { LANG_META, createT, detectLang } from "./i18n";
4
6
  // ── terminal-width helpers ────────────────────────────────────────
5
7
  // CJK characters occupy 2 terminal columns; padEnd/padStart count
6
8
  // string length (=1 per char), which breaks alignment with mixed text.
@@ -51,97 +53,13 @@ function truncateVisual(s, maxCols) {
51
53
  }
52
54
  return result;
53
55
  }
54
- // ── language override (env: CACHE_TUI_LANG) ──
55
- const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined;
56
56
  // ── language ──────────────────────────────────────────────────────
57
- const LANG_ZH = DEBUG_LANG
58
- ? DEBUG_LANG === "zh"
59
- : (() => {
60
- try {
61
- return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh");
62
- }
63
- catch {
64
- return false;
65
- }
66
- })();
67
- const ZH_T = {
68
- title: "缓存统计",
69
- hit: "命中率",
70
- totalHit: "总命中:",
71
- read: "缓存读:",
72
- write: "缓存写:",
73
- miss: "未命中:",
74
- out: "输出:",
75
- cost: "费用:",
76
- saved: "累计节省:",
77
- model: "模型:",
78
- provider: "提供商:",
79
- rate: "单价:",
80
- hitFolded: "命中",
81
- inputRate: "输入",
82
- cacheRate: "缓存",
83
- writeRate: "写入",
84
- noData: "等待缓存数据...",
85
- tok: "tok",
86
- distTitle: "估算 Token 分布",
87
- distSys: "系统提示:",
88
- distUser: "用户:",
89
- distAgent: "Agent 指令:",
90
- distTool: "Tool 调用:",
91
- distRes: "Tool 结果:",
92
- distTotal: "总计:",
93
- distOut: "输出:",
94
- secDetail: "明细",
95
- secModel: "模型",
96
- secSkills: "已加载技能",
97
- balTotal: "总余额:",
98
- balNoKey: "未配置 API Key",
99
- balLoading: "查询中...",
100
- balError: "查询失败",
101
- balErr401: "API Key 无效",
102
- balErr403: "余额查询被拒绝",
103
- balErrEmpty: "未获取到余额数据",
104
- balErrTimeout: "查询超时",
105
- };
106
- const EN_T = {
107
- title: "Token Cache",
108
- hit: "Hit",
109
- totalHit: "Total Hit:",
110
- read: "Read:",
111
- write: "Write:",
112
- miss: "Miss:",
113
- out: "Out:",
114
- cost: "Cost:",
115
- saved: "Total Saved:",
116
- model: "Model:",
117
- provider: "Provider:",
118
- rate: "Rate:",
119
- hitFolded: "hit",
120
- inputRate: "in",
121
- cacheRate: "cache",
122
- writeRate: "write",
123
- noData: "Waiting for cache data...",
124
- tok: "tok",
125
- distTitle: "Estimated Token Dist.",
126
- distSys: "System:",
127
- distUser: "User:",
128
- distAgent: "Agent Instr:",
129
- distTool: "Tool Call:",
130
- distRes: "Tool Result:",
131
- distTotal: "Total:",
132
- distOut: "Output:",
133
- secDetail: "Detail",
134
- secModel: "Model",
135
- secSkills: "Loaded Skills",
136
- balTotal: "Total:",
137
- balNoKey: "No API Key set",
138
- balLoading: "Fetching...",
139
- balError: "Fetch failed",
140
- balErr401: "Invalid API Key",
141
- balErr403: "Balance request rejected",
142
- balErrEmpty: "No balance data",
143
- balErrTimeout: "Request timed out",
144
- };
57
+ // 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
58
+ // 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
59
+ const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined;
60
+ const INIT_LANG = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
61
+ ? DEBUG_LANG
62
+ : detectLang();
145
63
  // ── color helpers ────────────────────────────────────────────────
146
64
  /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
147
65
  function rgb(raw) {
@@ -200,7 +118,7 @@ function desaturateTo(raw, maxSat, fallback) {
200
118
  * converges to within a fraction of an 8‑bit step, eliminating
201
119
  * colour banding in edge cases.
202
120
  */
203
- // BT.601 luma (perceptual brightness used as the grey anchor)
121
+ // Bt.601 luma (perceptual brightness used as the grey anchor)
204
122
  const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
205
123
  let lo = 0, hi = 1;
206
124
  for (let i = 0; i < 12; i++) {
@@ -316,27 +234,6 @@ function estimateTokens(text) {
316
234
  return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0));
317
235
  }
318
236
  const BALANCE_POLL_MS = 5 * 60 * 1000; // 5 minutes
319
- async function fetchDeepSeekBalance(apiKey, signal) {
320
- const res = await fetch("https://api.deepseek.com/user/balance", {
321
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
322
- signal,
323
- });
324
- if (!res.ok) {
325
- if (res.status === 401)
326
- throw new Error("401");
327
- if (res.status === 402 || res.status === 403)
328
- throw new Error("403");
329
- throw new Error(String(res.status));
330
- }
331
- const json = await res.json();
332
- const infos = json.balance_infos ?? [];
333
- if (infos.length === 0)
334
- throw new Error("EMPTY");
335
- return infos.map((info) => ({
336
- currency: info.currency ?? "CNY",
337
- total: info.total_balance ?? "0",
338
- }));
339
- }
340
237
  /**
341
238
  * 将余额从来源币种换算为目标币种。
342
239
  * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
@@ -348,11 +245,69 @@ function convertBalance(target, targetRate, amount, from) {
348
245
  const usd = from === "USD" ? amount : amount / fromRate;
349
246
  return target === "USD" ? usd : usd * targetRate;
350
247
  }
248
+ /**
249
+ * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
250
+ * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
251
+ * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。
252
+ * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
253
+ */
254
+ function findOpencodeKey(api, provider) {
255
+ try {
256
+ const provs = api.state.provider;
257
+ // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
258
+ const id = provider.id.toLowerCase();
259
+ const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id));
260
+ if (!hit)
261
+ return "";
262
+ const k = typeof hit.key === "string" ? hit.key : "";
263
+ if (k)
264
+ return k;
265
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : "";
266
+ }
267
+ catch {
268
+ return "";
269
+ }
270
+ }
351
271
  /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
352
272
  function balanceSymbol(currency) {
353
273
  const sym = CURRENCIES[currency];
354
274
  return sym ?? currency + " ";
355
275
  }
276
+ /** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */
277
+ function fmtCompact(n) {
278
+ if (n >= 1e6)
279
+ return (n / 1e6).toFixed(1) + "M";
280
+ if (n >= 1e3)
281
+ return (n / 1e3).toFixed(1) + "K";
282
+ return String(Math.round(n));
283
+ }
284
+ /** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
285
+ function formatBalanceAmount(total) {
286
+ const n = parseFloat(total);
287
+ if (!Number.isFinite(n))
288
+ return total;
289
+ if (n === 0 || n >= 1)
290
+ return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
291
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
292
+ }
293
+ /**
294
+ * 将余额列表格式化为单行文本。
295
+ * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
296
+ */
297
+ function formatBalanceText(list, pref, rate) {
298
+ const native = pref ? list.find((x) => x.currency === pref) : undefined;
299
+ if (native)
300
+ return balanceSymbol(native.currency) + formatBalanceAmount(native.total);
301
+ const base = list[0];
302
+ const baseAmt = parseFloat(base.total);
303
+ const converted = Number.isFinite(baseAmt)
304
+ ? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
305
+ : baseAmt;
306
+ const shown = pref && base.currency !== pref
307
+ ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
308
+ : formatBalanceAmount(base.total);
309
+ return balanceSymbol(pref || base.currency) + shown;
310
+ }
356
311
  const CURRENCIES = {
357
312
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
358
313
  };
@@ -379,9 +334,9 @@ function TokenCachePanel(props) {
379
334
  const [skillsOpen, setSkillsOpen] = createSignal(true);
380
335
  let boxEl;
381
336
  // ── shared signals (de-structured so internal code is unchanged) ──
382
- const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
383
- // ── reactive translation (follows langZH signal) ──
384
- const t = createMemo(() => langZH() ? ZH_T : EN_T);
337
+ const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceUnsupported, setBalanceUnsupported, balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
338
+ // ── reactive translation (follows langCode signal) ──
339
+ const t = createT(() => langCode());
385
340
  // ── scan session messages reactively ──
386
341
  // SolidJS createMemo re-evaluates whenever the underlying
387
342
  // api.state.session state changes — no event listener needed.
@@ -406,51 +361,47 @@ function TokenCachePanel(props) {
406
361
  hasSkills: false,
407
362
  });
408
363
  const [refreshTick, setRefreshTick] = createSignal(0);
409
- // ── balance state + polling ──────────────────────────────────
410
- const [balanceState, setBalanceState] = createSignal({
411
- status: "idle", data: null, lastFetch: 0,
412
- });
413
- // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
414
- let balanceSeq = 0;
415
- const pollBalance = async () => {
416
- const key = props.api.kv.get(`${KV_PREFIX}.ds_key`, "");
417
- if (!key) {
418
- setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
364
+ // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
365
+ const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name);
366
+ // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
367
+ // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
368
+ // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
369
+ createEffect(() => {
370
+ if (!autoBalance())
419
371
  return;
372
+ const sid = props.signals.overrideSessionId() ?? props.sessionId;
373
+ const msgs = props.api.state.session.messages(sid);
374
+ let pid = "";
375
+ for (let i = msgs.length - 1; i >= 0; i--) {
376
+ const m = msgs[i];
377
+ if (m.role === "assistant" && m.providerID) {
378
+ pid = m.providerID;
379
+ break;
380
+ }
420
381
  }
421
- const now = Date.now();
422
- const prev = balanceState();
423
- // key 已更换(重新输入)→ 强制重新查询,绕过缓存
424
- if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS)
425
- return; // cache still fresh
426
- const seq = ++balanceSeq;
427
- setBalanceState({ ...prev, status: "loading", error: undefined, key });
428
- const controller = new AbortController();
429
- let timedOut = false;
430
- const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 10_000);
431
- try {
432
- const data = await fetchDeepSeekBalance(key, controller.signal);
433
- clearTimeout(timer);
434
- if (seq !== balanceSeq)
435
- return; // 已被更新的请求取代,丢弃过期结果
436
- setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key });
382
+ // 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
383
+ // 回退到会话级模型元数据,反映当前正在使用的 provider
384
+ if (!pid) {
385
+ try {
386
+ const session = props.api.state.session.get(sid);
387
+ pid = session?.model?.providerID ?? "";
388
+ }
389
+ catch { /* ignore */ }
437
390
  }
438
- catch (err) {
439
- clearTimeout(timer);
440
- if (seq !== balanceSeq)
441
- return;
442
- const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "");
443
- // 失败时清空旧数据,避免显示过期余额
444
- setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key });
391
+ if (!pid)
392
+ return;
393
+ const hit = matchBalanceProvider(pid);
394
+ if (hit) {
395
+ setBalanceUnsupported(false);
396
+ if (hit.id !== balanceProviderId()) {
397
+ setBalanceProviderId(hit.id);
398
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
399
+ }
400
+ }
401
+ else {
402
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
403
+ setBalanceUnsupported(true);
445
404
  }
446
- };
447
- // Re-fetch when the API key is (re)configured via /cache-balance-key.
448
- // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
449
- // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
450
- // 形成无限循环(每次重跑都发起新的 fetch 请求)。
451
- createEffect(() => {
452
- void balanceRefresh();
453
- untrack(() => { void pollBalance(); });
454
405
  });
455
406
  // ── auto-clear override when the user navigates to a different main session ──
456
407
  let lastMainSid = props.sessionId;
@@ -489,19 +440,19 @@ function TokenCachePanel(props) {
489
440
  for (const msg of msgs) {
490
441
  if (msg.role !== "assistant")
491
442
  continue;
492
- const t = msg.tokens;
493
- if (!t)
443
+ const tok = msg.tokens;
444
+ if (!tok)
494
445
  continue;
495
- const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read);
446
+ const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read);
496
447
  if (mit > 0) {
497
448
  prevMsgHitRate = lastMsgHitRate;
498
449
  lastMsgHitRate = (mrt / mit) * 100;
499
450
  }
500
451
  if (fallbackTokens) {
501
- input += num(t.input);
502
- read += num(t.cache?.read);
503
- write += num(t.cache?.write);
504
- output += num(t.output);
452
+ input += num(tok.input);
453
+ read += num(tok.cache?.read);
454
+ write += num(tok.cache?.write);
455
+ output += num(tok.output);
505
456
  }
506
457
  if (fallbackCost) {
507
458
  cost += num(msg.cost);
@@ -527,7 +478,8 @@ function TokenCachePanel(props) {
527
478
  break;
528
479
  }
529
480
  const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0;
530
- const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0;
481
+ // 总命中率分母含缓存写(业界口径:read / (input+read+write)
482
+ const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0;
531
483
  const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
532
484
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0;
533
485
  const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || "";
@@ -626,14 +578,14 @@ function TokenCachePanel(props) {
626
578
  for (let i = msgs.length - 1; i >= 0; i--) {
627
579
  if (msgs[i].role !== "assistant")
628
580
  continue;
629
- const t = msgs[i].tokens;
630
- if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) {
581
+ const tok = msgs[i].tokens;
582
+ if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) {
631
583
  lastAssMsg = msgs[i];
632
584
  break;
633
585
  }
634
586
  }
635
- // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
636
- dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read);
587
+ // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
588
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write);
637
589
  dist.apiOutput = num(lastAssMsg?.tokens?.output);
638
590
  hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0;
639
591
  }
@@ -705,6 +657,26 @@ function TokenCachePanel(props) {
705
657
  const balCur = props.api.kv.get(`${KV_PREFIX}.balance_currency`);
706
658
  if (typeof balCur === "string")
707
659
  setBalanceCurrency(balCur);
660
+ // Restore balance provider (fall back to default when unknown)
661
+ const savedProvider = props.api.kv.get(`${KV_PREFIX}.balance.provider`);
662
+ if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
663
+ setBalanceProviderId(savedProvider);
664
+ setBalanceUnsupported(false);
665
+ }
666
+ // Restore auto-switch (default on)
667
+ const savedAuto = props.api.kv.get(`${KV_PREFIX}.balance.auto`);
668
+ if (typeof savedAuto === "boolean")
669
+ setAutoBalance(savedAuto);
670
+ // Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
671
+ const legacyKey = props.api.kv.get(`${KV_PREFIX}.ds_key`, "");
672
+ if (legacyKey) {
673
+ const dsKey = props.api.kv.get(`${KV_PREFIX}.balance.deepseek.key`, "");
674
+ if (!dsKey)
675
+ props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey);
676
+ props.api.kv.set(`${KV_PREFIX}.ds_key`, "");
677
+ }
678
+ // 恢复的 provider 可能与默认值不同,强制重新查询
679
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
708
680
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
709
681
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
710
682
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
@@ -712,11 +684,6 @@ function TokenCachePanel(props) {
712
684
  setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)));
713
685
  const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
714
686
  setBorderVisible(bv !== false);
715
- // Restore language preference
716
- const savedLang = props.api.kv.get(`${KV_PREFIX}.lang`);
717
- if (savedLang === "zh" || savedLang === "en") {
718
- setLangZH(savedLang === "zh");
719
- }
720
687
  // Restore distribution snapshot so the token distribution block
721
688
  // doesn't blank out while api.state.part() re-hydrates.
722
689
  const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`);
@@ -767,8 +734,7 @@ function TokenCachePanel(props) {
767
734
  const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1); });
768
735
  const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1); });
769
736
  setRefreshTick(v => v + 1);
770
- const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS);
771
- onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession(); });
737
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession(); });
772
738
  });
773
739
  // ── colours ──
774
740
  // Pull from the current theme, auto-desaturate if too punchy,
@@ -798,11 +764,14 @@ function TokenCachePanel(props) {
798
764
  const gutter = createMemo(() => borderVisible() ? 6 : 0);
799
765
  const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())));
800
766
  function trendLabel(t) {
801
- return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "");
767
+ // |t| < 0.05 视为无变化:避免显示 "0.0%" 的矛盾(箭头存在但数值截断为零)
768
+ if (Math.abs(t) < 0.05)
769
+ return "-";
770
+ return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%";
802
771
  }
803
772
  const barW = createMemo(() => {
804
773
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
805
- const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
774
+ const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
806
775
  return Math.max(3, panelWidth() - overhead);
807
776
  });
808
777
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
@@ -828,49 +797,184 @@ function TokenCachePanel(props) {
828
797
  // boxEl.width may be undefined before the first measurement — guard with 0
829
798
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
830
799
  setPanelWidth((prev) => (prev === w ? prev : w));
831
- }, 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: dimColor(pal().muted, 0.75) }, 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] })] })] })] }), _jsxs(Show, { when: open(), children: [_jsx(Show, { when: props.signals.overrideSessionId(), children: (() => {
832
- const prefix = " \u21b3 " + (langZH() ? "\u5B50\u4EE3\u7406: " : "Sub: ");
800
+ }, 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: dimColor(pal().muted, 0.75) }, 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: Math.abs(data().trend) >= 0.05 ? (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")] })] })] })] }), _jsxs(Show, { when: open(), children: [_jsx(Show, { when: props.signals.overrideSessionId(), children: (() => {
801
+ const prefix = " \u21b3 " + t("subPrefix");
833
802
  const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix));
834
803
  return (_jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: prefix }), _jsx("span", { style: { fg: pal().text }, children: truncateVisual(props.signals.overrideSessionId(), maxSidW) })] }));
835
- })() }), _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) })] })] }) }), _jsx(Show, { when: sectionSkills(), children: _jsxs(Show, { when: data().hasSkills, children: [_jsxs("text", { onMouseUp: () => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: skillsOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t().secSkills }) }), _jsxs("span", { style: { fg: pal().muted }, children: [" (", data().skills.length, ")"] }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`)) })] }), _jsx(Show, { when: skillsOpen(), children: data().skills.map((sk) => {
836
- const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok);
804
+ })() }), _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: Math.abs(data().trend) >= 0.05 ? (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 + data().write), 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")) })] })] }) }), _jsx(Show, { when: sectionSkills(), children: _jsxs(Show, { when: data().hasSkills, children: [_jsxs("text", { onMouseUp: () => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: skillsOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t("secSkills") }) }), _jsxs("span", { style: { fg: pal().muted }, children: [" (", data().skills.length, ")"] }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`)) })] }), _jsx(Show, { when: skillsOpen(), children: data().skills.map((sk) => {
805
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"));
837
806
  const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
838
807
  const label = truncateVisual(sk.name, maxLabel);
839
- return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t().tok) }));
840
- }) })] }) }), _jsxs(Show, { when: sectionBalance(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx(Show, { when: balanceState().status === "idle", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t().balNoKey })] }) }), _jsx(Show, { when: balanceState().status === "loading", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t().balLoading })] }) }), _jsx(Show, { when: balanceState().status === "error", children: _jsxs("text", { fg: pal().error, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: (() => {
841
- const code = balanceState().error;
842
- if (code === "401")
843
- return t().balErr401;
844
- if (code === "403")
845
- return t().balErr403;
846
- if (code === "EMPTY")
847
- return t().balErrEmpty;
848
- if (code === "TIMEOUT")
849
- return t().balErrTimeout;
850
- return t().balError + (code ? ` (${code})` : "");
851
- })() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: (() => {
852
- const list = balanceState().data;
853
- const pref = balanceCurrency();
854
- // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示
855
- const native = pref ? list.find(x => x.currency === pref) : undefined;
856
- if (native) {
857
- return (_jsx("text", { fg: pal().text, children: justify(t().balTotal, balanceSymbol(native.currency) + native.total) }));
858
- }
859
- // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算
860
- const base = list[0];
861
- const baseAmt = parseFloat(base.total);
862
- const converted = Number.isFinite(baseAmt)
863
- ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency)
864
- : baseAmt;
865
- const shown = pref && base.currency !== pref
866
- ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
867
- : base.total;
868
- return (_jsx("text", { fg: pal().text, children: justify(t().balTotal, balanceSymbol(pref || base.currency) + shown) }));
869
- })() })] })] })] })] }));
808
+ return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t("tok")) }));
809
+ }) })] }) }), _jsxs(Show, { when: sectionBalance(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx(Show, { when: balanceUnsupported(), children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balUnsupported") })] }) }), _jsxs(Show, { when: !balanceUnsupported(), children: [_jsx(Show, { when: balanceState().status === "idle", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balNoKey", { p: providerName() }) })] }) }), _jsx(Show, { when: balanceState().status === "loading", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balLoading") })] }) }), _jsx(Show, { when: balanceState().status === "error", children: _jsxs("text", { fg: pal().error, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: (() => {
810
+ const code = balanceState().error;
811
+ if (code === "401")
812
+ return t("balErr401");
813
+ if (code === "403")
814
+ return t("balErr403");
815
+ if (code === "EMPTY")
816
+ return t("balErrEmpty");
817
+ if (code === "TIMEOUT")
818
+ return t("balErrTimeout");
819
+ return t("balError") + (code ? ` (${code})` : "");
820
+ })() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: _jsx("text", { fg: pal().text, children: justify(t("balTotal"), formatBalanceText(balanceState().data, balanceCurrency(), exchangeRate())) }) })] })] })] })] })] }));
870
821
  }
871
822
  // ---------------------------------------------------------------------------
872
823
  // Plugin entry
873
824
  // ---------------------------------------------------------------------------
825
+ /**
826
+ * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
827
+ * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
828
+ * 三合一信息与路径同行显示在中间位置。
829
+ */
830
+ function BottomStatusBar(props) {
831
+ const KV_PREFIX = "cache_panel";
832
+ const t = createT(() => props.signals.langCode());
833
+ const sid = props.sessionId;
834
+ // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ──
835
+ const stats = createMemo(() => {
836
+ const id = sid;
837
+ if (!id)
838
+ return null;
839
+ const msgs = props.api.state.session.messages(id);
840
+ const session = typeof props.api.state.session.get === "function"
841
+ ? props.api.state.session.get(id)
842
+ : undefined;
843
+ let input = session?.tokens?.input ?? 0;
844
+ let read = session?.tokens?.cache?.read ?? 0;
845
+ let write = session?.tokens?.cache?.write ?? 0;
846
+ // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
847
+ if (session?.tokens == null) {
848
+ for (const m of msgs) {
849
+ if (m.role !== "assistant")
850
+ continue;
851
+ const tk = m.tokens;
852
+ if (!tk)
853
+ continue;
854
+ input += num(tk.input);
855
+ read += num(tk.cache?.read);
856
+ write += num(tk.cache?.write);
857
+ }
858
+ }
859
+ // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
860
+ // 分母含缓存写(业界口径:read / (input+read+write))
861
+ let hitRate = -1, prevHitRate = -1;
862
+ for (let i = msgs.length - 1; i >= 0; i--) {
863
+ const m = msgs[i];
864
+ if (m.role !== "assistant")
865
+ continue;
866
+ const tk = m.tokens;
867
+ if (!tk)
868
+ continue;
869
+ const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write);
870
+ const mrt = num(tk.cache?.read);
871
+ if (mit <= 0)
872
+ continue;
873
+ const rate = (mrt / mit) * 100;
874
+ if (hitRate < 0) {
875
+ hitRate = rate;
876
+ continue;
877
+ }
878
+ prevHitRate = rate;
879
+ break;
880
+ }
881
+ return { hitRate, prevHitRate, input, read, write };
882
+ });
883
+ // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
884
+ // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
885
+ createEffect(() => {
886
+ if (!props.signals.autoBalance())
887
+ return;
888
+ const id = sid;
889
+ if (!id)
890
+ return;
891
+ const msgs = props.api.state.session.messages(id);
892
+ let pid = "";
893
+ for (let i = msgs.length - 1; i >= 0; i--) {
894
+ const m = msgs[i];
895
+ if (m.role === "assistant" && m.providerID) {
896
+ pid = m.providerID;
897
+ break;
898
+ }
899
+ }
900
+ if (!pid) {
901
+ try {
902
+ pid = props.api.state.session.get(id)?.model?.providerID ?? "";
903
+ }
904
+ catch { }
905
+ }
906
+ if (!pid)
907
+ return;
908
+ const hit = matchBalanceProvider(pid);
909
+ if (hit) {
910
+ props.signals.setBalanceUnsupported(false);
911
+ if (hit.id !== props.signals.balanceProviderId()) {
912
+ props.signals.setBalanceProviderId(hit.id);
913
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
914
+ }
915
+ }
916
+ else {
917
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
918
+ props.signals.setBalanceUnsupported(true);
919
+ }
920
+ });
921
+ // ── 主题色(与侧边栏同口径)──
922
+ const pal = createMemo(() => {
923
+ const th = props.api.theme.current;
924
+ const sat = (k, fb) => desaturateTo(th[k], MAX_SAT, fb);
925
+ return {
926
+ text: sat("text", FALLBACK.text),
927
+ muted: sat("textMuted", FALLBACK.muted),
928
+ success: sat("success", FALLBACK.success),
929
+ warning: sat("warning", FALLBACK.warning),
930
+ error: sat("error", FALLBACK.error),
931
+ };
932
+ });
933
+ const hitColor = createMemo(() => {
934
+ const r = stats()?.hitRate ?? -1;
935
+ if (r >= 85)
936
+ return pal().success;
937
+ if (r >= 70)
938
+ return pal().warning;
939
+ return pal().error;
940
+ });
941
+ // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
942
+ const trend = createMemo(() => {
943
+ const s = stats();
944
+ if (!s || s.prevHitRate < 0 || s.hitRate < 0)
945
+ return null;
946
+ const d = s.hitRate - s.prevHitRate;
947
+ return Math.abs(d) < 0.05 ? null : d;
948
+ });
949
+ const balanceText = createMemo(() => {
950
+ const s = props.signals.balanceState();
951
+ if (s.status === "ok" && s.data)
952
+ return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate());
953
+ if (s.status === "loading")
954
+ return "\u2026";
955
+ if (s.status === "error")
956
+ return "\u26a0";
957
+ return "-";
958
+ });
959
+ // 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
960
+ const directory = createMemo(() => {
961
+ try {
962
+ return props.api.state.path.directory;
963
+ }
964
+ catch {
965
+ return "";
966
+ }
967
+ });
968
+ // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
969
+ onMount(() => {
970
+ try {
971
+ const v = props.api.kv.get(`${KV_PREFIX}.section.bottom`, true);
972
+ props.signals.setSectionBottom(v !== false);
973
+ }
974
+ catch { }
975
+ });
976
+ return (_jsx(Show, { when: props.signals.sectionBottom(), fallback: _jsx("text", { fg: pal().muted, children: directory() }), children: _jsxs("box", { marginLeft: 1, flexGrow: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: pal().muted, children: directory() }), _jsxs("box", { flexDirection: "row", children: [_jsxs("text", { children: [_jsxs("span", { style: { fg: pal().muted }, children: [t("barHit"), " "] }), _jsx("span", { style: { fg: hitColor() }, children: (stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats().hitRate * 10) / 10).toFixed(1) + "%" : "--" }), _jsx(Show, { when: trend() !== null, children: _jsx("span", { style: { fg: trend() > 0 ? pal().success : pal().error }, children: " " + (trend() > 0 ? "\u2191" : "\u2193") + Math.abs(trend()).toFixed(1) + "%" }) }), _jsx("span", { style: { fg: pal().muted }, children: " \u00b7 " + t("barTok") + " " }), _jsx("span", { style: { fg: pal().text }, children: stats() ? fmtCompact(stats().input + stats().read + stats().write) : "--" }), _jsxs(Show, { when: !props.signals.balanceUnsupported(), children: [_jsx("span", { style: { fg: pal().muted }, children: " \u00b7 " + t("barBal") + " " }), _jsx("span", { style: { fg: pal().text }, children: balanceText() })] })] }), _jsx("text", { fg: pal().muted, children: " \u00b7 " })] })] }) }));
977
+ }
874
978
  function createSidebarSlot(api, signals) {
875
979
  let lastSlotSid = "";
876
980
  return {
@@ -899,28 +1003,166 @@ const tui = async (api) => {
899
1003
  const [sectionDist, setSectionDist] = createSignal(true);
900
1004
  const [sectionSkills, setSectionSkills] = createSignal(true);
901
1005
  const [sectionBalance, setSectionBalance] = createSignal(true);
1006
+ const [sectionBottom, setSectionBottom] = createSignal(true);
902
1007
  const [balanceRefresh, setBalanceRefresh] = createSignal(0);
1008
+ const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek");
1009
+ const [autoBalance, setAutoBalance] = createSignal(true);
1010
+ const [balanceUnsupported, setBalanceUnsupported] = createSignal(false);
903
1011
  const [balanceCurrency, setBalanceCurrency] = createSignal("");
904
1012
  const [borderVisible, setBorderVisible] = createSignal(true);
905
- const [langZH, setLangZH] = createSignal(LANG_ZH);
1013
+ const [langCode, setLangCode] = createSignal(INIT_LANG);
906
1014
  const [overrideSessionId, setOverrideSessionId] = createSignal(undefined);
1015
+ // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1016
+ // 避免重复请求导致两处余额不一致 ──
1017
+ const [balanceState, setBalanceState] = createSignal({
1018
+ status: "idle", data: null, lastFetch: 0,
1019
+ });
1020
+ // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
1021
+ let balanceSeq = 0;
907
1022
  const signals = {
908
1023
  currencySymbol, setCurrencySymbol,
909
1024
  exchangeRate, setExchangeRate,
910
- langZH, setLangZH,
1025
+ langCode, setLangCode,
911
1026
  sectionDetail, setSectionDetail,
912
1027
  sectionModel, setSectionModel,
913
1028
  sectionDist, setSectionDist,
914
1029
  sectionSkills, setSectionSkills,
915
1030
  sectionBalance, setSectionBalance,
1031
+ sectionBottom, setSectionBottom,
916
1032
  balanceRefresh, setBalanceRefresh,
1033
+ balanceProviderId, setBalanceProviderId,
1034
+ autoBalance, setAutoBalance,
1035
+ balanceUnsupported, setBalanceUnsupported,
1036
+ balanceState,
917
1037
  balanceCurrency, setBalanceCurrency,
918
1038
  borderVisible, setBorderVisible,
919
1039
  overrideSessionId, setOverrideSessionId,
920
1040
  };
921
1041
  api.slots.register(createSidebarSlot(api, signals));
1042
+ // 输入框 hint 行(session_prompt slot,replace 模式):
1043
+ // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
1044
+ // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
1045
+ api.slots.register({
1046
+ order: 55,
1047
+ slots: {
1048
+ session_prompt(_ctx, input) {
1049
+ return (_jsx(api.ui.Prompt, { sessionID: input.session_id, visible: input.visible, disabled: input.disabled, onSubmit: input.on_submit, ref: input.ref, hint: _jsx(BottomStatusBar, { api: api, signals: signals, sessionId: input.session_id }) }));
1050
+ },
1051
+ },
1052
+ });
922
1053
  // ── slash commands for runtime config ──
923
1054
  const KV_PREFIX = "cache_panel";
1055
+ // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
1056
+ const restoreLang = () => {
1057
+ try {
1058
+ const saved = api.kv.get(`${KV_PREFIX}.lang`);
1059
+ if (saved && LANG_META.some((m) => m.code === saved))
1060
+ setLangCode(saved);
1061
+ }
1062
+ catch { }
1063
+ };
1064
+ if (api.kv.ready) {
1065
+ restoreLang();
1066
+ }
1067
+ else {
1068
+ const langTimer = setInterval(() => {
1069
+ if (api.kv.ready) {
1070
+ clearInterval(langTimer);
1071
+ restoreLang();
1072
+ }
1073
+ }, 10);
1074
+ api.lifecycle.onDispose(() => clearInterval(langTimer));
1075
+ }
1076
+ const pollBalance = async () => {
1077
+ const provider = getBalanceProvider(balanceProviderId());
1078
+ // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
1079
+ const key = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1080
+ || findOpencodeKey(api, provider);
1081
+ if (balanceUnsupported()) {
1082
+ setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
1083
+ return;
1084
+ }
1085
+ if (!key) {
1086
+ setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
1087
+ return;
1088
+ }
1089
+ const now = Date.now();
1090
+ const prev = balanceState();
1091
+ // key 已更换(重新输入)→ 强制重新查询,绕过缓存
1092
+ if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS)
1093
+ return; // cache still fresh
1094
+ const seq = ++balanceSeq;
1095
+ setBalanceState({ ...prev, status: "loading", error: undefined, key });
1096
+ const controller = new AbortController();
1097
+ let timedOut = false;
1098
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 10_000);
1099
+ try {
1100
+ const data = await provider.fetchBalance(key, controller.signal);
1101
+ clearTimeout(timer);
1102
+ if (seq !== balanceSeq)
1103
+ return; // 已被更新的请求取代,丢弃过期结果
1104
+ setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key });
1105
+ }
1106
+ catch (err) {
1107
+ clearTimeout(timer);
1108
+ if (seq !== balanceSeq)
1109
+ return;
1110
+ const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "");
1111
+ // 失败时清空旧数据,避免显示过期余额
1112
+ setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key });
1113
+ }
1114
+ };
1115
+ // Re-fetch when the API key is (re)configured via /cache-balance-key.
1116
+ // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
1117
+ // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
1118
+ // 形成无限循环(每次重跑都发起新的 fetch 请求)。
1119
+ createEffect(() => {
1120
+ void balanceRefresh();
1121
+ untrack(() => { void pollBalance(); });
1122
+ });
1123
+ // 定时轮询(5 分钟);随插件生命周期清理
1124
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS);
1125
+ api.lifecycle.onDispose(() => clearInterval(balanceTimer));
1126
+ /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
1127
+ const providerOptionTitle = (p, current) => {
1128
+ const t = createT(() => langCode());
1129
+ const hasManual = !!api.kv.get(`${KV_PREFIX}.balance.${p.id}.key`, "");
1130
+ const hasAuto = !hasManual && !!findOpencodeKey(api, p);
1131
+ const mark = hasManual
1132
+ ? t("keyUser")
1133
+ : hasAuto
1134
+ ? t("keyOpenCode")
1135
+ : t("keyNotSet");
1136
+ return p.name + mark + (current && p.id === current ? " *" : "");
1137
+ };
1138
+ /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
1139
+ const promptBalanceKey = (dialog, provider) => {
1140
+ const t = createT(() => langCode());
1141
+ const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
1142
+ const masked = maskKey(current);
1143
+ dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: provider.name, description: () => _jsx("text", { children: t("balKeyPrompt", { p: provider.name }) }), placeholder: provider.keyPlaceholder ?? "sk-...", value: masked, onConfirm: (val) => {
1144
+ const input = val.trim();
1145
+ let key;
1146
+ if (input === "") {
1147
+ key = "";
1148
+ }
1149
+ else if (input.includes("*")) {
1150
+ key = current;
1151
+ }
1152
+ else {
1153
+ key = input;
1154
+ }
1155
+ api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key);
1156
+ setBalanceRefresh(v => v + 1);
1157
+ if (key) {
1158
+ api.ui.toast({ message: t("keySaved") });
1159
+ }
1160
+ else {
1161
+ api.ui.toast({ message: t("keyCleared") });
1162
+ }
1163
+ dialog?.clear();
1164
+ }, onCancel: () => dialog?.clear() })));
1165
+ };
924
1166
  api.command?.register(() => [
925
1167
  {
926
1168
  title: "Cache: Set Currency",
@@ -932,6 +1174,7 @@ const tui = async (api) => {
932
1174
  title: `${code} (${sym})`,
933
1175
  value: code,
934
1176
  })), onSelect: (opt) => {
1177
+ const t = createT(() => langCode());
935
1178
  const sym = CURRENCIES[opt.value] ?? "$";
936
1179
  const defRate = DEFAULT_RATES[opt.value] ?? 1;
937
1180
  api.kv.set(`${KV_PREFIX}.currency`, sym);
@@ -941,7 +1184,7 @@ const tui = async (api) => {
941
1184
  signals.setBalanceCurrency(opt.value);
942
1185
  signals.setCurrencySymbol(sym);
943
1186
  signals.setExchangeRate(defRate);
944
- api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` });
1187
+ api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) });
945
1188
  dialog?.clear();
946
1189
  } })));
947
1190
  },
@@ -953,11 +1196,12 @@ const tui = async (api) => {
953
1196
  slash: { name: "cache-rate" },
954
1197
  onSelect: (dialog) => {
955
1198
  dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: "Exchange Rate", description: () => _jsx("text", { children: "Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)" }), placeholder: "1.0", value: String(api.kv.get(`${KV_PREFIX}.rate`, 1)), onConfirm: (val) => {
1199
+ const t = createT(() => langCode());
956
1200
  const n = parseFloat(val);
957
1201
  if (n > 0) {
958
1202
  api.kv.set(`${KV_PREFIX}.rate`, n);
959
1203
  signals.setExchangeRate(n);
960
- api.ui.toast({ message: `Exchange rate set to ${n}` });
1204
+ api.ui.toast({ message: t("rateSet", { r: n }) });
961
1205
  }
962
1206
  dialog?.clear();
963
1207
  } })));
@@ -969,25 +1213,38 @@ const tui = async (api) => {
969
1213
  description: "Show or hide a sidebar section",
970
1214
  slash: { name: "cache-section" },
971
1215
  onSelect: (dialog) => {
1216
+ const t = createT(() => langCode());
972
1217
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
973
1218
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
974
1219
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
975
1220
  const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
976
1221
  const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
1222
+ const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true));
977
1223
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
978
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
979
- { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
980
- { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
981
- { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
982
- { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
983
- { title: `DS Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" },
984
- { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1224
+ const labels = {
1225
+ detail: t("secDetail"),
1226
+ model: t("secModel"),
1227
+ dist: t("distTitle"),
1228
+ skills: t("secSkills"),
1229
+ balance: t("secBalance"),
1230
+ bottom: t("secBottom"),
1231
+ border: t("secBorder"),
1232
+ };
1233
+ const optTitle = (label, on) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`;
1234
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("secToggle"), options: [
1235
+ { title: optTitle(labels.detail, detailOn), value: "detail" },
1236
+ { title: optTitle(labels.model, modelOn), value: "model" },
1237
+ { title: optTitle(labels.dist, distOn), value: "dist" },
1238
+ { title: optTitle(labels.skills, skillsOn), value: "skills" },
1239
+ { title: optTitle(labels.balance, balanceOn), value: "balance" },
1240
+ { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
1241
+ { title: optTitle(labels.border, borderOn), value: "border" },
985
1242
  ], onSelect: (opt) => {
986
1243
  if (opt.value === "border") {
987
1244
  const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
988
1245
  api.kv.set(`${KV_PREFIX}.border`, !cur);
989
1246
  signals.setBorderVisible(!cur);
990
- api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` });
1247
+ api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") });
991
1248
  }
992
1249
  else {
993
1250
  const key = `${KV_PREFIX}.section.${opt.value}`;
@@ -1003,7 +1260,10 @@ const tui = async (api) => {
1003
1260
  signals.setSectionSkills(!cur);
1004
1261
  if (opt.value === "balance")
1005
1262
  signals.setSectionBalance(!cur);
1006
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
1263
+ if (opt.value === "bottom")
1264
+ signals.setSectionBottom(!cur);
1265
+ const name = labels[opt.value] ?? opt.value;
1266
+ api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) });
1007
1267
  }
1008
1268
  dialog?.clear();
1009
1269
  } })));
@@ -1015,6 +1275,7 @@ const tui = async (api) => {
1015
1275
  description: "Display the current plugin configuration",
1016
1276
  slash: { name: "cache-config" },
1017
1277
  onSelect: (dialog) => {
1278
+ const t = createT(() => langCode());
1018
1279
  const sym = api.kv.get(`${KV_PREFIX}.currency`) ?? "$";
1019
1280
  const rate = api.kv.get(`${KV_PREFIX}.rate`) ?? 1;
1020
1281
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
@@ -1022,9 +1283,16 @@ const tui = async (api) => {
1022
1283
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
1023
1284
  const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
1024
1285
  const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
1286
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true));
1287
+ const on = (v) => v ? "ON" : "OFF";
1025
1288
  api.ui.toast({
1026
- title: "Cache Panel Config",
1027
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"} | Balance: ${balance ? "ON" : "OFF"}`,
1289
+ title: t("panelConfigTitle"),
1290
+ message: t("panelConfigMsg", {
1291
+ c: sym, r: rate,
1292
+ d: on(detail), m: on(model),
1293
+ t: on(dist), k: on(skills),
1294
+ b: on(balance), f: on(bottom),
1295
+ }),
1028
1296
  duration: 8000,
1029
1297
  });
1030
1298
  dialog?.clear();
@@ -1036,61 +1304,93 @@ const tui = async (api) => {
1036
1304
  description: "Switch between Chinese and English display",
1037
1305
  slash: { name: "cache-lang" },
1038
1306
  onSelect: (dialog) => {
1039
- const cur = langZH();
1040
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Display Language", options: [
1041
- { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1042
- { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1043
- ], onSelect: (opt) => {
1044
- const zh = opt.value === "zh";
1045
- api.kv.set(`${KV_PREFIX}.lang`, opt.value);
1046
- setLangZH(zh);
1047
- api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" });
1307
+ const t = createT(() => langCode());
1308
+ const cur = langCode();
1309
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("langTitle"), options: LANG_META.map((m) => ({
1310
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
1311
+ value: m.code,
1312
+ })), onSelect: (opt) => {
1313
+ const code = opt.value;
1314
+ api.kv.set(`${KV_PREFIX}.lang`, code);
1315
+ setLangCode(code);
1316
+ api.ui.toast({ message: t("langSwitched") });
1048
1317
  dialog?.clear();
1049
1318
  } })));
1050
1319
  },
1051
1320
  },
1052
1321
  {
1053
- title: "Cache: Set DeepSeek API Key",
1054
- value: "cache.balance.key",
1055
- description: "Set or update the DeepSeek API key for balance display",
1056
- slash: { name: "cache-balance-key" },
1322
+ title: "Cache: Switch Balance Provider",
1323
+ value: "cache.balance",
1324
+ description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
1325
+ slash: { name: "cache-balance" },
1057
1326
  onSelect: (dialog) => {
1058
- const zh = langZH();
1059
- const current = api.kv.get(`${KV_PREFIX}.ds_key`, "");
1060
- // 已保存的 key 以脱敏形式预填:保留 "sk-" 前缀 + 头 5 尾 5 字符,中间用 * 填充
1061
- const maskKey = (k) => {
1062
- if (!k)
1063
- return "";
1064
- const prefix = k.startsWith("sk-") ? "sk-" : "";
1065
- const body = prefix ? k.slice(3) : k;
1066
- if (body.length <= 10)
1067
- return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5));
1068
- return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5);
1069
- };
1070
- const masked = maskKey(current);
1071
- dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: zh ? "DeepSeek API Key" : "DeepSeek API Key", description: () => _jsx("text", { children: zh ? "输入 DeepSeek API Key 以显示账户余额(留空清除)" : "Enter your DeepSeek API key to show account balance (leave empty to clear)" }), placeholder: "sk-...", value: masked, onConfirm: (val) => {
1072
- const input = val.trim();
1073
- // 清除;含 * (脱敏占位符残留)→ 视为未修改,保留原 key;否则为新 key
1074
- let key;
1075
- if (input === "") {
1076
- key = "";
1077
- }
1078
- else if (input.includes("*")) {
1079
- key = current;
1080
- }
1081
- else {
1082
- key = input;
1083
- }
1084
- api.kv.set(`${KV_PREFIX}.ds_key`, key);
1085
- setBalanceRefresh(v => v + 1);
1086
- if (key) {
1087
- api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." });
1327
+ const t = createT(() => langCode());
1328
+ const current = signals.balanceProviderId();
1329
+ const auto = signals.autoBalance();
1330
+ const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`;
1331
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("balProvTitle"), options: [
1332
+ {
1333
+ title: autoLabel,
1334
+ value: "__auto__",
1335
+ },
1336
+ ...balanceProviders.map((p) => ({
1337
+ title: providerOptionTitle(p, current),
1338
+ value: p.id,
1339
+ })),
1340
+ ], onSelect: (opt) => {
1341
+ if (opt.value === "__auto__") {
1342
+ const next = !auto;
1343
+ api.kv.set(`${KV_PREFIX}.balance.auto`, next);
1344
+ signals.setAutoBalance(next);
1345
+ api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") });
1346
+ dialog?.clear();
1088
1347
  }
1089
1348
  else {
1090
- api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" });
1349
+ const provider = getBalanceProvider(opt.value);
1350
+ // 手动切换会关闭自动切换
1351
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id);
1352
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false);
1353
+ signals.setBalanceProviderId(provider.id);
1354
+ signals.setAutoBalance(false);
1355
+ signals.setBalanceUnsupported(false);
1356
+ // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
1357
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
1358
+ const hasKey = !!api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
1359
+ if (!hasKey) {
1360
+ // 未配置 key → 进入设置流程(对话框保持打开等待输入)
1361
+ promptBalanceKey(dialog, provider);
1362
+ }
1363
+ else {
1364
+ api.ui.toast({ message: t("providerManual", { p: provider.name }) });
1365
+ dialog?.clear();
1366
+ }
1091
1367
  }
1092
- dialog?.clear();
1093
- }, onCancel: () => dialog?.clear() })));
1368
+ } })));
1369
+ },
1370
+ },
1371
+ {
1372
+ title: "Cache: Set Balance API Key",
1373
+ value: "cache.balance.key",
1374
+ description: "Select a provider and set its API key for balance display",
1375
+ slash: { name: "cache-balance-key" },
1376
+ onSelect: (dialog) => {
1377
+ const t = createT(() => langCode());
1378
+ // 步骤 1:选择 provider
1379
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("balSelectTitle"), options: balanceProviders.map((p) => ({
1380
+ title: providerOptionTitle(p),
1381
+ value: p.id,
1382
+ })), onSelect: (opt) => {
1383
+ const provider = getBalanceProvider(opt.value);
1384
+ // 手动指定 provider 会关闭自动切换
1385
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id);
1386
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false);
1387
+ signals.setBalanceProviderId(provider.id);
1388
+ signals.setAutoBalance(false);
1389
+ // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
1390
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
1391
+ // 步骤 2:输入 key
1392
+ promptBalanceKey(dialog, provider);
1393
+ } })));
1094
1394
  },
1095
1395
  },
1096
1396
  {
@@ -1099,9 +1399,10 @@ const tui = async (api) => {
1099
1399
  description: "Dump all tool parts found in the current session for skill detection debugging",
1100
1400
  slash: { name: "cache-debug-skills" },
1101
1401
  onSelect: () => {
1402
+ const t = createT(() => langCode());
1102
1403
  const rt = api.route.current;
1103
1404
  if (rt.name !== "session" || !rt.params) {
1104
- api.ui.toast({ message: "Please run this command inside a session", variant: "warning" });
1405
+ api.ui.toast({ message: t("runInSession"), variant: "warning" });
1105
1406
  return;
1106
1407
  }
1107
1408
  const sid = String(rt.params.sessionID);
@@ -1188,7 +1489,7 @@ const tui = async (api) => {
1188
1489
  return false; seen.add(c.value); return true; });
1189
1490
  if (unique.length > 0) {
1190
1491
  // ── 有子代理 → DialogSelect 列表选择 ──
1191
- const zh = langZH();
1492
+ const t = createT(() => langCode());
1192
1493
  const currentSid = signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "");
1193
1494
  const options = unique.map((c, i) => ({
1194
1495
  title: `${i + 1}. ${c.title}`,
@@ -1197,33 +1498,33 @@ const tui = async (api) => {
1197
1498
  }));
1198
1499
  // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
1199
1500
  const backValue = "__main__";
1200
- const backTitle = `\u2500 ${zh ? "\u56DE\u5230\u4E3B\u4F1A\u8BDD" : "Back to Main"}`;
1501
+ const backTitle = `\u2500 ${t("backToMainTitle")}`;
1201
1502
  options.unshift({ title: backTitle, value: backValue, description: "" });
1202
1503
  options.push({ title: backTitle, value: backValue, description: "" });
1203
1504
  const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1;
1204
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: zh ? "选择子代理" : "Select Sub-Agent", options: options, current: currentIdx >= 0 ? options[currentIdx].value : undefined, onSelect: (opt) => {
1505
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("subSelectTitle"), options: options, current: currentIdx >= 0 ? options[currentIdx].value : undefined, onSelect: (opt) => {
1205
1506
  if (opt.value === backValue) {
1206
1507
  signals.setOverrideSessionId(undefined);
1207
1508
  api.kv.set(`${KV_PREFIX}.session`, "");
1208
- api.ui.toast({ message: zh ? "已切回主会话" : "Switched to main session" });
1509
+ api.ui.toast({ message: t("backToMain") });
1209
1510
  }
1210
1511
  else {
1211
1512
  signals.setOverrideSessionId(opt.value);
1212
1513
  api.kv.set(`${KV_PREFIX}.session`, opt.value);
1213
- api.ui.toast({ message: (zh ? "已切换至子代理: " : "Showing sub-agent: ") + opt.value.slice(0, 24) + "\u2026" });
1514
+ api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) });
1214
1515
  }
1215
1516
  dialog?.clear();
1216
1517
  } })));
1217
1518
  }
1218
1519
  else {
1219
1520
  // ── 无子代理 → DialogPrompt 手动粘贴 ──
1220
- const zh = langZH();
1221
- dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: signals.overrideSessionId() ? zh ? "切换子代理" : "Switch Sub" : zh ? "查看子代理缓存" : "View Sub Cache", description: () => _jsx("text", { children: zh ? "未找到子代理,请手动粘贴 Session ID" : "No sub-agents found. Paste a Session ID manually" }), placeholder: "ses_...", value: signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "") ?? "", onConfirm: (val) => {
1521
+ const t = createT(() => langCode());
1522
+ dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle"), description: () => _jsx("text", { children: t("subNoFound") }), placeholder: "ses_...", value: signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "") ?? "", onConfirm: (val) => {
1222
1523
  const sid = val.trim();
1223
1524
  if (sid) {
1224
1525
  signals.setOverrideSessionId(sid);
1225
1526
  api.kv.set(`${KV_PREFIX}.session`, sid);
1226
- api.ui.toast({ message: (langZH() ? "已切换至子代理: " : "Showing sub-agent: ") + sid.slice(0, 24) + "\u2026" });
1527
+ api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) });
1227
1528
  }
1228
1529
  dialog?.clear();
1229
1530
  }, onCancel: () => dialog?.clear() })));
@@ -1236,9 +1537,10 @@ const tui = async (api) => {
1236
1537
  description: "Return to main session stats",
1237
1538
  slash: { name: "cache-session-back" },
1238
1539
  onSelect: (dialog) => {
1540
+ const t = createT(() => langCode());
1239
1541
  signals.setOverrideSessionId(undefined);
1240
1542
  api.kv.set(`${KV_PREFIX}.session`, "");
1241
- api.ui.toast({ message: langZH() ? "已切回主会话" : "Switched to main session" });
1543
+ api.ui.toast({ message: t("backToMain") });
1242
1544
  dialog?.clear();
1243
1545
  },
1244
1546
  },