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/src/index.tsx CHANGED
@@ -8,6 +8,8 @@ import type {
8
8
  TuiSlotPlugin,
9
9
  TuiPluginModule,
10
10
  TuiThemeCurrent,
11
+ TuiDialogStack,
12
+ TuiPromptRef,
11
13
  } from "@opencode-ai/plugin/tui"
12
14
  import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
13
15
  import type {
@@ -19,6 +21,8 @@ import type {
19
21
  } from "@opencode-ai/sdk/v2"
20
22
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
21
23
  import { PLUGIN_VERSION } from "./_version"
24
+ import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
25
+ import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Helpers
@@ -71,97 +75,14 @@ function truncateVisual(s: string, maxCols: number): string {
71
75
  return result
72
76
  }
73
77
 
74
- // ── language override (env: CACHE_TUI_LANG) ──
75
- const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
76
-
77
78
  // ── language ──────────────────────────────────────────────────────
79
+ // 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
80
+ // 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
78
81
 
79
- const LANG_ZH = DEBUG_LANG
80
- ? DEBUG_LANG === "zh"
81
- : (() => {
82
- try { return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh") }
83
- catch { return false }
84
- })()
85
-
86
- const ZH_T = {
87
- title: "缓存统计",
88
- hit: "命中率",
89
- totalHit: "总命中:",
90
- read: "缓存读:",
91
- write: "缓存写:",
92
- miss: "未命中:",
93
- out: "输出:",
94
- cost: "费用:",
95
- saved: "累计节省:",
96
- model: "模型:",
97
- provider: "提供商:",
98
- rate: "单价:",
99
- hitFolded: "命中",
100
- inputRate: "输入",
101
- cacheRate: "缓存",
102
- writeRate: "写入",
103
- noData: "等待缓存数据...",
104
- tok: "tok",
105
- distTitle: "估算 Token 分布",
106
- distSys: "系统提示:",
107
- distUser: "用户:",
108
- distAgent: "Agent 指令:",
109
- distTool: "Tool 调用:",
110
- distRes: "Tool 结果:",
111
- distTotal: "总计:",
112
- distOut: "输出:",
113
- secDetail: "明细",
114
- secModel: "模型",
115
- secSkills: "已加载技能",
116
- balTotal: "总余额:",
117
- balNoKey: "未配置 API Key",
118
- balLoading: "查询中...",
119
- balError: "查询失败",
120
- balErr401: "API Key 无效",
121
- balErr403: "余额查询被拒绝",
122
- balErrEmpty:"未获取到余额数据",
123
- balErrTimeout: "查询超时",
124
- } as const
125
-
126
- const EN_T = {
127
- title: "Token Cache",
128
- hit: "Hit",
129
- totalHit: "Total Hit:",
130
- read: "Read:",
131
- write: "Write:",
132
- miss: "Miss:",
133
- out: "Out:",
134
- cost: "Cost:",
135
- saved: "Total Saved:",
136
- model: "Model:",
137
- provider: "Provider:",
138
- rate: "Rate:",
139
- hitFolded: "hit",
140
- inputRate: "in",
141
- cacheRate: "cache",
142
- writeRate: "write",
143
- noData: "Waiting for cache data...",
144
- tok: "tok",
145
- distTitle: "Estimated Token Dist.",
146
- distSys: "System:",
147
- distUser: "User:",
148
- distAgent: "Agent Instr:",
149
- distTool: "Tool Call:",
150
- distRes: "Tool Result:",
151
- distTotal: "Total:",
152
- distOut: "Output:",
153
- secDetail: "Detail",
154
- secModel: "Model",
155
- secSkills: "Loaded Skills",
156
- balTotal: "Total:",
157
- balNoKey: "No API Key set",
158
- balLoading: "Fetching...",
159
- balError: "Fetch failed",
160
- balErr401: "Invalid API Key",
161
- balErr403: "Balance request rejected",
162
- balErrEmpty:"No balance data",
163
- balErrTimeout: "Request timed out",
164
- } as const
82
+ const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
83
+ const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
84
+ ? (DEBUG_LANG as LangCode)
85
+ : detectLang()
165
86
 
166
87
  // ── color helpers ────────────────────────────────────────────────
167
88
 
@@ -222,7 +143,7 @@ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
222
143
  * converges to within a fraction of an 8‑bit step, eliminating
223
144
  * colour banding in edge cases.
224
145
  */
225
- // BT.601 luma (perceptual brightness used as the grey anchor)
146
+ // Bt.601 luma (perceptual brightness used as the grey anchor)
226
147
  const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
227
148
  let lo = 0, hi = 1
228
149
  for (let i = 0; i < 12; i++) {
@@ -343,22 +264,17 @@ interface TokenDist {
343
264
  toolResult: number // ToolPart completed output / error
344
265
  output: number // AssistantMessage.tokens.output (fallback)
345
266
  apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
346
- apiInput: number // StepFinishPart.tokens.input (API exact total context)
267
+ apiInput: number // API exact total input context (input + cache read + cache write)
347
268
  stepCost: number
348
269
  }
349
270
 
350
271
  // ---------------------------------------------------------------------------
351
- // DeepSeek Balance
272
+ // Balance state
352
273
  // ---------------------------------------------------------------------------
353
274
 
354
- interface DeepSeekBalance {
355
- currency: string
356
- total: string
357
- }
358
-
359
275
  interface BalanceState {
360
276
  status: "idle" | "loading" | "ok" | "error"
361
- data: DeepSeekBalance[] | null
277
+ data: BalanceEntry[] | null
362
278
  lastFetch: number
363
279
  error?: string
364
280
  key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换
@@ -366,28 +282,6 @@ interface BalanceState {
366
282
 
367
283
  const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes
368
284
 
369
- async function fetchDeepSeekBalance(apiKey: string, signal?: AbortSignal): Promise<DeepSeekBalance[]> {
370
- const res = await fetch("https://api.deepseek.com/user/balance", {
371
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
372
- signal,
373
- })
374
- if (!res.ok) {
375
- if (res.status === 401) throw new Error("401")
376
- if (res.status === 402 || res.status === 403) throw new Error("403")
377
- throw new Error(String(res.status))
378
- }
379
- const json = await res.json() as {
380
- is_available?: boolean
381
- balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[]
382
- }
383
- const infos = json.balance_infos ?? []
384
- if (infos.length === 0) throw new Error("EMPTY")
385
- return infos.map((info) => ({
386
- currency: info.currency ?? "CNY",
387
- total: info.total_balance ?? "0",
388
- }))
389
- }
390
-
391
285
  /**
392
286
  * 将余额从来源币种换算为目标币种。
393
287
  * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
@@ -399,12 +293,66 @@ function convertBalance(target: string, targetRate: number, amount: number, from
399
293
  return target === "USD" ? usd : usd * targetRate
400
294
  }
401
295
 
296
+ /**
297
+ * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
298
+ * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
299
+ * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。
300
+ * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
301
+ */
302
+ function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
303
+ try {
304
+ const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
305
+ // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
306
+ const id = provider.id.toLowerCase()
307
+ const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
308
+ if (!hit) return ""
309
+ const k = typeof hit.key === "string" ? hit.key : ""
310
+ if (k) return k
311
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : ""
312
+ } catch {
313
+ return ""
314
+ }
315
+ }
316
+
402
317
  /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
403
318
  function balanceSymbol(currency: string): string {
404
319
  const sym = CURRENCIES[currency]
405
320
  return sym ?? currency + " "
406
321
  }
407
322
 
323
+ /** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */
324
+ function fmtCompact(n: number): string {
325
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
326
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
327
+ return String(Math.round(n))
328
+ }
329
+
330
+ /** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
331
+ function formatBalanceAmount(total: string): string {
332
+ const n = parseFloat(total)
333
+ if (!Number.isFinite(n)) return total
334
+ if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
335
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 })
336
+ }
337
+
338
+ /**
339
+ * 将余额列表格式化为单行文本。
340
+ * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
341
+ */
342
+ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
343
+ const native = pref ? list.find((x) => x.currency === pref) : undefined
344
+ if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
345
+ const base = list[0]
346
+ const baseAmt = parseFloat(base.total)
347
+ const converted = Number.isFinite(baseAmt)
348
+ ? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
349
+ : baseAmt
350
+ const shown = pref && base.currency !== pref
351
+ ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
352
+ : formatBalanceAmount(base.total)
353
+ return balanceSymbol(pref || base.currency) + shown
354
+ }
355
+
408
356
  // ---------------------------------------------------------------------------
409
357
  // Sidebar component
410
358
  // ---------------------------------------------------------------------------
@@ -417,8 +365,8 @@ interface PanelSignals {
417
365
  setCurrencySymbol: (v: string) => void
418
366
  exchangeRate: () => number
419
367
  setExchangeRate: (v: number) => void
420
- langZH: () => boolean
421
- setLangZH: (v: boolean) => void
368
+ langCode: () => LangCode
369
+ setLangCode: (v: LangCode) => void
422
370
  sectionDetail: () => boolean
423
371
  setSectionDetail: (v: boolean) => void
424
372
  sectionModel: () => boolean
@@ -429,9 +377,23 @@ interface PanelSignals {
429
377
  setSectionSkills: (v: boolean) => void
430
378
  sectionBalance: () => boolean
431
379
  setSectionBalance: (v: boolean) => void
432
- /** Increment to force a DeepSeek balance re-fetch. */
380
+ /** Bottom status bar (prompt hint line) visibility. */
381
+ sectionBottom: () => boolean
382
+ setSectionBottom: (v: boolean) => void
383
+ /** Increment to force a balance re-fetch. */
433
384
  balanceRefresh: () => number
434
385
  setBalanceRefresh: (v: number) => void
386
+ /** Currently selected balance provider id (e.g. "deepseek"). */
387
+ balanceProviderId: () => string
388
+ setBalanceProviderId: (v: string) => void
389
+ /** Auto-switch to the session's provider for balance display. Manual switch disables it. */
390
+ autoBalance: () => boolean
391
+ setAutoBalance: (v: boolean) => void
392
+ /** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */
393
+ balanceUnsupported: () => boolean
394
+ setBalanceUnsupported: (v: boolean) => void
395
+ /** Shared balance query state — single source of truth for sidebar and bottom bar. */
396
+ balanceState: () => BalanceState
435
397
  /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
436
398
  balanceCurrency: () => string
437
399
  setBalanceCurrency: (v: string) => void
@@ -481,19 +443,23 @@ function TokenCachePanel(props: {
481
443
  const {
482
444
  currencySymbol, setCurrencySymbol,
483
445
  exchangeRate, setExchangeRate,
484
- langZH, setLangZH,
446
+ langCode,
485
447
  sectionDetail, setSectionDetail,
486
448
  sectionModel, setSectionModel,
487
449
  sectionDist, setSectionDist,
488
450
  sectionSkills, setSectionSkills,
489
451
  sectionBalance, setSectionBalance,
490
452
  balanceRefresh,
453
+ balanceProviderId, setBalanceProviderId,
454
+ autoBalance, setAutoBalance,
455
+ balanceUnsupported, setBalanceUnsupported,
456
+ balanceState,
491
457
  balanceCurrency, setBalanceCurrency,
492
458
  borderVisible, setBorderVisible,
493
459
  } = props.signals
494
460
 
495
- // ── reactive translation (follows langZH signal) ──
496
- const t = createMemo(() => langZH() ? ZH_T : EN_T)
461
+ // ── reactive translation (follows langCode signal) ──
462
+ const t = createT(() => langCode())
497
463
 
498
464
  // ── scan session messages reactively ──
499
465
  // SolidJS createMemo re-evaluates whenever the underlying
@@ -522,46 +488,44 @@ function TokenCachePanel(props: {
522
488
  })
523
489
  const [refreshTick, setRefreshTick] = createSignal(0)
524
490
 
525
- // ── balance state + polling ──────────────────────────────────
526
- const [balanceState, setBalanceState] = createSignal<BalanceState>({
527
- status: "idle", data: null, lastFetch: 0,
528
- })
529
- // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
530
- let balanceSeq = 0
491
+ // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
492
+ const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
531
493
 
532
- const pollBalance = async () => {
533
- const key = props.api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
534
- if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
535
- const now = Date.now()
536
- const prev = balanceState()
537
- // key 已更换(重新输入)→ 强制重新查询,绕过缓存
538
- if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
539
- const seq = ++balanceSeq
540
- setBalanceState({ ...prev, status: "loading", error: undefined, key })
541
- const controller = new AbortController()
542
- let timedOut = false
543
- const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
544
- try {
545
- const data = await fetchDeepSeekBalance(key, controller.signal)
546
- clearTimeout(timer)
547
- if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
548
- setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
549
- } catch (err) {
550
- clearTimeout(timer)
551
- if (seq !== balanceSeq) return
552
- const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
553
- // 失败时清空旧数据,避免显示过期余额
554
- setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
555
- }
556
- }
557
-
558
- // Re-fetch when the API key is (re)configured via /cache-balance-key.
559
- // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
560
- // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
561
- // 形成无限循环(每次重跑都发起新的 fetch 请求)。
494
+ // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
495
+ // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
496
+ // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
562
497
  createEffect(() => {
563
- void balanceRefresh()
564
- untrack(() => { void pollBalance() })
498
+ if (!autoBalance()) return
499
+ const sid = props.signals.overrideSessionId() ?? props.sessionId
500
+ const msgs = props.api.state.session.messages(sid) as Message[]
501
+ let pid = ""
502
+ for (let i = msgs.length - 1; i >= 0; i--) {
503
+ const m = msgs[i]
504
+ if (m.role === "assistant" && (m as AssistantMessage).providerID) {
505
+ pid = (m as AssistantMessage).providerID
506
+ break
507
+ }
508
+ }
509
+ // 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
510
+ // → 回退到会话级模型元数据,反映当前正在使用的 provider
511
+ if (!pid) {
512
+ try {
513
+ const session = props.api.state.session.get(sid)
514
+ pid = session?.model?.providerID ?? ""
515
+ } catch { /* ignore */ }
516
+ }
517
+ if (!pid) return
518
+ const hit = matchBalanceProvider(pid)
519
+ if (hit) {
520
+ setBalanceUnsupported(false)
521
+ if (hit.id !== balanceProviderId()) {
522
+ setBalanceProviderId(hit.id)
523
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
524
+ }
525
+ } else {
526
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
527
+ setBalanceUnsupported(true)
528
+ }
565
529
  })
566
530
 
567
531
  // ── auto-clear override when the user navigates to a different main session ──
@@ -605,11 +569,11 @@ function TokenCachePanel(props: {
605
569
  let prevMsgHitRate = -1, lastMsgHitRate = -1
606
570
  for (const msg of msgs) {
607
571
  if (msg.role !== "assistant") continue
608
- const t = (msg as AssistantMessage).tokens; if (!t) continue
609
- const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read)
572
+ const tok = (msg as AssistantMessage).tokens; if (!tok) continue
573
+ const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read)
610
574
  if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
611
575
  if (fallbackTokens) {
612
- input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output)
576
+ input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output)
613
577
  }
614
578
  if (fallbackCost) {
615
579
  cost += num((msg as AssistantMessage).cost)
@@ -627,7 +591,8 @@ function TokenCachePanel(props: {
627
591
  break
628
592
  }
629
593
  const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
630
- const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
594
+ // 总命中率分母含缓存写(业界口径:read / (input+read+write)
595
+ const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
631
596
  const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
632
597
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
633
598
  const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
@@ -690,11 +655,11 @@ function TokenCachePanel(props: {
690
655
  // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
691
656
  for (let i = msgs.length - 1; i >= 0; i--) {
692
657
  if (msgs[i].role !== "assistant") continue
693
- const t = (msgs[i] as AssistantMessage).tokens
694
- if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
658
+ const tok = (msgs[i] as AssistantMessage).tokens
659
+ if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
695
660
  }
696
- // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
697
- dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
661
+ // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
662
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write)
698
663
  dist.apiOutput = num(lastAssMsg?.tokens?.output)
699
664
  hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
700
665
  } catch {}
@@ -763,6 +728,24 @@ function TokenCachePanel(props: {
763
728
  if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
764
729
  const balCur = props.api.kv.get<string>(`${KV_PREFIX}.balance_currency`)
765
730
  if (typeof balCur === "string") setBalanceCurrency(balCur)
731
+ // Restore balance provider (fall back to default when unknown)
732
+ const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
733
+ if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
734
+ setBalanceProviderId(savedProvider)
735
+ setBalanceUnsupported(false)
736
+ }
737
+ // Restore auto-switch (default on)
738
+ const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
739
+ if (typeof savedAuto === "boolean") setAutoBalance(savedAuto)
740
+ // Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
741
+ const legacyKey = props.api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
742
+ if (legacyKey) {
743
+ const dsKey = props.api.kv.get<string>(`${KV_PREFIX}.balance.deepseek.key`, "")
744
+ if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey)
745
+ props.api.kv.set(`${KV_PREFIX}.ds_key`, "")
746
+ }
747
+ // 恢复的 provider 可能与默认值不同,强制重新查询
748
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
766
749
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
767
750
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
768
751
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
@@ -770,11 +753,6 @@ function TokenCachePanel(props: {
770
753
  setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
771
754
  const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
772
755
  setBorderVisible(bv !== false)
773
- // Restore language preference
774
- const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
775
- if (savedLang === "zh" || savedLang === "en") {
776
- setLangZH(savedLang === "zh")
777
- }
778
756
  // Restore distribution snapshot so the token distribution block
779
757
  // doesn't blank out while api.state.part() re-hydrates.
780
758
  const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
@@ -822,8 +800,7 @@ function TokenCachePanel(props: {
822
800
  const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
823
801
  const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
824
802
  setRefreshTick(v => v + 1)
825
- const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
826
- onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() })
803
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
827
804
  })
828
805
 
829
806
  // ── colours ──
@@ -855,12 +832,14 @@ function TokenCachePanel(props: {
855
832
 
856
833
  const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
857
834
  function trendLabel(t: number): string {
858
- return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
835
+ // |t| < 0.05 视为无变化:避免显示 "0.0%" 的矛盾(箭头存在但数值截断为零)
836
+ if (Math.abs(t) < 0.05) return "-"
837
+ return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%"
859
838
  }
860
839
 
861
840
  const barW = createMemo(() => {
862
841
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
863
- const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
842
+ const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
864
843
  return Math.max(3, panelWidth() - overhead)
865
844
  })
866
845
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
@@ -906,7 +885,7 @@ function TokenCachePanel(props: {
906
885
  <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
907
886
  <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
908
887
  <span style={{ fg: pal().primary }}>
909
- <b>{t().title}</b>
888
+ <b>{t("title")}</b>
910
889
  <Show when={open()}>
911
890
  <span style={{ fg: dimColor(pal().muted, 0.75) }}> v{PLUGIN_VERSION}</span>
912
891
  </Show>
@@ -914,18 +893,18 @@ function TokenCachePanel(props: {
914
893
  <Show when={!open() && data().hasData}>
915
894
  <Show when={data().hasTrendData}>
916
895
  <span>
917
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))}
896
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend))))}
918
897
  </span>
919
- <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
920
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
898
+ <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
899
+ <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
921
900
  {" "}{trendLabel(data().trend)}
922
901
  </span>
923
902
  </Show>
924
903
  <Show when={!data().hasTrendData}>
925
904
  <span>
926
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))}
905
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded"))))}
927
906
  </span>
928
- <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
907
+ <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
929
908
  </Show>
930
909
  </Show>
931
910
  </text>
@@ -933,7 +912,7 @@ function TokenCachePanel(props: {
933
912
  <Show when={open()}>
934
913
  <Show when={props.signals.overrideSessionId()}>
935
914
  {(() => {
936
- const prefix = " \u21b3 " + (langZH() ? "\u5B50\u4EE3\u7406: " : "Sub: ")
915
+ const prefix = " \u21b3 " + t("subPrefix")
937
916
  const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix))
938
917
  return (
939
918
  <text>
@@ -948,7 +927,7 @@ function TokenCachePanel(props: {
948
927
  <text fg={pal().muted}>{sep()}</text>
949
928
  <text>
950
929
  <span style={{ fg: pal().muted }}>{"> "}</span>
951
- <span style={{ fg: pal().muted }}>{t().noData}</span>
930
+ <span style={{ fg: pal().muted }}>{t("noData")}</span>
952
931
  </text>
953
932
  </>
954
933
  }>
@@ -956,11 +935,11 @@ function TokenCachePanel(props: {
956
935
 
957
936
  {/* hit rate + bar — inline to avoid box spacing */}
958
937
  <text>
959
- <span style={{ fg: pal().text }}>{t().hit} </span>
938
+ <span style={{ fg: pal().text }}>{t("hit")} </span>
960
939
  <span style={{ fg: hitColor() }}>[{bar()}] </span>
961
940
  <span style={{ fg: pal().text }}>{pct()}</span>
962
941
  <Show when={data().hasTrendData}>
963
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
942
+ <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
964
943
  {" "}{trendLabel(data().trend)}
965
944
  </span>
966
945
  </Show>
@@ -968,38 +947,39 @@ function TokenCachePanel(props: {
968
947
 
969
948
  {/* session cumulative hit rate */}
970
949
  <text fg={pal().muted}>
971
- {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
950
+ {justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
972
951
  </text>
973
952
 
974
953
  {/* ── detail section (collapsible, default open) ── */}
975
954
  <Show when={sectionDetail()}>
976
955
  <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
977
956
  <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
978
- <span style={{ fg: pal().primary }}><b>{t().secDetail}</b></span>
979
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))}</span>
957
+ <span style={{ fg: pal().primary }}><b>{t("secDetail")}</b></span>
958
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail")))}</span>
980
959
  </text>
981
960
 
982
961
  <Show when={detailOpen()}>
983
962
  <Show when={data().read > 0}>
984
963
  <text fg={pal().muted}>
985
- {justify(t().read, fmt(data().read), t().tok)}
964
+ {justify(t("read"), fmt(data().read), t("tok"))}
986
965
  </text>
987
966
  </Show>
988
967
  <Show when={data().write > 0}>
989
968
  <text fg={pal().muted}>
990
- {justify(t().write, fmt(data().write), t().tok)}
969
+ {justify(t("write"), fmt(data().write), t("tok"))}
991
970
  </text>
992
971
  </Show>
972
+ {/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */}
993
973
  <text fg={pal().muted}>
994
- {justify(t().miss, fmt(data().freshInput), t().tok)}
974
+ {justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))}
995
975
  </text>
996
976
  <text fg={pal().muted}>
997
- {justify(t().out, fmt(data().output), t().tok)}
977
+ {justify(t("out"), fmt(data().output), t("tok"))}
998
978
  </text>
999
979
  <Show when={data().saved > 0}>
1000
980
  <text>
1001
- <span style={{ fg: pal().muted }}>{t().saved}</span>
1002
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
981
+ <span style={{ fg: pal().muted }}>{t("saved")}</span>
982
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
1003
983
  <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
1004
984
  </text>
1005
985
  </Show>
@@ -1010,34 +990,34 @@ function TokenCachePanel(props: {
1010
990
  <Show when={sectionModel()}>
1011
991
  {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
1012
992
  <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
1013
- <span style={{ fg: pal().primary }}><b>{t().secModel}</b></span>
1014
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))}</span>
993
+ <span style={{ fg: pal().primary }}><b>{t("secModel")}</b></span>
994
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel")))}</span>
1015
995
  </text>}
1016
996
 
1017
997
  <Show when={modelOpen()}>
1018
998
  <text fg={pal().text}>
1019
- {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
999
+ {justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))}
1020
1000
  </text>
1021
1001
  <Show when={data().providerName}>
1022
1002
  <text fg={pal().muted}>
1023
- {justify(t().provider, data().providerName)}
1003
+ {justify(t("provider"), data().providerName)}
1024
1004
  </text>
1025
1005
  </Show>
1026
1006
  <text fg={pal().muted}>
1027
- {justify(t().model, data().model)}
1007
+ {justify(t("model"), data().model)}
1028
1008
  </text>
1029
1009
  <Show when={data().hasPricing}>
1030
1010
  <text fg={pal().muted}>
1031
- {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)}
1011
+ {justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))}
1032
1012
  </text>
1033
1013
  <Show when={data().cacheReadRate > 0}>
1034
1014
  <text fg={pal().muted}>
1035
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)}
1015
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))}
1036
1016
  </text>
1037
1017
  </Show>
1038
1018
  <Show when={data().cacheWriteRate > 0}>
1039
1019
  <text fg={pal().muted}>
1040
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)}
1020
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))}
1041
1021
  </text>
1042
1022
  </Show>
1043
1023
  </Show>
@@ -1049,37 +1029,37 @@ function TokenCachePanel(props: {
1049
1029
  <Show when={data().hasDistData}>
1050
1030
  {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
1051
1031
  <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
1052
- <span style={{ fg: pal().primary }}><b>{t().distTitle}</b></span>
1053
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))}</span>
1032
+ <span style={{ fg: pal().primary }}><b>{t("distTitle")}</b></span>
1033
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle")))}</span>
1054
1034
  </text>}
1055
1035
  <Show when={distOpen()}>
1056
1036
  <Show when={data().dist.system > 0}>
1057
1037
  <text fg={pal().muted}>
1058
- {justify(t().distSys, fmt(data().dist.system), t().tok)}
1038
+ {justify(t("distSys"), fmt(data().dist.system), t("tok"))}
1059
1039
  </text>
1060
1040
  </Show>
1061
1041
  <Show when={data().dist.user > 0}>
1062
1042
  <text fg={pal().muted}>
1063
- {justify(t().distUser, fmt(data().dist.user), t().tok)}
1043
+ {justify(t("distUser"), fmt(data().dist.user), t("tok"))}
1064
1044
  </text>
1065
1045
  </Show>
1066
1046
  <Show when={data().dist.agent > 0}>
1067
1047
  <text fg={pal().muted}>
1068
- {justify(t().distAgent, fmt(data().dist.agent), t().tok)}
1048
+ {justify(t("distAgent"), fmt(data().dist.agent), t("tok"))}
1069
1049
  </text>
1070
1050
  </Show>
1071
1051
  <Show when={data().dist.toolCall > 0}>
1072
1052
  <text fg={pal().muted}>
1073
- {justify(t().distTool, fmt(data().dist.toolCall), t().tok)}
1053
+ {justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))}
1074
1054
  </text>
1075
1055
  </Show>
1076
1056
  <Show when={data().dist.toolResult > 0}>
1077
1057
  <text fg={pal().muted}>
1078
- {justify(t().distRes, fmt(data().dist.toolResult), t().tok)}
1058
+ {justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
1079
1059
  </text>
1080
1060
  </Show>
1081
1061
  <text fg={pal().text}>
1082
- {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)}
1062
+ {justify(t("distTotal"), fmt(data().dist.apiInput), t("tok"))}
1083
1063
  </text>
1084
1064
  </Show>
1085
1065
  </Show>
@@ -1090,18 +1070,18 @@ function TokenCachePanel(props: {
1090
1070
  <Show when={data().hasSkills}>
1091
1071
  {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
1092
1072
  <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
1093
- <span style={{ fg: pal().primary }}><b>{t().secSkills}</b></span>
1073
+ <span style={{ fg: pal().primary }}><b>{t("secSkills")}</b></span>
1094
1074
  <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
1095
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))}</span>
1075
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`))}</span>
1096
1076
  </text>}
1097
1077
  <Show when={skillsOpen()}>
1098
1078
  {data().skills.map((sk: { name: string; tokens: number }) => {
1099
- const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok)
1079
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"))
1100
1080
  const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
1101
1081
  const label = truncateVisual(sk.name, maxLabel)
1102
1082
  return (
1103
1083
  <text fg={pal().muted}>
1104
- {justify(label, fmt(sk.tokens), t().tok)}
1084
+ {justify(label, fmt(sk.tokens), t("tok"))}
1105
1085
  </text>
1106
1086
  )
1107
1087
  })}
@@ -1109,62 +1089,46 @@ function TokenCachePanel(props: {
1109
1089
  </Show>
1110
1090
  </Show>
1111
1091
 
1112
- {/* ── DeepSeek balance (single line) ── */}
1092
+ {/* ── provider balance (single line) ── */}
1113
1093
  <Show when={sectionBalance()}>
1114
1094
  <text fg={pal().muted}>{sep()}</text>
1115
- <Show when={balanceState().status === "idle"}>
1116
- <text fg={pal().muted}>
1117
- <span style={{ fg: pal().muted }}>{"> "}</span>
1118
- <span>{t().balNoKey}</span>
1119
- </text>
1120
- </Show>
1121
- <Show when={balanceState().status === "loading"}>
1095
+ <Show when={balanceUnsupported()}>
1122
1096
  <text fg={pal().muted}>
1123
1097
  <span style={{ fg: pal().muted }}>{"> "}</span>
1124
- <span>{t().balLoading}</span>
1125
- </text>
1126
- </Show>
1127
- <Show when={balanceState().status === "error"}>
1128
- <text fg={pal().error}>
1129
- <span style={{ fg: pal().muted }}>{"> "}</span>
1130
- <span>{(() => {
1131
- const code = balanceState().error
1132
- if (code === "401") return t().balErr401
1133
- if (code === "403") return t().balErr403
1134
- if (code === "EMPTY") return t().balErrEmpty
1135
- if (code === "TIMEOUT") return t().balErrTimeout
1136
- return t().balError + (code ? ` (${code})` : "")
1137
- })()}</span>
1098
+ <span>{t("balUnsupported")}</span>
1138
1099
  </text>
1139
1100
  </Show>
1140
- <Show when={balanceState().status === "ok" && balanceState().data}>
1141
- {(() => {
1142
- const list = balanceState().data!
1143
- const pref = balanceCurrency()
1144
- // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示
1145
- const native = pref ? list.find(x => x.currency === pref) : undefined
1146
- if (native) {
1147
- return (
1148
- <text fg={pal().text}>
1149
- {justify(t().balTotal, balanceSymbol(native.currency) + native.total)}
1150
- </text>
1151
- )
1152
- }
1153
- // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算
1154
- const base = list[0]
1155
- const baseAmt = parseFloat(base.total)
1156
- const converted = Number.isFinite(baseAmt)
1157
- ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency)
1158
- : baseAmt
1159
- const shown = pref && base.currency !== pref
1160
- ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
1161
- : base.total
1162
- return (
1163
- <text fg={pal().text}>
1164
- {justify(t().balTotal, balanceSymbol(pref || base.currency) + shown)}
1165
- </text>
1166
- )
1167
- })()}
1101
+ <Show when={!balanceUnsupported()}>
1102
+ <Show when={balanceState().status === "idle"}>
1103
+ <text fg={pal().muted}>
1104
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1105
+ <span>{t("balNoKey", { p: providerName() })}</span>
1106
+ </text>
1107
+ </Show>
1108
+ <Show when={balanceState().status === "loading"}>
1109
+ <text fg={pal().muted}>
1110
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1111
+ <span>{t("balLoading")}</span>
1112
+ </text>
1113
+ </Show>
1114
+ <Show when={balanceState().status === "error"}>
1115
+ <text fg={pal().error}>
1116
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1117
+ <span>{(() => {
1118
+ const code = balanceState().error
1119
+ if (code === "401") return t("balErr401")
1120
+ if (code === "403") return t("balErr403")
1121
+ if (code === "EMPTY") return t("balErrEmpty")
1122
+ if (code === "TIMEOUT") return t("balErrTimeout")
1123
+ return t("balError") + (code ? ` (${code})` : "")
1124
+ })()}</span>
1125
+ </text>
1126
+ </Show>
1127
+ <Show when={balanceState().status === "ok" && balanceState().data}>
1128
+ <text fg={pal().text}>
1129
+ {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
1130
+ </text>
1131
+ </Show>
1168
1132
  </Show>
1169
1133
  </Show>
1170
1134
  </Show>
@@ -1177,6 +1141,166 @@ function TokenCachePanel(props: {
1177
1141
  // Plugin entry
1178
1142
  // ---------------------------------------------------------------------------
1179
1143
 
1144
+ /**
1145
+ * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
1146
+ * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
1147
+ * 三合一信息与路径同行显示在中间位置。
1148
+ */
1149
+ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element {
1150
+ const KV_PREFIX = "cache_panel"
1151
+ const t = createT(() => props.signals.langCode())
1152
+
1153
+ const sid = props.sessionId
1154
+
1155
+ // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ──
1156
+ const stats = createMemo(() => {
1157
+ const id = sid
1158
+ if (!id) return null
1159
+ const msgs = props.api.state.session.messages(id) as Message[]
1160
+ const session = typeof props.api.state.session.get === "function"
1161
+ ? props.api.state.session.get(id)
1162
+ : undefined
1163
+ let input = session?.tokens?.input ?? 0
1164
+ let read = session?.tokens?.cache?.read ?? 0
1165
+ let write = session?.tokens?.cache?.write ?? 0
1166
+ // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
1167
+ if (session?.tokens == null) {
1168
+ for (const m of msgs) {
1169
+ if (m.role !== "assistant") continue
1170
+ const tk = (m as AssistantMessage).tokens
1171
+ if (!tk) continue
1172
+ input += num(tk.input)
1173
+ read += num(tk.cache?.read)
1174
+ write += num(tk.cache?.write)
1175
+ }
1176
+ }
1177
+ // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
1178
+ // 分母含缓存写(业界口径:read / (input+read+write))
1179
+ let hitRate = -1, prevHitRate = -1
1180
+ for (let i = msgs.length - 1; i >= 0; i--) {
1181
+ const m = msgs[i]
1182
+ if (m.role !== "assistant") continue
1183
+ const tk = (m as AssistantMessage).tokens
1184
+ if (!tk) continue
1185
+ const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
1186
+ const mrt = num(tk.cache?.read)
1187
+ if (mit <= 0) continue
1188
+ const rate = (mrt / mit) * 100
1189
+ if (hitRate < 0) { hitRate = rate; continue }
1190
+ prevHitRate = rate
1191
+ break
1192
+ }
1193
+ return { hitRate, prevHitRate, input, read, write }
1194
+ })
1195
+
1196
+ // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
1197
+
1198
+ // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
1199
+ createEffect(() => {
1200
+ if (!props.signals.autoBalance()) return
1201
+ const id = sid
1202
+ if (!id) return
1203
+ const msgs = props.api.state.session.messages(id) as Message[]
1204
+ let pid = ""
1205
+ for (let i = msgs.length - 1; i >= 0; i--) {
1206
+ const m = msgs[i]
1207
+ if (m.role === "assistant" && (m as AssistantMessage).providerID) { pid = (m as AssistantMessage).providerID; break }
1208
+ }
1209
+ if (!pid) {
1210
+ try { pid = props.api.state.session.get(id)?.model?.providerID ?? "" } catch {}
1211
+ }
1212
+ if (!pid) return
1213
+ const hit = matchBalanceProvider(pid)
1214
+ if (hit) {
1215
+ props.signals.setBalanceUnsupported(false)
1216
+ if (hit.id !== props.signals.balanceProviderId()) {
1217
+ props.signals.setBalanceProviderId(hit.id)
1218
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
1219
+ }
1220
+ } else {
1221
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
1222
+ props.signals.setBalanceUnsupported(true)
1223
+ }
1224
+ })
1225
+
1226
+ // ── 主题色(与侧边栏同口径)──
1227
+ const pal = createMemo(() => {
1228
+ const th = props.api.theme.current as Record<string, unknown>
1229
+ const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
1230
+ return {
1231
+ text: sat("text", FALLBACK.text),
1232
+ muted: sat("textMuted", FALLBACK.muted),
1233
+ success: sat("success", FALLBACK.success),
1234
+ warning: sat("warning", FALLBACK.warning),
1235
+ error: sat("error", FALLBACK.error),
1236
+ }
1237
+ })
1238
+
1239
+ const hitColor = createMemo(() => {
1240
+ const r = stats()?.hitRate ?? -1
1241
+ if (r >= 85) return pal().success
1242
+ if (r >= 70) return pal().warning
1243
+ return pal().error
1244
+ })
1245
+
1246
+ // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
1247
+ const trend = createMemo(() => {
1248
+ const s = stats()
1249
+ if (!s || s.prevHitRate < 0 || s.hitRate < 0) return null
1250
+ const d = s.hitRate - s.prevHitRate
1251
+ return Math.abs(d) < 0.05 ? null : d
1252
+ })
1253
+
1254
+ const balanceText = createMemo(() => {
1255
+ const s = props.signals.balanceState()
1256
+ if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate())
1257
+ if (s.status === "loading") return "\u2026"
1258
+ if (s.status === "error") return "\u26a0"
1259
+ return "-"
1260
+ })
1261
+
1262
+ // 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
1263
+ const directory = createMemo(() => {
1264
+ try { return props.api.state.path.directory } catch { return "" }
1265
+ })
1266
+
1267
+ // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
1268
+ onMount(() => {
1269
+ try {
1270
+ const v = props.api.kv.get<boolean>(`${KV_PREFIX}.section.bottom`, true)
1271
+ props.signals.setSectionBottom(v !== false)
1272
+ } catch {}
1273
+ })
1274
+
1275
+ return (
1276
+ <Show when={props.signals.sectionBottom()} fallback={<text fg={pal().muted}>{directory()}</text>}>
1277
+ <box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
1278
+ <text fg={pal().muted}>{directory()}</text>
1279
+ <box flexDirection="row">
1280
+ <text>
1281
+ <span style={{ fg: pal().muted }}>{t("barHit")} </span>
1282
+ <span style={{ fg: hitColor() }}>{(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"}</span>
1283
+ <Show when={trend() !== null}>
1284
+ <span style={{ fg: trend()! > 0 ? pal().success : pal().error }}>
1285
+ {" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"}
1286
+ </span>
1287
+ </Show>
1288
+ <span style={{ fg: pal().muted }}>{" \u00b7 " + t("barTok") + " "}</span>
1289
+ <span style={{ fg: pal().text }}>
1290
+ {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"}
1291
+ </span>
1292
+ <Show when={!props.signals.balanceUnsupported()}>
1293
+ <span style={{ fg: pal().muted }}>{" \u00b7 " + t("barBal") + " "}</span>
1294
+ <span style={{ fg: pal().text }}>{balanceText()}</span>
1295
+ </Show>
1296
+ </text>
1297
+ <text fg={pal().muted}>{" \u00b7 "}</text>
1298
+ </box>
1299
+ </box>
1300
+ </Show>
1301
+ )
1302
+ }
1303
+
1180
1304
  function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
1181
1305
  let lastSlotSid = ""
1182
1306
  return {
@@ -1213,22 +1337,39 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1213
1337
  const [sectionDist, setSectionDist] = createSignal(true)
1214
1338
  const [sectionSkills, setSectionSkills] = createSignal(true)
1215
1339
  const [sectionBalance, setSectionBalance] = createSignal(true)
1340
+ const [sectionBottom, setSectionBottom] = createSignal(true)
1216
1341
  const [balanceRefresh, setBalanceRefresh] = createSignal(0)
1342
+ const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
1343
+ const [autoBalance, setAutoBalance] = createSignal(true)
1344
+ const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
1217
1345
  const [balanceCurrency, setBalanceCurrency] = createSignal("")
1218
1346
  const [borderVisible, setBorderVisible] = createSignal(true)
1219
- const [langZH, setLangZH] = createSignal(LANG_ZH)
1347
+ const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
1220
1348
  const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
1221
1349
 
1350
+ // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1351
+ // 避免重复请求导致两处余额不一致 ──
1352
+ const [balanceState, setBalanceState] = createSignal<BalanceState>({
1353
+ status: "idle", data: null, lastFetch: 0,
1354
+ })
1355
+ // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
1356
+ let balanceSeq = 0
1357
+
1222
1358
  const signals: PanelSignals = {
1223
1359
  currencySymbol, setCurrencySymbol,
1224
1360
  exchangeRate, setExchangeRate,
1225
- langZH, setLangZH,
1361
+ langCode, setLangCode,
1226
1362
  sectionDetail, setSectionDetail,
1227
1363
  sectionModel, setSectionModel,
1228
1364
  sectionDist, setSectionDist,
1229
1365
  sectionSkills, setSectionSkills,
1230
1366
  sectionBalance, setSectionBalance,
1367
+ sectionBottom, setSectionBottom,
1231
1368
  balanceRefresh, setBalanceRefresh,
1369
+ balanceProviderId, setBalanceProviderId,
1370
+ autoBalance, setAutoBalance,
1371
+ balanceUnsupported, setBalanceUnsupported,
1372
+ balanceState,
1232
1373
  balanceCurrency, setBalanceCurrency,
1233
1374
  borderVisible, setBorderVisible,
1234
1375
  overrideSessionId, setOverrideSessionId,
@@ -1236,8 +1377,146 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1236
1377
 
1237
1378
  api.slots.register(createSidebarSlot(api, signals))
1238
1379
 
1380
+ // 输入框 hint 行(session_prompt slot,replace 模式):
1381
+ // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
1382
+ // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
1383
+ api.slots.register({
1384
+ order: 55,
1385
+ slots: {
1386
+ session_prompt(
1387
+ _ctx: TuiSlotContext,
1388
+ input: {
1389
+ session_id: string
1390
+ visible?: boolean
1391
+ disabled?: boolean
1392
+ on_submit?: () => void
1393
+ ref?: (ref: TuiPromptRef | undefined) => void
1394
+ },
1395
+ ): JSX.Element {
1396
+ return (
1397
+ <api.ui.Prompt
1398
+ sessionID={input.session_id}
1399
+ visible={input.visible}
1400
+ disabled={input.disabled}
1401
+ onSubmit={input.on_submit}
1402
+ ref={input.ref}
1403
+ hint={<BottomStatusBar api={api} signals={signals} sessionId={input.session_id} />}
1404
+ />
1405
+ )
1406
+ },
1407
+ },
1408
+ })
1409
+
1239
1410
  // ── slash commands for runtime config ──
1240
1411
  const KV_PREFIX = "cache_panel"
1412
+
1413
+ // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
1414
+ const restoreLang = () => {
1415
+ try {
1416
+ const saved = api.kv.get<string>(`${KV_PREFIX}.lang`)
1417
+ if (saved && LANG_META.some((m) => m.code === saved)) setLangCode(saved as LangCode)
1418
+ } catch {}
1419
+ }
1420
+ if (api.kv.ready) {
1421
+ restoreLang()
1422
+ } else {
1423
+ const langTimer = setInterval(() => {
1424
+ if (api.kv.ready) { clearInterval(langTimer); restoreLang() }
1425
+ }, 10)
1426
+ api.lifecycle.onDispose(() => clearInterval(langTimer))
1427
+ }
1428
+
1429
+ const pollBalance = async () => {
1430
+ const provider = getBalanceProvider(balanceProviderId())
1431
+ // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
1432
+ const key = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1433
+ || findOpencodeKey(api, provider)
1434
+ if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
1435
+ if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
1436
+ const now = Date.now()
1437
+ const prev = balanceState()
1438
+ // key 已更换(重新输入)→ 强制重新查询,绕过缓存
1439
+ if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
1440
+ const seq = ++balanceSeq
1441
+ setBalanceState({ ...prev, status: "loading", error: undefined, key })
1442
+ const controller = new AbortController()
1443
+ let timedOut = false
1444
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
1445
+ try {
1446
+ const data = await provider.fetchBalance(key, controller.signal)
1447
+ clearTimeout(timer)
1448
+ if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
1449
+ setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
1450
+ } catch (err) {
1451
+ clearTimeout(timer)
1452
+ if (seq !== balanceSeq) return
1453
+ const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
1454
+ // 失败时清空旧数据,避免显示过期余额
1455
+ setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
1456
+ }
1457
+ }
1458
+
1459
+ // Re-fetch when the API key is (re)configured via /cache-balance-key.
1460
+ // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
1461
+ // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
1462
+ // 形成无限循环(每次重跑都发起新的 fetch 请求)。
1463
+ createEffect(() => {
1464
+ void balanceRefresh()
1465
+ untrack(() => { void pollBalance() })
1466
+ })
1467
+
1468
+ // 定时轮询(5 分钟);随插件生命周期清理
1469
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
1470
+ api.lifecycle.onDispose(() => clearInterval(balanceTimer))
1471
+
1472
+ /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
1473
+ const providerOptionTitle = (p: BalanceProvider, current?: string) => {
1474
+ const t = createT(() => langCode())
1475
+ const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
1476
+ const hasAuto = !hasManual && !!findOpencodeKey(api, p)
1477
+ const mark = hasManual
1478
+ ? t("keyUser")
1479
+ : hasAuto
1480
+ ? t("keyOpenCode")
1481
+ : t("keyNotSet")
1482
+ return p.name + mark + (current && p.id === current ? " *" : "")
1483
+ }
1484
+
1485
+ /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
1486
+ const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
1487
+ const t = createT(() => langCode())
1488
+ const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1489
+ const masked = maskKey(current)
1490
+ dialog?.replace(() => (
1491
+ <api.ui.DialogPrompt
1492
+ title={provider.name}
1493
+ description={() => <text>{t("balKeyPrompt", { p: provider.name })}</text>}
1494
+ placeholder={provider.keyPlaceholder ?? "sk-..."}
1495
+ value={masked}
1496
+ onConfirm={(val) => {
1497
+ const input = val.trim()
1498
+ let key: string
1499
+ if (input === "") {
1500
+ key = ""
1501
+ } else if (input.includes("*")) {
1502
+ key = current
1503
+ } else {
1504
+ key = input
1505
+ }
1506
+ api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
1507
+ setBalanceRefresh(v => v + 1)
1508
+ if (key) {
1509
+ api.ui.toast({ message: t("keySaved") })
1510
+ } else {
1511
+ api.ui.toast({ message: t("keyCleared") })
1512
+ }
1513
+ dialog?.clear()
1514
+ }}
1515
+ onCancel={() => dialog?.clear()}
1516
+ />
1517
+ ))
1518
+ }
1519
+
1241
1520
  api.command?.register(() => [
1242
1521
  {
1243
1522
  title: "Cache: Set Currency",
@@ -1253,6 +1532,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1253
1532
  value: code,
1254
1533
  }))}
1255
1534
  onSelect={(opt) => {
1535
+ const t = createT(() => langCode())
1256
1536
  const sym = CURRENCIES[opt.value] ?? "$"
1257
1537
  const defRate = DEFAULT_RATES[opt.value] ?? 1
1258
1538
  api.kv.set(`${KV_PREFIX}.currency`, sym)
@@ -1262,7 +1542,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1262
1542
  signals.setBalanceCurrency(opt.value)
1263
1543
  signals.setCurrencySymbol(sym)
1264
1544
  signals.setExchangeRate(defRate)
1265
- api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
1545
+ api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) })
1266
1546
  dialog?.clear()
1267
1547
  }}
1268
1548
  />
@@ -1282,11 +1562,12 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1282
1562
  placeholder="1.0"
1283
1563
  value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
1284
1564
  onConfirm={(val) => {
1565
+ const t = createT(() => langCode())
1285
1566
  const n = parseFloat(val)
1286
1567
  if (n > 0) {
1287
1568
  api.kv.set(`${KV_PREFIX}.rate`, n)
1288
1569
  signals.setExchangeRate(n)
1289
- api.ui.toast({ message: `Exchange rate set to ${n}` })
1570
+ api.ui.toast({ message: t("rateSet", { r: n }) })
1290
1571
  }
1291
1572
  dialog?.clear()
1292
1573
  }}
@@ -1300,29 +1581,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1300
1581
  description: "Show or hide a sidebar section",
1301
1582
  slash: { name: "cache-section" },
1302
1583
  onSelect: (dialog) => {
1584
+ const t = createT(() => langCode())
1303
1585
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1304
1586
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1305
1587
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1306
1588
  const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1307
1589
  const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1590
+ const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
1308
1591
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1592
+ const labels: Record<string, string> = {
1593
+ detail: t("secDetail"),
1594
+ model: t("secModel"),
1595
+ dist: t("distTitle"),
1596
+ skills: t("secSkills"),
1597
+ balance: t("secBalance"),
1598
+ bottom: t("secBottom"),
1599
+ border: t("secBorder"),
1600
+ }
1601
+ const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`
1309
1602
  dialog?.replace(() => (
1310
1603
  <api.ui.DialogSelect
1311
- title="Toggle Section"
1604
+ title={t("secToggle")}
1312
1605
  options={[
1313
- { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
1314
- { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
1315
- { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1316
- { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
1317
- { title: `DS Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" },
1318
- { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1606
+ { title: optTitle(labels.detail, detailOn), value: "detail" },
1607
+ { title: optTitle(labels.model, modelOn), value: "model" },
1608
+ { title: optTitle(labels.dist, distOn), value: "dist" },
1609
+ { title: optTitle(labels.skills, skillsOn), value: "skills" },
1610
+ { title: optTitle(labels.balance, balanceOn), value: "balance" },
1611
+ { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
1612
+ { title: optTitle(labels.border, borderOn), value: "border" },
1319
1613
  ]}
1320
1614
  onSelect={(opt) => {
1321
1615
  if (opt.value === "border") {
1322
1616
  const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1323
1617
  api.kv.set(`${KV_PREFIX}.border`, !cur)
1324
1618
  signals.setBorderVisible(!cur)
1325
- api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
1619
+ api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
1326
1620
  } else {
1327
1621
  const key = `${KV_PREFIX}.section.${opt.value}`
1328
1622
  const cur = Boolean(api.kv.get(key, true))
@@ -1332,7 +1626,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1332
1626
  if (opt.value === "dist") signals.setSectionDist(!cur)
1333
1627
  if (opt.value === "skills") signals.setSectionSkills(!cur)
1334
1628
  if (opt.value === "balance") signals.setSectionBalance(!cur)
1335
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1629
+ if (opt.value === "bottom") signals.setSectionBottom(!cur)
1630
+ const name = labels[opt.value] ?? opt.value
1631
+ api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) })
1336
1632
  }
1337
1633
  dialog?.clear()
1338
1634
  }}
@@ -1346,6 +1642,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1346
1642
  description: "Display the current plugin configuration",
1347
1643
  slash: { name: "cache-config" },
1348
1644
  onSelect: (dialog) => {
1645
+ const t = createT(() => langCode())
1349
1646
  const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
1350
1647
  const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
1351
1648
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
@@ -1353,9 +1650,16 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1353
1650
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1354
1651
  const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1355
1652
  const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1653
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
1654
+ const on = (v: boolean) => v ? "ON" : "OFF"
1356
1655
  api.ui.toast({
1357
- title: "Cache Panel Config",
1358
- 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"}`,
1656
+ title: t("panelConfigTitle"),
1657
+ message: t("panelConfigMsg", {
1658
+ c: sym, r: rate,
1659
+ d: on(detail), m: on(model),
1660
+ t: on(dist), k: on(skills),
1661
+ b: on(balance), f: on(bottom),
1662
+ }),
1359
1663
  duration: 8000,
1360
1664
  })
1361
1665
  dialog?.clear()
@@ -1367,69 +1671,107 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1367
1671
  description: "Switch between Chinese and English display",
1368
1672
  slash: { name: "cache-lang" },
1369
1673
  onSelect: (dialog) => {
1370
- const cur = langZH()
1674
+ const t = createT(() => langCode())
1675
+ const cur = langCode()
1371
1676
  dialog?.replace(() => (
1372
1677
  <api.ui.DialogSelect
1373
- title="Display Language"
1678
+ title={t("langTitle")}
1679
+ options={LANG_META.map((m) => ({
1680
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
1681
+ value: m.code,
1682
+ }))}
1683
+ onSelect={(opt) => {
1684
+ const code = opt.value as LangCode
1685
+ api.kv.set(`${KV_PREFIX}.lang`, code)
1686
+ setLangCode(code)
1687
+ api.ui.toast({ message: t("langSwitched") })
1688
+ dialog?.clear()
1689
+ }}
1690
+ />
1691
+ ))
1692
+ },
1693
+ },
1694
+ {
1695
+ title: "Cache: Switch Balance Provider",
1696
+ value: "cache.balance",
1697
+ description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
1698
+ slash: { name: "cache-balance" },
1699
+ onSelect: (dialog) => {
1700
+ const t = createT(() => langCode())
1701
+ const current = signals.balanceProviderId()
1702
+ const auto = signals.autoBalance()
1703
+ const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
1704
+ dialog?.replace(() => (
1705
+ <api.ui.DialogSelect
1706
+ title={t("balProvTitle")}
1374
1707
  options={[
1375
- { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1376
- { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1708
+ {
1709
+ title: autoLabel,
1710
+ value: "__auto__",
1711
+ },
1712
+ ...balanceProviders.map((p) => ({
1713
+ title: providerOptionTitle(p, current),
1714
+ value: p.id,
1715
+ })),
1377
1716
  ]}
1378
1717
  onSelect={(opt) => {
1379
- const zh = opt.value === "zh"
1380
- api.kv.set(`${KV_PREFIX}.lang`, opt.value)
1381
- setLangZH(zh)
1382
- api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" })
1383
- dialog?.clear()
1718
+ if (opt.value === "__auto__") {
1719
+ const next = !auto
1720
+ api.kv.set(`${KV_PREFIX}.balance.auto`, next)
1721
+ signals.setAutoBalance(next)
1722
+ api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") })
1723
+ dialog?.clear()
1724
+ } else {
1725
+ const provider = getBalanceProvider(opt.value)
1726
+ // 手动切换会关闭自动切换
1727
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
1728
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1729
+ signals.setBalanceProviderId(provider.id)
1730
+ signals.setAutoBalance(false)
1731
+ signals.setBalanceUnsupported(false)
1732
+ // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
1733
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1734
+ const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1735
+ if (!hasKey) {
1736
+ // 未配置 key → 进入设置流程(对话框保持打开等待输入)
1737
+ promptBalanceKey(dialog, provider)
1738
+ } else {
1739
+ api.ui.toast({ message: t("providerManual", { p: provider.name }) })
1740
+ dialog?.clear()
1741
+ }
1742
+ }
1384
1743
  }}
1385
1744
  />
1386
1745
  ))
1387
1746
  },
1388
1747
  },
1389
1748
  {
1390
- title: "Cache: Set DeepSeek API Key",
1749
+ title: "Cache: Set Balance API Key",
1391
1750
  value: "cache.balance.key",
1392
- description: "Set or update the DeepSeek API key for balance display",
1751
+ description: "Select a provider and set its API key for balance display",
1393
1752
  slash: { name: "cache-balance-key" },
1394
1753
  onSelect: (dialog) => {
1395
- const zh = langZH()
1396
- const current = api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
1397
- // 已保存的 key 以脱敏形式预填:保留 "sk-" 前缀 + 头 5 尾 5 字符,中间用 * 填充
1398
- const maskKey = (k: string): string => {
1399
- if (!k) return ""
1400
- const prefix = k.startsWith("sk-") ? "sk-" : ""
1401
- const body = prefix ? k.slice(3) : k
1402
- if (body.length <= 10) return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5))
1403
- return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5)
1404
- }
1405
- const masked = maskKey(current)
1754
+ const t = createT(() => langCode())
1755
+ // 步骤 1:选择 provider
1406
1756
  dialog?.replace(() => (
1407
- <api.ui.DialogPrompt
1408
- title={zh ? "DeepSeek API Key" : "DeepSeek API Key"}
1409
- description={() => <text>{zh ? "输入 DeepSeek API Key 以显示账户余额(留空清除)" : "Enter your DeepSeek API key to show account balance (leave empty to clear)"}</text>}
1410
- placeholder="sk-..."
1411
- value={masked}
1412
- onConfirm={(val) => {
1413
- const input = val.trim()
1414
- // 清除;含 * (脱敏占位符残留)→ 视为未修改,保留原 key;否则为新 key
1415
- let key: string
1416
- if (input === "") {
1417
- key = ""
1418
- } else if (input.includes("*")) {
1419
- key = current
1420
- } else {
1421
- key = input
1422
- }
1423
- api.kv.set(`${KV_PREFIX}.ds_key`, key)
1424
- setBalanceRefresh(v => v + 1)
1425
- if (key) {
1426
- api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." })
1427
- } else {
1428
- api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" })
1429
- }
1430
- dialog?.clear()
1757
+ <api.ui.DialogSelect
1758
+ title={t("balSelectTitle")}
1759
+ options={balanceProviders.map((p) => ({
1760
+ title: providerOptionTitle(p),
1761
+ value: p.id,
1762
+ }))}
1763
+ onSelect={(opt) => {
1764
+ const provider = getBalanceProvider(opt.value)
1765
+ // 手动指定 provider 会关闭自动切换
1766
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
1767
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1768
+ signals.setBalanceProviderId(provider.id)
1769
+ signals.setAutoBalance(false)
1770
+ // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
1771
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1772
+ // 步骤 2:输入 key
1773
+ promptBalanceKey(dialog, provider)
1431
1774
  }}
1432
- onCancel={() => dialog?.clear()}
1433
1775
  />
1434
1776
  ))
1435
1777
  },
@@ -1440,9 +1782,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1440
1782
  description: "Dump all tool parts found in the current session for skill detection debugging",
1441
1783
  slash: { name: "cache-debug-skills" },
1442
1784
  onSelect: () => {
1785
+ const t = createT(() => langCode())
1443
1786
  const rt = api.route.current
1444
1787
  if (rt.name !== "session" || !rt.params) {
1445
- api.ui.toast({ message: "Please run this command inside a session", variant: "warning" })
1788
+ api.ui.toast({ message: t("runInSession"), variant: "warning" })
1446
1789
  return
1447
1790
  }
1448
1791
  const sid = String(rt.params.sessionID)
@@ -1520,7 +1863,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1520
1863
 
1521
1864
  if (unique.length > 0) {
1522
1865
  // ── 有子代理 → DialogSelect 列表选择 ──
1523
- const zh = langZH()
1866
+ const t = createT(() => langCode())
1524
1867
  const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
1525
1868
  const options = unique.map((c, i) => ({
1526
1869
  title: `${i + 1}. ${c.title}`,
@@ -1529,24 +1872,24 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1529
1872
  }))
1530
1873
  // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
1531
1874
  const backValue = "__main__"
1532
- const backTitle = `\u2500 ${zh ? "\u56DE\u5230\u4E3B\u4F1A\u8BDD" : "Back to Main"}`
1875
+ const backTitle = `\u2500 ${t("backToMainTitle")}`
1533
1876
  options.unshift({ title: backTitle, value: backValue, description: "" })
1534
1877
  options.push({ title: backTitle, value: backValue, description: "" })
1535
1878
  const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
1536
1879
  dialog?.replace(() => (
1537
1880
  <api.ui.DialogSelect
1538
- title={zh ? "选择子代理" : "Select Sub-Agent"}
1881
+ title={t("subSelectTitle")}
1539
1882
  options={options}
1540
1883
  current={currentIdx >= 0 ? options[currentIdx].value : undefined}
1541
1884
  onSelect={(opt) => {
1542
1885
  if (opt.value === backValue) {
1543
1886
  signals.setOverrideSessionId(undefined)
1544
1887
  api.kv.set(`${KV_PREFIX}.session`, "")
1545
- api.ui.toast({ message: zh ? "已切回主会话" : "Switched to main session" })
1888
+ api.ui.toast({ message: t("backToMain") })
1546
1889
  } else {
1547
1890
  signals.setOverrideSessionId(opt.value)
1548
1891
  api.kv.set(`${KV_PREFIX}.session`, opt.value)
1549
- api.ui.toast({ message: (zh ? "已切换至子代理: " : "Showing sub-agent: ") + opt.value.slice(0, 24) + "\u2026" })
1892
+ api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) })
1550
1893
  }
1551
1894
  dialog?.clear()
1552
1895
  }}
@@ -1554,11 +1897,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1554
1897
  ))
1555
1898
  } else {
1556
1899
  // ── 无子代理 → DialogPrompt 手动粘贴 ──
1557
- const zh = langZH()
1900
+ const t = createT(() => langCode())
1558
1901
  dialog?.replace(() => (
1559
1902
  <api.ui.DialogPrompt
1560
- title={signals.overrideSessionId() ? zh ? "切换子代理" : "Switch Sub" : zh ? "查看子代理缓存" : "View Sub Cache"}
1561
- description={() => <text>{zh ? "未找到子代理,请手动粘贴 Session ID" : "No sub-agents found. Paste a Session ID manually"}</text>}
1903
+ title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")}
1904
+ description={() => <text>{t("subNoFound")}</text>}
1562
1905
  placeholder="ses_..."
1563
1906
  value={signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "") ?? ""}
1564
1907
  onConfirm={(val) => {
@@ -1566,7 +1909,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1566
1909
  if (sid) {
1567
1910
  signals.setOverrideSessionId(sid)
1568
1911
  api.kv.set(`${KV_PREFIX}.session`, sid)
1569
- api.ui.toast({ message: (langZH() ? "已切换至子代理: " : "Showing sub-agent: ") + sid.slice(0, 24) + "\u2026" })
1912
+ api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
1570
1913
  }
1571
1914
  dialog?.clear()
1572
1915
  }}
@@ -1582,9 +1925,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1582
1925
  description: "Return to main session stats",
1583
1926
  slash: { name: "cache-session-back" },
1584
1927
  onSelect: (dialog) => {
1928
+ const t = createT(() => langCode())
1585
1929
  signals.setOverrideSessionId(undefined)
1586
1930
  api.kv.set(`${KV_PREFIX}.session`, "")
1587
- api.ui.toast({ message: langZH() ? "已切回主会话" : "Switched to main session" })
1931
+ api.ui.toast({ message: t("backToMain") })
1588
1932
  dialog?.clear()
1589
1933
  },
1590
1934
  },