opencode-visual-cache 1.5.0 → 1.6.1

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
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/soli
2
2
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js";
3
3
  import { PLUGIN_VERSION } from "./_version";
4
4
  import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider } from "./balance-providers";
5
+ import { LANG_META, createT, detectLang } from "./i18n";
5
6
  // ── terminal-width helpers ────────────────────────────────────────
6
7
  // CJK characters occupy 2 terminal columns; padEnd/padStart count
7
8
  // string length (=1 per char), which breaks alignment with mixed text.
@@ -52,97 +53,13 @@ function truncateVisual(s, maxCols) {
52
53
  }
53
54
  return result;
54
55
  }
55
- // ── language override (env: CACHE_TUI_LANG) ──
56
- const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined;
57
56
  // ── language ──────────────────────────────────────────────────────
58
- const LANG_ZH = DEBUG_LANG
59
- ? DEBUG_LANG === "zh"
60
- : (() => {
61
- try {
62
- return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh");
63
- }
64
- catch {
65
- return false;
66
- }
67
- })();
68
- const ZH_T = {
69
- title: "缓存统计",
70
- hit: "命中率",
71
- totalHit: "总命中:",
72
- read: "缓存读:",
73
- write: "缓存写:",
74
- miss: "未命中:",
75
- out: "输出:",
76
- cost: "费用:",
77
- saved: "累计节省:",
78
- model: "模型:",
79
- provider: "提供商:",
80
- rate: "单价:",
81
- hitFolded: "命中",
82
- inputRate: "输入",
83
- cacheRate: "缓存",
84
- writeRate: "写入",
85
- noData: "等待缓存数据...",
86
- tok: "tok",
87
- distTitle: "估算 Token 分布",
88
- distSys: "系统提示:",
89
- distUser: "用户:",
90
- distAgent: "Agent 指令:",
91
- distTool: "Tool 调用:",
92
- distRes: "Tool 结果:",
93
- distTotal: "总计:",
94
- distOut: "输出:",
95
- secDetail: "明细",
96
- secModel: "模型",
97
- secSkills: "已加载技能",
98
- balTotal: "总余额:",
99
- balNoKey: "未配置 {p} API Key",
100
- balLoading: "查询中...",
101
- balError: "查询失败",
102
- balErr401: "API Key 无效",
103
- balErr403: "余额查询被拒绝",
104
- balErrEmpty: "未获取到余额数据",
105
- balErrTimeout: "查询超时",
106
- };
107
- const EN_T = {
108
- title: "Token Cache",
109
- hit: "Hit",
110
- totalHit: "Total Hit:",
111
- read: "Read:",
112
- write: "Write:",
113
- miss: "Miss:",
114
- out: "Out:",
115
- cost: "Cost:",
116
- saved: "Total Saved:",
117
- model: "Model:",
118
- provider: "Provider:",
119
- rate: "Rate:",
120
- hitFolded: "hit",
121
- inputRate: "in",
122
- cacheRate: "cache",
123
- writeRate: "write",
124
- noData: "Waiting for cache data...",
125
- tok: "tok",
126
- distTitle: "Estimated Token Dist.",
127
- distSys: "System:",
128
- distUser: "User:",
129
- distAgent: "Agent Instr:",
130
- distTool: "Tool Call:",
131
- distRes: "Tool Result:",
132
- distTotal: "Total:",
133
- distOut: "Output:",
134
- secDetail: "Detail",
135
- secModel: "Model",
136
- secSkills: "Loaded Skills",
137
- balTotal: "Total:",
138
- balNoKey: "{p} API Key not set",
139
- balLoading: "Fetching...",
140
- balError: "Fetch failed",
141
- balErr401: "Invalid API Key",
142
- balErr403: "Balance request rejected",
143
- balErrEmpty: "No balance data",
144
- balErrTimeout: "Request timed out",
145
- };
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();
146
63
  // ── color helpers ────────────────────────────────────────────────
147
64
  /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
148
65
  function rgb(raw) {
@@ -201,7 +118,7 @@ function desaturateTo(raw, maxSat, fallback) {
201
118
  * converges to within a fraction of an 8‑bit step, eliminating
202
119
  * colour banding in edge cases.
203
120
  */
204
- // BT.601 luma (perceptual brightness used as the grey anchor)
121
+ // Bt.601 luma (perceptual brightness used as the grey anchor)
205
122
  const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
206
123
  let lo = 0, hi = 1;
207
124
  for (let i = 0; i < 12; i++) {
@@ -356,6 +273,41 @@ function balanceSymbol(currency) {
356
273
  const sym = CURRENCIES[currency];
357
274
  return sym ?? currency + " ";
358
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
+ }
359
311
  const CURRENCIES = {
360
312
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
361
313
  };
@@ -382,9 +334,9 @@ function TokenCachePanel(props) {
382
334
  const [skillsOpen, setSkillsOpen] = createSignal(true);
383
335
  let boxEl;
384
336
  // ── shared signals (de-structured so internal code is unchanged) ──
385
- const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
386
- // ── reactive translation (follows langZH signal) ──
387
- 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());
388
340
  // ── scan session messages reactively ──
389
341
  // SolidJS createMemo re-evaluates whenever the underlying
390
342
  // api.state.session state changes — no event listener needed.
@@ -395,7 +347,7 @@ function TokenCachePanel(props) {
395
347
  // stable until the next successful computation arrives.
396
348
  const [lastDist, setLastDist] = createSignal({
397
349
  system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
398
- output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
350
+ output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0,
399
351
  });
400
352
  const [lastHasDist, setLastHasDist] = createSignal(false);
401
353
  const [dataSignal, setDataSignal] = createSignal({
@@ -403,63 +355,14 @@ function TokenCachePanel(props) {
403
355
  cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
404
356
  hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
405
357
  providerName: "", sessionHitRate: 0,
406
- dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
358
+ dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 },
407
359
  hasDistData: false,
408
360
  skills: [],
409
361
  hasSkills: false,
410
362
  });
411
363
  const [refreshTick, setRefreshTick] = createSignal(0);
412
- // ── balance state + polling ──────────────────────────────────
413
- const [balanceState, setBalanceState] = createSignal({
414
- status: "idle", data: null, lastFetch: 0,
415
- });
416
- // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
417
- let balanceSeq = 0;
418
- // 当前 provider 显示名
364
+ // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
419
365
  const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name);
420
- const pollBalance = async () => {
421
- const provider = getBalanceProvider(balanceProviderId());
422
- // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
423
- const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "")
424
- || findOpencodeKey(props.api, provider);
425
- if (!key) {
426
- setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
427
- return;
428
- }
429
- const now = Date.now();
430
- const prev = balanceState();
431
- // key 已更换(重新输入)→ 强制重新查询,绕过缓存
432
- if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS)
433
- return; // cache still fresh
434
- const seq = ++balanceSeq;
435
- setBalanceState({ ...prev, status: "loading", error: undefined, key });
436
- const controller = new AbortController();
437
- let timedOut = false;
438
- const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 10_000);
439
- try {
440
- const data = await provider.fetchBalance(key, controller.signal);
441
- clearTimeout(timer);
442
- if (seq !== balanceSeq)
443
- return; // 已被更新的请求取代,丢弃过期结果
444
- setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key });
445
- }
446
- catch (err) {
447
- clearTimeout(timer);
448
- if (seq !== balanceSeq)
449
- return;
450
- const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "");
451
- // 失败时清空旧数据,避免显示过期余额
452
- setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key });
453
- }
454
- };
455
- // Re-fetch when the API key is (re)configured via /cache-balance-key.
456
- // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
457
- // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
458
- // 形成无限循环(每次重跑都发起新的 fetch 请求)。
459
- createEffect(() => {
460
- void balanceRefresh();
461
- untrack(() => { void pollBalance(); });
462
- });
463
366
  // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
464
367
  // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
465
368
  // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
@@ -488,9 +391,16 @@ function TokenCachePanel(props) {
488
391
  if (!pid)
489
392
  return;
490
393
  const hit = matchBalanceProvider(pid);
491
- if (hit && hit.id !== balanceProviderId()) {
492
- setBalanceProviderId(hit.id);
493
- props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
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);
494
404
  }
495
405
  });
496
406
  // ── auto-clear override when the user navigates to a different main session ──
@@ -530,19 +440,19 @@ function TokenCachePanel(props) {
530
440
  for (const msg of msgs) {
531
441
  if (msg.role !== "assistant")
532
442
  continue;
533
- const t = msg.tokens;
534
- if (!t)
443
+ const tok = msg.tokens;
444
+ if (!tok)
535
445
  continue;
536
- 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);
537
447
  if (mit > 0) {
538
448
  prevMsgHitRate = lastMsgHitRate;
539
449
  lastMsgHitRate = (mrt / mit) * 100;
540
450
  }
541
451
  if (fallbackTokens) {
542
- input += num(t.input);
543
- read += num(t.cache?.read);
544
- write += num(t.cache?.write);
545
- 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);
546
456
  }
547
457
  if (fallbackCost) {
548
458
  cost += num(msg.cost);
@@ -568,13 +478,14 @@ function TokenCachePanel(props) {
568
478
  break;
569
479
  }
570
480
  const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0;
571
- 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;
572
483
  const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
573
484
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0;
574
485
  const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || "";
575
486
  // untrack 只包裹已知触发死锁的 API
576
487
  const distData = untrack(() => {
577
- let dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 };
488
+ let dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 };
578
489
  let hasDistData = false;
579
490
  const loadedSkills = new Map();
580
491
  try {
@@ -609,6 +520,7 @@ function TokenCachePanel(props) {
609
520
  else if (msg.role === "assistant") {
610
521
  const am = msg;
611
522
  dist.output += num(am.tokens?.output);
523
+ dist.reasoning += num(am.tokens?.reasoning);
612
524
  let parts = [];
613
525
  try {
614
526
  parts = props.api.state.part(msg.id);
@@ -624,6 +536,13 @@ function TokenCachePanel(props) {
624
536
  catch { }
625
537
  if (rawInput)
626
538
  dist.toolCall += estimateTokens(rawInput);
539
+ // 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part)
540
+ if (tp.tool === "task" && tp.state?.input) {
541
+ const ti = tp.state.input;
542
+ const prompt = typeof ti.prompt === "string" ? ti.prompt : "";
543
+ const desc = typeof ti.description === "string" ? ti.description : "";
544
+ dist.agent += estimateTokens(prompt || desc);
545
+ }
627
546
  if (tp.state.status === "completed") {
628
547
  const c = tp.state;
629
548
  if (c.output)
@@ -654,8 +573,6 @@ function TokenCachePanel(props) {
654
573
  }
655
574
  }
656
575
  }
657
- else if (p.type === "reasoning")
658
- dist.agent += estimateTokens(p.text);
659
576
  else if (p.type === "subtask") {
660
577
  const sub = p;
661
578
  dist.agent += estimateTokens(sub.prompt || sub.description || "");
@@ -667,16 +584,45 @@ function TokenCachePanel(props) {
667
584
  for (let i = msgs.length - 1; i >= 0; i--) {
668
585
  if (msgs[i].role !== "assistant")
669
586
  continue;
670
- const t = msgs[i].tokens;
671
- if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) {
587
+ const tok = msgs[i].tokens;
588
+ if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) {
672
589
  lastAssMsg = msgs[i];
673
590
  break;
674
591
  }
675
592
  }
676
- // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
677
- dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read);
593
+ // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
594
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write);
678
595
  dist.apiOutput = num(lastAssMsg?.tokens?.output);
679
- hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0;
596
+ // 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。
597
+ // opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish),
598
+ // 故按 parentID 链聚合统计,而非单条消息。
599
+ if (lastAssMsg) {
600
+ const roundParent = lastAssMsg.parentID;
601
+ let lastCost;
602
+ for (let i = msgs.length - 1; i >= 0; i--) {
603
+ const m = msgs[i];
604
+ if (m.role !== "assistant")
605
+ continue;
606
+ if (m.parentID !== roundParent)
607
+ break;
608
+ let parts = [];
609
+ try {
610
+ parts = props.api.state.part(m.id);
611
+ }
612
+ catch { }
613
+ for (const p of parts) {
614
+ if (p.type !== "step-finish")
615
+ continue;
616
+ dist.stepCount++;
617
+ const sc = p.cost;
618
+ if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc))
619
+ lastCost = sc;
620
+ }
621
+ }
622
+ if (lastCost !== undefined)
623
+ dist.stepCost = lastCost;
624
+ }
625
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0;
680
626
  }
681
627
  catch { }
682
628
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist();
@@ -750,6 +696,7 @@ function TokenCachePanel(props) {
750
696
  const savedProvider = props.api.kv.get(`${KV_PREFIX}.balance.provider`);
751
697
  if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
752
698
  setBalanceProviderId(savedProvider);
699
+ setBalanceUnsupported(false);
753
700
  }
754
701
  // Restore auto-switch (default on)
755
702
  const savedAuto = props.api.kv.get(`${KV_PREFIX}.balance.auto`);
@@ -772,11 +719,6 @@ function TokenCachePanel(props) {
772
719
  setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)));
773
720
  const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
774
721
  setBorderVisible(bv !== false);
775
- // Restore language preference
776
- const savedLang = props.api.kv.get(`${KV_PREFIX}.lang`);
777
- if (savedLang === "zh" || savedLang === "en") {
778
- setLangZH(savedLang === "zh");
779
- }
780
722
  // Restore distribution snapshot so the token distribution block
781
723
  // doesn't blank out while api.state.part() re-hydrates.
782
724
  const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`);
@@ -827,8 +769,7 @@ function TokenCachePanel(props) {
827
769
  const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1); });
828
770
  const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1); });
829
771
  setRefreshTick(v => v + 1);
830
- const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS);
831
- onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession(); });
772
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession(); });
832
773
  });
833
774
  // ── colours ──
834
775
  // Pull from the current theme, auto-desaturate if too punchy,
@@ -858,11 +799,14 @@ function TokenCachePanel(props) {
858
799
  const gutter = createMemo(() => borderVisible() ? 6 : 0);
859
800
  const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())));
860
801
  function trendLabel(t) {
861
- return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "");
802
+ // |t| < 0.05 视为无变化:避免显示 "0.0%" 的矛盾(箭头存在但数值截断为零)
803
+ if (Math.abs(t) < 0.05)
804
+ return "-";
805
+ return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%";
862
806
  }
863
807
  const barW = createMemo(() => {
864
808
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
865
- const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
809
+ const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
866
810
  return Math.max(3, panelWidth() - overhead);
867
811
  });
868
812
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
@@ -888,49 +832,184 @@ function TokenCachePanel(props) {
888
832
  // boxEl.width may be undefined before the first measurement — guard with 0
889
833
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
890
834
  setPanelWidth((prev) => (prev === w ? prev : w));
891
- }, 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: (() => {
892
- const prefix = " \u21b3 " + (langZH() ? "\u5B50\u4EE3\u7406: " : "Sub: ");
835
+ }, 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: (() => {
836
+ const prefix = " \u21b3 " + t("subPrefix");
893
837
  const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix));
894
838
  return (_jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: prefix }), _jsx("span", { style: { fg: pal().text }, children: truncateVisual(props.signals.overrideSessionId(), maxSidW) })] }));
895
- })() }), _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) => {
896
- const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok);
839
+ })() }), _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().dist.stepCount >= 2, children: _jsx("text", { fg: pal().muted, children: justify(t("stepsCount", { n: data().dist.stepCount }), fmtCost(data().dist.stepCost, currencySymbol(), exchangeRate())) }) }), _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(Show, { when: data().dist.reasoning > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distReason"), fmt(data().dist.reasoning), 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) => {
840
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"));
897
841
  const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
898
842
  const label = truncateVisual(sk.name, maxLabel);
899
- return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t().tok) }));
900
- }) })] }) }), _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.replace("{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: (() => {
901
- const code = balanceState().error;
902
- if (code === "401")
903
- return t().balErr401;
904
- if (code === "403")
905
- return t().balErr403;
906
- if (code === "EMPTY")
907
- return t().balErrEmpty;
908
- if (code === "TIMEOUT")
909
- return t().balErrTimeout;
910
- return t().balError + (code ? ` (${code})` : "");
911
- })() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: (() => {
912
- const list = balanceState().data;
913
- const pref = balanceCurrency();
914
- // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示
915
- const native = pref ? list.find(x => x.currency === pref) : undefined;
916
- if (native) {
917
- return (_jsx("text", { fg: pal().text, children: justify(t().balTotal, balanceSymbol(native.currency) + native.total) }));
918
- }
919
- // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算
920
- const base = list[0];
921
- const baseAmt = parseFloat(base.total);
922
- const converted = Number.isFinite(baseAmt)
923
- ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency)
924
- : baseAmt;
925
- const shown = pref && base.currency !== pref
926
- ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
927
- : base.total;
928
- return (_jsx("text", { fg: pal().text, children: justify(t().balTotal, balanceSymbol(pref || base.currency) + shown) }));
929
- })() })] })] })] })] }));
843
+ return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t("tok")) }));
844
+ }) })] }) }), _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: (() => {
845
+ const code = balanceState().error;
846
+ if (code === "401")
847
+ return t("balErr401");
848
+ if (code === "403")
849
+ return t("balErr403");
850
+ if (code === "EMPTY")
851
+ return t("balErrEmpty");
852
+ if (code === "TIMEOUT")
853
+ return t("balErrTimeout");
854
+ return t("balError") + (code ? ` (${code})` : "");
855
+ })() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: _jsx("text", { fg: pal().text, children: justify(t("balTotal"), formatBalanceText(balanceState().data, balanceCurrency(), exchangeRate())) }) })] })] })] })] })] }));
930
856
  }
931
857
  // ---------------------------------------------------------------------------
932
858
  // Plugin entry
933
859
  // ---------------------------------------------------------------------------
860
+ /**
861
+ * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
862
+ * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
863
+ * 三合一信息与路径同行显示在中间位置。
864
+ */
865
+ function BottomStatusBar(props) {
866
+ const KV_PREFIX = "cache_panel";
867
+ const t = createT(() => props.signals.langCode());
868
+ const sid = props.sessionId;
869
+ // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ──
870
+ const stats = createMemo(() => {
871
+ const id = sid;
872
+ if (!id)
873
+ return null;
874
+ const msgs = props.api.state.session.messages(id);
875
+ const session = typeof props.api.state.session.get === "function"
876
+ ? props.api.state.session.get(id)
877
+ : undefined;
878
+ let input = session?.tokens?.input ?? 0;
879
+ let read = session?.tokens?.cache?.read ?? 0;
880
+ let write = session?.tokens?.cache?.write ?? 0;
881
+ // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
882
+ if (session?.tokens == null) {
883
+ for (const m of msgs) {
884
+ if (m.role !== "assistant")
885
+ continue;
886
+ const tk = m.tokens;
887
+ if (!tk)
888
+ continue;
889
+ input += num(tk.input);
890
+ read += num(tk.cache?.read);
891
+ write += num(tk.cache?.write);
892
+ }
893
+ }
894
+ // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
895
+ // 分母含缓存写(业界口径:read / (input+read+write))
896
+ let hitRate = -1, prevHitRate = -1;
897
+ for (let i = msgs.length - 1; i >= 0; i--) {
898
+ const m = msgs[i];
899
+ if (m.role !== "assistant")
900
+ continue;
901
+ const tk = m.tokens;
902
+ if (!tk)
903
+ continue;
904
+ const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write);
905
+ const mrt = num(tk.cache?.read);
906
+ if (mit <= 0)
907
+ continue;
908
+ const rate = (mrt / mit) * 100;
909
+ if (hitRate < 0) {
910
+ hitRate = rate;
911
+ continue;
912
+ }
913
+ prevHitRate = rate;
914
+ break;
915
+ }
916
+ return { hitRate, prevHitRate, input, read, write };
917
+ });
918
+ // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
919
+ // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
920
+ createEffect(() => {
921
+ if (!props.signals.autoBalance())
922
+ return;
923
+ const id = sid;
924
+ if (!id)
925
+ return;
926
+ const msgs = props.api.state.session.messages(id);
927
+ let pid = "";
928
+ for (let i = msgs.length - 1; i >= 0; i--) {
929
+ const m = msgs[i];
930
+ if (m.role === "assistant" && m.providerID) {
931
+ pid = m.providerID;
932
+ break;
933
+ }
934
+ }
935
+ if (!pid) {
936
+ try {
937
+ pid = props.api.state.session.get(id)?.model?.providerID ?? "";
938
+ }
939
+ catch { }
940
+ }
941
+ if (!pid)
942
+ return;
943
+ const hit = matchBalanceProvider(pid);
944
+ if (hit) {
945
+ props.signals.setBalanceUnsupported(false);
946
+ if (hit.id !== props.signals.balanceProviderId()) {
947
+ props.signals.setBalanceProviderId(hit.id);
948
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
949
+ }
950
+ }
951
+ else {
952
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
953
+ props.signals.setBalanceUnsupported(true);
954
+ }
955
+ });
956
+ // ── 主题色(与侧边栏同口径)──
957
+ const pal = createMemo(() => {
958
+ const th = props.api.theme.current;
959
+ const sat = (k, fb) => desaturateTo(th[k], MAX_SAT, fb);
960
+ return {
961
+ text: sat("text", FALLBACK.text),
962
+ muted: sat("textMuted", FALLBACK.muted),
963
+ success: sat("success", FALLBACK.success),
964
+ warning: sat("warning", FALLBACK.warning),
965
+ error: sat("error", FALLBACK.error),
966
+ };
967
+ });
968
+ const hitColor = createMemo(() => {
969
+ const r = stats()?.hitRate ?? -1;
970
+ if (r >= 85)
971
+ return pal().success;
972
+ if (r >= 70)
973
+ return pal().warning;
974
+ return pal().error;
975
+ });
976
+ // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
977
+ const trend = createMemo(() => {
978
+ const s = stats();
979
+ if (!s || s.prevHitRate < 0 || s.hitRate < 0)
980
+ return null;
981
+ const d = s.hitRate - s.prevHitRate;
982
+ return Math.abs(d) < 0.05 ? null : d;
983
+ });
984
+ const balanceText = createMemo(() => {
985
+ const s = props.signals.balanceState();
986
+ if (s.status === "ok" && s.data)
987
+ return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate());
988
+ if (s.status === "loading")
989
+ return "\u2026";
990
+ if (s.status === "error")
991
+ return "\u26a0";
992
+ return "-";
993
+ });
994
+ // 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
995
+ const directory = createMemo(() => {
996
+ try {
997
+ return props.api.state.path.directory;
998
+ }
999
+ catch {
1000
+ return "";
1001
+ }
1002
+ });
1003
+ // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
1004
+ onMount(() => {
1005
+ try {
1006
+ const v = props.api.kv.get(`${KV_PREFIX}.section.bottom`, true);
1007
+ props.signals.setSectionBottom(v !== false);
1008
+ }
1009
+ catch { }
1010
+ });
1011
+ 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 " })] })] }) }));
1012
+ }
934
1013
  function createSidebarSlot(api, signals) {
935
1014
  let lastSlotSid = "";
936
1015
  return {
@@ -959,50 +1038,144 @@ const tui = async (api) => {
959
1038
  const [sectionDist, setSectionDist] = createSignal(true);
960
1039
  const [sectionSkills, setSectionSkills] = createSignal(true);
961
1040
  const [sectionBalance, setSectionBalance] = createSignal(true);
1041
+ const [sectionBottom, setSectionBottom] = createSignal(true);
962
1042
  const [balanceRefresh, setBalanceRefresh] = createSignal(0);
963
1043
  const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek");
964
1044
  const [autoBalance, setAutoBalance] = createSignal(true);
1045
+ const [balanceUnsupported, setBalanceUnsupported] = createSignal(false);
965
1046
  const [balanceCurrency, setBalanceCurrency] = createSignal("");
966
1047
  const [borderVisible, setBorderVisible] = createSignal(true);
967
- const [langZH, setLangZH] = createSignal(LANG_ZH);
1048
+ const [langCode, setLangCode] = createSignal(INIT_LANG);
968
1049
  const [overrideSessionId, setOverrideSessionId] = createSignal(undefined);
1050
+ // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1051
+ // 避免重复请求导致两处余额不一致 ──
1052
+ const [balanceState, setBalanceState] = createSignal({
1053
+ status: "idle", data: null, lastFetch: 0,
1054
+ });
1055
+ // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
1056
+ let balanceSeq = 0;
969
1057
  const signals = {
970
1058
  currencySymbol, setCurrencySymbol,
971
1059
  exchangeRate, setExchangeRate,
972
- langZH, setLangZH,
1060
+ langCode, setLangCode,
973
1061
  sectionDetail, setSectionDetail,
974
1062
  sectionModel, setSectionModel,
975
1063
  sectionDist, setSectionDist,
976
1064
  sectionSkills, setSectionSkills,
977
1065
  sectionBalance, setSectionBalance,
1066
+ sectionBottom, setSectionBottom,
978
1067
  balanceRefresh, setBalanceRefresh,
979
1068
  balanceProviderId, setBalanceProviderId,
980
1069
  autoBalance, setAutoBalance,
1070
+ balanceUnsupported, setBalanceUnsupported,
1071
+ balanceState,
981
1072
  balanceCurrency, setBalanceCurrency,
982
1073
  borderVisible, setBorderVisible,
983
1074
  overrideSessionId, setOverrideSessionId,
984
1075
  };
985
1076
  api.slots.register(createSidebarSlot(api, signals));
1077
+ // 输入框 hint 行(session_prompt slot,replace 模式):
1078
+ // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
1079
+ // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
1080
+ api.slots.register({
1081
+ order: 55,
1082
+ slots: {
1083
+ session_prompt(_ctx, input) {
1084
+ 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 }) }));
1085
+ },
1086
+ },
1087
+ });
986
1088
  // ── slash commands for runtime config ──
987
1089
  const KV_PREFIX = "cache_panel";
1090
+ // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
1091
+ const restoreLang = () => {
1092
+ try {
1093
+ const saved = api.kv.get(`${KV_PREFIX}.lang`);
1094
+ if (saved && LANG_META.some((m) => m.code === saved))
1095
+ setLangCode(saved);
1096
+ }
1097
+ catch { }
1098
+ };
1099
+ if (api.kv.ready) {
1100
+ restoreLang();
1101
+ }
1102
+ else {
1103
+ const langTimer = setInterval(() => {
1104
+ if (api.kv.ready) {
1105
+ clearInterval(langTimer);
1106
+ restoreLang();
1107
+ }
1108
+ }, 10);
1109
+ api.lifecycle.onDispose(() => clearInterval(langTimer));
1110
+ }
1111
+ const pollBalance = async () => {
1112
+ const provider = getBalanceProvider(balanceProviderId());
1113
+ // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
1114
+ const key = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1115
+ || findOpencodeKey(api, provider);
1116
+ if (balanceUnsupported()) {
1117
+ setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
1118
+ return;
1119
+ }
1120
+ if (!key) {
1121
+ setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
1122
+ return;
1123
+ }
1124
+ const now = Date.now();
1125
+ const prev = balanceState();
1126
+ // key 已更换(重新输入)→ 强制重新查询,绕过缓存
1127
+ if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS)
1128
+ return; // cache still fresh
1129
+ const seq = ++balanceSeq;
1130
+ setBalanceState({ ...prev, status: "loading", error: undefined, key });
1131
+ const controller = new AbortController();
1132
+ let timedOut = false;
1133
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 10_000);
1134
+ try {
1135
+ const data = await provider.fetchBalance(key, controller.signal);
1136
+ clearTimeout(timer);
1137
+ if (seq !== balanceSeq)
1138
+ return; // 已被更新的请求取代,丢弃过期结果
1139
+ setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key });
1140
+ }
1141
+ catch (err) {
1142
+ clearTimeout(timer);
1143
+ if (seq !== balanceSeq)
1144
+ return;
1145
+ const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "");
1146
+ // 失败时清空旧数据,避免显示过期余额
1147
+ setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key });
1148
+ }
1149
+ };
1150
+ // Re-fetch when the API key is (re)configured via /cache-balance-key.
1151
+ // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
1152
+ // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
1153
+ // 形成无限循环(每次重跑都发起新的 fetch 请求)。
1154
+ createEffect(() => {
1155
+ void balanceRefresh();
1156
+ untrack(() => { void pollBalance(); });
1157
+ });
1158
+ // 定时轮询(5 分钟);随插件生命周期清理
1159
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS);
1160
+ api.lifecycle.onDispose(() => clearInterval(balanceTimer));
988
1161
  /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
989
1162
  const providerOptionTitle = (p, current) => {
990
- const zh = langZH();
1163
+ const t = createT(() => langCode());
991
1164
  const hasManual = !!api.kv.get(`${KV_PREFIX}.balance.${p.id}.key`, "");
992
1165
  const hasAuto = !hasManual && !!findOpencodeKey(api, p);
993
1166
  const mark = hasManual
994
- ? (zh ? "(用户 key)" : " (user key)")
1167
+ ? t("keyUser")
995
1168
  : hasAuto
996
- ? (zh ? "(OpenCode)" : " (OpenCode)")
997
- : (zh ? "(未配置)" : " (not set)");
1169
+ ? t("keyOpenCode")
1170
+ : t("keyNotSet");
998
1171
  return p.name + mark + (current && p.id === current ? " *" : "");
999
1172
  };
1000
1173
  /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
1001
1174
  const promptBalanceKey = (dialog, provider) => {
1002
- const zh = langZH();
1175
+ const t = createT(() => langCode());
1003
1176
  const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
1004
1177
  const masked = maskKey(current);
1005
- dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: provider.name, description: () => _jsx("text", { children: zh ? `输入 ${provider.name} API Key 以显示账户余额(留空清除)` : `Enter your ${provider.name} API key to show account balance (leave empty to clear)` }), placeholder: provider.keyPlaceholder ?? "sk-...", value: masked, onConfirm: (val) => {
1178
+ 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) => {
1006
1179
  const input = val.trim();
1007
1180
  let key;
1008
1181
  if (input === "") {
@@ -1017,10 +1190,10 @@ const tui = async (api) => {
1017
1190
  api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key);
1018
1191
  setBalanceRefresh(v => v + 1);
1019
1192
  if (key) {
1020
- api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." });
1193
+ api.ui.toast({ message: t("keySaved") });
1021
1194
  }
1022
1195
  else {
1023
- api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" });
1196
+ api.ui.toast({ message: t("keyCleared") });
1024
1197
  }
1025
1198
  dialog?.clear();
1026
1199
  }, onCancel: () => dialog?.clear() })));
@@ -1036,6 +1209,7 @@ const tui = async (api) => {
1036
1209
  title: `${code} (${sym})`,
1037
1210
  value: code,
1038
1211
  })), onSelect: (opt) => {
1212
+ const t = createT(() => langCode());
1039
1213
  const sym = CURRENCIES[opt.value] ?? "$";
1040
1214
  const defRate = DEFAULT_RATES[opt.value] ?? 1;
1041
1215
  api.kv.set(`${KV_PREFIX}.currency`, sym);
@@ -1045,7 +1219,7 @@ const tui = async (api) => {
1045
1219
  signals.setBalanceCurrency(opt.value);
1046
1220
  signals.setCurrencySymbol(sym);
1047
1221
  signals.setExchangeRate(defRate);
1048
- api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` });
1222
+ api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) });
1049
1223
  dialog?.clear();
1050
1224
  } })));
1051
1225
  },
@@ -1057,11 +1231,12 @@ const tui = async (api) => {
1057
1231
  slash: { name: "cache-rate" },
1058
1232
  onSelect: (dialog) => {
1059
1233
  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) => {
1234
+ const t = createT(() => langCode());
1060
1235
  const n = parseFloat(val);
1061
1236
  if (n > 0) {
1062
1237
  api.kv.set(`${KV_PREFIX}.rate`, n);
1063
1238
  signals.setExchangeRate(n);
1064
- api.ui.toast({ message: `Exchange rate set to ${n}` });
1239
+ api.ui.toast({ message: t("rateSet", { r: n }) });
1065
1240
  }
1066
1241
  dialog?.clear();
1067
1242
  } })));
@@ -1073,25 +1248,38 @@ const tui = async (api) => {
1073
1248
  description: "Show or hide a sidebar section",
1074
1249
  slash: { name: "cache-section" },
1075
1250
  onSelect: (dialog) => {
1251
+ const t = createT(() => langCode());
1076
1252
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
1077
1253
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
1078
1254
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
1079
1255
  const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
1080
1256
  const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
1257
+ const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true));
1081
1258
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
1082
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
1083
- { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
1084
- { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
1085
- { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1086
- { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
1087
- { title: `Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" },
1088
- { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1259
+ const labels = {
1260
+ detail: t("secDetail"),
1261
+ model: t("secModel"),
1262
+ dist: t("distTitle"),
1263
+ skills: t("secSkills"),
1264
+ balance: t("secBalance"),
1265
+ bottom: t("secBottom"),
1266
+ border: t("secBorder"),
1267
+ };
1268
+ const optTitle = (label, on) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`;
1269
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("secToggle"), options: [
1270
+ { title: optTitle(labels.detail, detailOn), value: "detail" },
1271
+ { title: optTitle(labels.model, modelOn), value: "model" },
1272
+ { title: optTitle(labels.dist, distOn), value: "dist" },
1273
+ { title: optTitle(labels.skills, skillsOn), value: "skills" },
1274
+ { title: optTitle(labels.balance, balanceOn), value: "balance" },
1275
+ { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
1276
+ { title: optTitle(labels.border, borderOn), value: "border" },
1089
1277
  ], onSelect: (opt) => {
1090
1278
  if (opt.value === "border") {
1091
1279
  const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
1092
1280
  api.kv.set(`${KV_PREFIX}.border`, !cur);
1093
1281
  signals.setBorderVisible(!cur);
1094
- api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` });
1282
+ api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") });
1095
1283
  }
1096
1284
  else {
1097
1285
  const key = `${KV_PREFIX}.section.${opt.value}`;
@@ -1107,7 +1295,10 @@ const tui = async (api) => {
1107
1295
  signals.setSectionSkills(!cur);
1108
1296
  if (opt.value === "balance")
1109
1297
  signals.setSectionBalance(!cur);
1110
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
1298
+ if (opt.value === "bottom")
1299
+ signals.setSectionBottom(!cur);
1300
+ const name = labels[opt.value] ?? opt.value;
1301
+ api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) });
1111
1302
  }
1112
1303
  dialog?.clear();
1113
1304
  } })));
@@ -1119,6 +1310,7 @@ const tui = async (api) => {
1119
1310
  description: "Display the current plugin configuration",
1120
1311
  slash: { name: "cache-config" },
1121
1312
  onSelect: (dialog) => {
1313
+ const t = createT(() => langCode());
1122
1314
  const sym = api.kv.get(`${KV_PREFIX}.currency`) ?? "$";
1123
1315
  const rate = api.kv.get(`${KV_PREFIX}.rate`) ?? 1;
1124
1316
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
@@ -1126,9 +1318,16 @@ const tui = async (api) => {
1126
1318
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
1127
1319
  const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
1128
1320
  const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
1321
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true));
1322
+ const on = (v) => v ? "ON" : "OFF";
1129
1323
  api.ui.toast({
1130
- title: "Cache Panel Config",
1131
- 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"}`,
1324
+ title: t("panelConfigTitle"),
1325
+ message: t("panelConfigMsg", {
1326
+ c: sym, r: rate,
1327
+ d: on(detail), m: on(model),
1328
+ t: on(dist), k: on(skills),
1329
+ b: on(balance), f: on(bottom),
1330
+ }),
1132
1331
  duration: 8000,
1133
1332
  });
1134
1333
  dialog?.clear();
@@ -1140,15 +1339,16 @@ const tui = async (api) => {
1140
1339
  description: "Switch between Chinese and English display",
1141
1340
  slash: { name: "cache-lang" },
1142
1341
  onSelect: (dialog) => {
1143
- const cur = langZH();
1144
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Display Language", options: [
1145
- { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1146
- { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1147
- ], onSelect: (opt) => {
1148
- const zh = opt.value === "zh";
1149
- api.kv.set(`${KV_PREFIX}.lang`, opt.value);
1150
- setLangZH(zh);
1151
- api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" });
1342
+ const t = createT(() => langCode());
1343
+ const cur = langCode();
1344
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("langTitle"), options: LANG_META.map((m) => ({
1345
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
1346
+ value: m.code,
1347
+ })), onSelect: (opt) => {
1348
+ const code = opt.value;
1349
+ api.kv.set(`${KV_PREFIX}.lang`, code);
1350
+ setLangCode(code);
1351
+ api.ui.toast({ message: t("langSwitched") });
1152
1352
  dialog?.clear();
1153
1353
  } })));
1154
1354
  },
@@ -1159,13 +1359,11 @@ const tui = async (api) => {
1159
1359
  description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
1160
1360
  slash: { name: "cache-balance" },
1161
1361
  onSelect: (dialog) => {
1162
- const zh = langZH();
1362
+ const t = createT(() => langCode());
1163
1363
  const current = signals.balanceProviderId();
1164
1364
  const auto = signals.autoBalance();
1165
- const autoLabel = auto
1166
- ? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]")
1167
- : (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]");
1168
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: zh ? "余额提供商 / 自动切换" : "Balance Provider / Auto-switch", options: [
1365
+ const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`;
1366
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("balProvTitle"), options: [
1169
1367
  {
1170
1368
  title: autoLabel,
1171
1369
  value: "__auto__",
@@ -1179,7 +1377,7 @@ const tui = async (api) => {
1179
1377
  const next = !auto;
1180
1378
  api.kv.set(`${KV_PREFIX}.balance.auto`, next);
1181
1379
  signals.setAutoBalance(next);
1182
- api.ui.toast({ message: zh ? `自动切换余额提供商: ${next ? "" : ""}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` });
1380
+ api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") });
1183
1381
  dialog?.clear();
1184
1382
  }
1185
1383
  else {
@@ -1189,6 +1387,7 @@ const tui = async (api) => {
1189
1387
  api.kv.set(`${KV_PREFIX}.balance.auto`, false);
1190
1388
  signals.setBalanceProviderId(provider.id);
1191
1389
  signals.setAutoBalance(false);
1390
+ signals.setBalanceUnsupported(false);
1192
1391
  // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
1193
1392
  signals.setBalanceRefresh(signals.balanceRefresh() + 1);
1194
1393
  const hasKey = !!api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
@@ -1197,7 +1396,7 @@ const tui = async (api) => {
1197
1396
  promptBalanceKey(dialog, provider);
1198
1397
  }
1199
1398
  else {
1200
- api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动切换已关闭)` : `Balance provider: ${provider.name} (auto-switch off)` });
1399
+ api.ui.toast({ message: t("providerManual", { p: provider.name }) });
1201
1400
  dialog?.clear();
1202
1401
  }
1203
1402
  }
@@ -1210,9 +1409,9 @@ const tui = async (api) => {
1210
1409
  description: "Select a provider and set its API key for balance display",
1211
1410
  slash: { name: "cache-balance-key" },
1212
1411
  onSelect: (dialog) => {
1213
- const zh = langZH();
1412
+ const t = createT(() => langCode());
1214
1413
  // 步骤 1:选择 provider
1215
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: zh ? "选择余额提供商" : "Select Balance Provider", options: balanceProviders.map((p) => ({
1414
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("balSelectTitle"), options: balanceProviders.map((p) => ({
1216
1415
  title: providerOptionTitle(p),
1217
1416
  value: p.id,
1218
1417
  })), onSelect: (opt) => {
@@ -1235,9 +1434,10 @@ const tui = async (api) => {
1235
1434
  description: "Dump all tool parts found in the current session for skill detection debugging",
1236
1435
  slash: { name: "cache-debug-skills" },
1237
1436
  onSelect: () => {
1437
+ const t = createT(() => langCode());
1238
1438
  const rt = api.route.current;
1239
1439
  if (rt.name !== "session" || !rt.params) {
1240
- api.ui.toast({ message: "Please run this command inside a session", variant: "warning" });
1440
+ api.ui.toast({ message: t("runInSession"), variant: "warning" });
1241
1441
  return;
1242
1442
  }
1243
1443
  const sid = String(rt.params.sessionID);
@@ -1324,7 +1524,7 @@ const tui = async (api) => {
1324
1524
  return false; seen.add(c.value); return true; });
1325
1525
  if (unique.length > 0) {
1326
1526
  // ── 有子代理 → DialogSelect 列表选择 ──
1327
- const zh = langZH();
1527
+ const t = createT(() => langCode());
1328
1528
  const currentSid = signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "");
1329
1529
  const options = unique.map((c, i) => ({
1330
1530
  title: `${i + 1}. ${c.title}`,
@@ -1333,33 +1533,33 @@ const tui = async (api) => {
1333
1533
  }));
1334
1534
  // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
1335
1535
  const backValue = "__main__";
1336
- const backTitle = `\u2500 ${zh ? "\u56DE\u5230\u4E3B\u4F1A\u8BDD" : "Back to Main"}`;
1536
+ const backTitle = `\u2500 ${t("backToMainTitle")}`;
1337
1537
  options.unshift({ title: backTitle, value: backValue, description: "" });
1338
1538
  options.push({ title: backTitle, value: backValue, description: "" });
1339
1539
  const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1;
1340
- dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: zh ? "选择子代理" : "Select Sub-Agent", options: options, current: currentIdx >= 0 ? options[currentIdx].value : undefined, onSelect: (opt) => {
1540
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: t("subSelectTitle"), options: options, current: currentIdx >= 0 ? options[currentIdx].value : undefined, onSelect: (opt) => {
1341
1541
  if (opt.value === backValue) {
1342
1542
  signals.setOverrideSessionId(undefined);
1343
1543
  api.kv.set(`${KV_PREFIX}.session`, "");
1344
- api.ui.toast({ message: zh ? "已切回主会话" : "Switched to main session" });
1544
+ api.ui.toast({ message: t("backToMain") });
1345
1545
  }
1346
1546
  else {
1347
1547
  signals.setOverrideSessionId(opt.value);
1348
1548
  api.kv.set(`${KV_PREFIX}.session`, opt.value);
1349
- api.ui.toast({ message: (zh ? "已切换至子代理: " : "Showing sub-agent: ") + opt.value.slice(0, 24) + "\u2026" });
1549
+ api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) });
1350
1550
  }
1351
1551
  dialog?.clear();
1352
1552
  } })));
1353
1553
  }
1354
1554
  else {
1355
1555
  // ── 无子代理 → DialogPrompt 手动粘贴 ──
1356
- const zh = langZH();
1357
- 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) => {
1556
+ const t = createT(() => langCode());
1557
+ 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) => {
1358
1558
  const sid = val.trim();
1359
1559
  if (sid) {
1360
1560
  signals.setOverrideSessionId(sid);
1361
1561
  api.kv.set(`${KV_PREFIX}.session`, sid);
1362
- api.ui.toast({ message: (langZH() ? "已切换至子代理: " : "Showing sub-agent: ") + sid.slice(0, 24) + "\u2026" });
1562
+ api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) });
1363
1563
  }
1364
1564
  dialog?.clear();
1365
1565
  }, onCancel: () => dialog?.clear() })));
@@ -1372,9 +1572,10 @@ const tui = async (api) => {
1372
1572
  description: "Return to main session stats",
1373
1573
  slash: { name: "cache-session-back" },
1374
1574
  onSelect: (dialog) => {
1575
+ const t = createT(() => langCode());
1375
1576
  signals.setOverrideSessionId(undefined);
1376
1577
  api.kv.set(`${KV_PREFIX}.session`, "");
1377
- api.ui.toast({ message: langZH() ? "已切回主会话" : "Switched to main session" });
1578
+ api.ui.toast({ message: t("backToMain") });
1378
1579
  dialog?.clear();
1379
1580
  },
1380
1581
  },