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/src/index.tsx CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  TuiPluginModule,
10
10
  TuiThemeCurrent,
11
11
  TuiDialogStack,
12
+ TuiPromptRef,
12
13
  } from "@opencode-ai/plugin/tui"
13
14
  import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
14
15
  import type {
@@ -21,6 +22,7 @@ import type {
21
22
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
22
23
  import { PLUGIN_VERSION } from "./_version"
23
24
  import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
25
+ import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
24
26
 
25
27
  // ---------------------------------------------------------------------------
26
28
  // Helpers
@@ -73,97 +75,14 @@ function truncateVisual(s: string, maxCols: number): string {
73
75
  return result
74
76
  }
75
77
 
76
- // ── language override (env: CACHE_TUI_LANG) ──
77
- const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
78
-
79
78
  // ── language ──────────────────────────────────────────────────────
79
+ // 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
80
+ // 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
80
81
 
81
- const LANG_ZH = DEBUG_LANG
82
- ? DEBUG_LANG === "zh"
83
- : (() => {
84
- try { return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh") }
85
- catch { return false }
86
- })()
87
-
88
- const ZH_T = {
89
- title: "缓存统计",
90
- hit: "命中率",
91
- totalHit: "总命中:",
92
- read: "缓存读:",
93
- write: "缓存写:",
94
- miss: "未命中:",
95
- out: "输出:",
96
- cost: "费用:",
97
- saved: "累计节省:",
98
- model: "模型:",
99
- provider: "提供商:",
100
- rate: "单价:",
101
- hitFolded: "命中",
102
- inputRate: "输入",
103
- cacheRate: "缓存",
104
- writeRate: "写入",
105
- noData: "等待缓存数据...",
106
- tok: "tok",
107
- distTitle: "估算 Token 分布",
108
- distSys: "系统提示:",
109
- distUser: "用户:",
110
- distAgent: "Agent 指令:",
111
- distTool: "Tool 调用:",
112
- distRes: "Tool 结果:",
113
- distTotal: "总计:",
114
- distOut: "输出:",
115
- secDetail: "明细",
116
- secModel: "模型",
117
- secSkills: "已加载技能",
118
- balTotal: "总余额:",
119
- balNoKey: "未配置 {p} API Key",
120
- balLoading: "查询中...",
121
- balError: "查询失败",
122
- balErr401: "API Key 无效",
123
- balErr403: "余额查询被拒绝",
124
- balErrEmpty:"未获取到余额数据",
125
- balErrTimeout: "查询超时",
126
- } as const
127
-
128
- const EN_T = {
129
- title: "Token Cache",
130
- hit: "Hit",
131
- totalHit: "Total Hit:",
132
- read: "Read:",
133
- write: "Write:",
134
- miss: "Miss:",
135
- out: "Out:",
136
- cost: "Cost:",
137
- saved: "Total Saved:",
138
- model: "Model:",
139
- provider: "Provider:",
140
- rate: "Rate:",
141
- hitFolded: "hit",
142
- inputRate: "in",
143
- cacheRate: "cache",
144
- writeRate: "write",
145
- noData: "Waiting for cache data...",
146
- tok: "tok",
147
- distTitle: "Estimated Token Dist.",
148
- distSys: "System:",
149
- distUser: "User:",
150
- distAgent: "Agent Instr:",
151
- distTool: "Tool Call:",
152
- distRes: "Tool Result:",
153
- distTotal: "Total:",
154
- distOut: "Output:",
155
- secDetail: "Detail",
156
- secModel: "Model",
157
- secSkills: "Loaded Skills",
158
- balTotal: "Total:",
159
- balNoKey: "{p} API Key not set",
160
- balLoading: "Fetching...",
161
- balError: "Fetch failed",
162
- balErr401: "Invalid API Key",
163
- balErr403: "Balance request rejected",
164
- balErrEmpty:"No balance data",
165
- balErrTimeout: "Request timed out",
166
- } 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()
167
86
 
168
87
  // ── color helpers ────────────────────────────────────────────────
169
88
 
@@ -224,7 +143,7 @@ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
224
143
  * converges to within a fraction of an 8‑bit step, eliminating
225
144
  * colour banding in edge cases.
226
145
  */
227
- // BT.601 luma (perceptual brightness used as the grey anchor)
146
+ // Bt.601 luma (perceptual brightness used as the grey anchor)
228
147
  const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
229
148
  let lo = 0, hi = 1
230
149
  for (let i = 0; i < 12; i++) {
@@ -340,13 +259,15 @@ function estimateTokens(text: string): number {
340
259
  interface TokenDist {
341
260
  system: number // UserMessage.system
342
261
  user: number // user message text/file parts
343
- agent: number // SubtaskPart.prompt + ReasoningPart.text
262
+ agent: number // task tool input prompt/description (sub-agent delegation)
344
263
  toolCall: number // ToolPart.input (actual tool params)
345
264
  toolResult: number // ToolPart completed output / error
346
- output: number // AssistantMessage.tokens.output (fallback)
265
+ output: number // AssistantMessage.tokens.output (API exact, reasoning excluded)
266
+ reasoning: number // AssistantMessage.tokens.reasoning (API exact)
347
267
  apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
348
- apiInput: number // StepFinishPart.tokens.input (API exact total context)
349
- stepCost: number
268
+ apiInput: number // API exact total input context (input + cache read + cache write)
269
+ stepCost: number // last step-finish part cost (USD) in the current round
270
+ stepCount: number // step-finish parts count across the current round (parentID chain)
350
271
  }
351
272
 
352
273
  // ---------------------------------------------------------------------------
@@ -401,6 +322,39 @@ function balanceSymbol(currency: string): string {
401
322
  return sym ?? currency + " "
402
323
  }
403
324
 
325
+ /** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */
326
+ function fmtCompact(n: number): string {
327
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
328
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
329
+ return String(Math.round(n))
330
+ }
331
+
332
+ /** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
333
+ function formatBalanceAmount(total: string): string {
334
+ const n = parseFloat(total)
335
+ if (!Number.isFinite(n)) return total
336
+ if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
337
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 })
338
+ }
339
+
340
+ /**
341
+ * 将余额列表格式化为单行文本。
342
+ * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
343
+ */
344
+ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
345
+ const native = pref ? list.find((x) => x.currency === pref) : undefined
346
+ if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
347
+ const base = list[0]
348
+ const baseAmt = parseFloat(base.total)
349
+ const converted = Number.isFinite(baseAmt)
350
+ ? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
351
+ : baseAmt
352
+ const shown = pref && base.currency !== pref
353
+ ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
354
+ : formatBalanceAmount(base.total)
355
+ return balanceSymbol(pref || base.currency) + shown
356
+ }
357
+
404
358
  // ---------------------------------------------------------------------------
405
359
  // Sidebar component
406
360
  // ---------------------------------------------------------------------------
@@ -413,8 +367,8 @@ interface PanelSignals {
413
367
  setCurrencySymbol: (v: string) => void
414
368
  exchangeRate: () => number
415
369
  setExchangeRate: (v: number) => void
416
- langZH: () => boolean
417
- setLangZH: (v: boolean) => void
370
+ langCode: () => LangCode
371
+ setLangCode: (v: LangCode) => void
418
372
  sectionDetail: () => boolean
419
373
  setSectionDetail: (v: boolean) => void
420
374
  sectionModel: () => boolean
@@ -425,6 +379,9 @@ interface PanelSignals {
425
379
  setSectionSkills: (v: boolean) => void
426
380
  sectionBalance: () => boolean
427
381
  setSectionBalance: (v: boolean) => void
382
+ /** Bottom status bar (prompt hint line) visibility. */
383
+ sectionBottom: () => boolean
384
+ setSectionBottom: (v: boolean) => void
428
385
  /** Increment to force a balance re-fetch. */
429
386
  balanceRefresh: () => number
430
387
  setBalanceRefresh: (v: number) => void
@@ -434,6 +391,11 @@ interface PanelSignals {
434
391
  /** Auto-switch to the session's provider for balance display. Manual switch disables it. */
435
392
  autoBalance: () => boolean
436
393
  setAutoBalance: (v: boolean) => void
394
+ /** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */
395
+ balanceUnsupported: () => boolean
396
+ setBalanceUnsupported: (v: boolean) => void
397
+ /** Shared balance query state — single source of truth for sidebar and bottom bar. */
398
+ balanceState: () => BalanceState
437
399
  /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
438
400
  balanceCurrency: () => string
439
401
  setBalanceCurrency: (v: string) => void
@@ -483,7 +445,7 @@ function TokenCachePanel(props: {
483
445
  const {
484
446
  currencySymbol, setCurrencySymbol,
485
447
  exchangeRate, setExchangeRate,
486
- langZH, setLangZH,
448
+ langCode,
487
449
  sectionDetail, setSectionDetail,
488
450
  sectionModel, setSectionModel,
489
451
  sectionDist, setSectionDist,
@@ -492,12 +454,14 @@ function TokenCachePanel(props: {
492
454
  balanceRefresh,
493
455
  balanceProviderId, setBalanceProviderId,
494
456
  autoBalance, setAutoBalance,
457
+ balanceUnsupported, setBalanceUnsupported,
458
+ balanceState,
495
459
  balanceCurrency, setBalanceCurrency,
496
460
  borderVisible, setBorderVisible,
497
461
  } = props.signals
498
462
 
499
- // ── reactive translation (follows langZH signal) ──
500
- const t = createMemo(() => langZH() ? ZH_T : EN_T)
463
+ // ── reactive translation (follows langCode signal) ──
464
+ const t = createT(() => langCode())
501
465
 
502
466
  // ── scan session messages reactively ──
503
467
  // SolidJS createMemo re-evaluates whenever the underlying
@@ -510,7 +474,7 @@ function TokenCachePanel(props: {
510
474
  // stable until the next successful computation arrives.
511
475
  const [lastDist, setLastDist] = createSignal<TokenDist>({
512
476
  system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
513
- output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
477
+ output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0,
514
478
  })
515
479
  const [lastHasDist, setLastHasDist] = createSignal(false)
516
480
 
@@ -519,61 +483,16 @@ function TokenCachePanel(props: {
519
483
  cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
520
484
  hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
521
485
  providerName: "", sessionHitRate: 0,
522
- dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
486
+ dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 },
523
487
  hasDistData: false,
524
488
  skills: [] as { name: string; tokens: number }[],
525
489
  hasSkills: false,
526
490
  })
527
491
  const [refreshTick, setRefreshTick] = createSignal(0)
528
492
 
529
- // ── balance state + polling ──────────────────────────────────
530
- const [balanceState, setBalanceState] = createSignal<BalanceState>({
531
- status: "idle", data: null, lastFetch: 0,
532
- })
533
- // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
534
- let balanceSeq = 0
535
-
536
- // 当前 provider 显示名
493
+ // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
537
494
  const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
538
495
 
539
- const pollBalance = async () => {
540
- const provider = getBalanceProvider(balanceProviderId())
541
- // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
542
- const key = props.api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
543
- || findOpencodeKey(props.api, provider)
544
- if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
545
- const now = Date.now()
546
- const prev = balanceState()
547
- // key 已更换(重新输入)→ 强制重新查询,绕过缓存
548
- if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
549
- const seq = ++balanceSeq
550
- setBalanceState({ ...prev, status: "loading", error: undefined, key })
551
- const controller = new AbortController()
552
- let timedOut = false
553
- const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
554
- try {
555
- const data = await provider.fetchBalance(key, controller.signal)
556
- clearTimeout(timer)
557
- if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
558
- setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
559
- } catch (err) {
560
- clearTimeout(timer)
561
- if (seq !== balanceSeq) return
562
- const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
563
- // 失败时清空旧数据,避免显示过期余额
564
- setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
565
- }
566
- }
567
-
568
- // Re-fetch when the API key is (re)configured via /cache-balance-key.
569
- // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
570
- // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
571
- // 形成无限循环(每次重跑都发起新的 fetch 请求)。
572
- createEffect(() => {
573
- void balanceRefresh()
574
- untrack(() => { void pollBalance() })
575
- })
576
-
577
496
  // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
578
497
  // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
579
498
  // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
@@ -599,9 +518,15 @@ function TokenCachePanel(props: {
599
518
  }
600
519
  if (!pid) return
601
520
  const hit = matchBalanceProvider(pid)
602
- if (hit && hit.id !== balanceProviderId()) {
603
- setBalanceProviderId(hit.id)
604
- props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
521
+ if (hit) {
522
+ setBalanceUnsupported(false)
523
+ if (hit.id !== balanceProviderId()) {
524
+ setBalanceProviderId(hit.id)
525
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
526
+ }
527
+ } else {
528
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
529
+ setBalanceUnsupported(true)
605
530
  }
606
531
  })
607
532
 
@@ -646,11 +571,11 @@ function TokenCachePanel(props: {
646
571
  let prevMsgHitRate = -1, lastMsgHitRate = -1
647
572
  for (const msg of msgs) {
648
573
  if (msg.role !== "assistant") continue
649
- const t = (msg as AssistantMessage).tokens; if (!t) continue
650
- const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read)
574
+ const tok = (msg as AssistantMessage).tokens; if (!tok) continue
575
+ const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read)
651
576
  if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
652
577
  if (fallbackTokens) {
653
- input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output)
578
+ input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output)
654
579
  }
655
580
  if (fallbackCost) {
656
581
  cost += num((msg as AssistantMessage).cost)
@@ -668,14 +593,15 @@ function TokenCachePanel(props: {
668
593
  break
669
594
  }
670
595
  const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
671
- const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
596
+ // 总命中率分母含缓存写(业界口径:read / (input+read+write)
597
+ const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
672
598
  const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
673
599
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
674
600
  const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
675
601
 
676
602
  // untrack 只包裹已知触发死锁的 API
677
603
  const distData = untrack(() => {
678
- let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
604
+ let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 }
679
605
  let hasDistData = false
680
606
  const loadedSkills = new Map<string, { name: string; tokens: number }>()
681
607
  try {
@@ -697,12 +623,20 @@ function TokenCachePanel(props: {
697
623
  } else if (msg.role === "assistant") {
698
624
  const am = msg as AssistantMessage
699
625
  dist.output += num(am.tokens?.output)
626
+ dist.reasoning += num(am.tokens?.reasoning)
700
627
  let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
701
628
  for (const p of parts) {
702
629
  if (p.type === "tool") {
703
630
  const tp = p as any; let rawInput = ""
704
631
  try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
705
632
  if (rawInput) dist.toolCall += estimateTokens(rawInput)
633
+ // 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part)
634
+ if (tp.tool === "task" && tp.state?.input) {
635
+ const ti = tp.state.input
636
+ const prompt = typeof ti.prompt === "string" ? ti.prompt : ""
637
+ const desc = typeof ti.description === "string" ? ti.description : ""
638
+ dist.agent += estimateTokens(prompt || desc)
639
+ }
706
640
  if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
707
641
  else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
708
642
  if (tp.tool === "skill" && tp.state.status === "completed") {
@@ -723,21 +657,40 @@ function TokenCachePanel(props: {
723
657
  }
724
658
  }
725
659
  }
726
- } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
727
- else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
660
+ } else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
728
661
  }
729
662
  }
730
663
  }
731
664
  // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
732
665
  for (let i = msgs.length - 1; i >= 0; i--) {
733
666
  if (msgs[i].role !== "assistant") continue
734
- const t = (msgs[i] as AssistantMessage).tokens
735
- if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
667
+ const tok = (msgs[i] as AssistantMessage).tokens
668
+ if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
736
669
  }
737
- // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
738
- dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
670
+ // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
671
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write)
739
672
  dist.apiOutput = num(lastAssMsg?.tokens?.output)
740
- hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
673
+ // 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。
674
+ // opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish),
675
+ // 故按 parentID 链聚合统计,而非单条消息。
676
+ if (lastAssMsg) {
677
+ const roundParent = (lastAssMsg as AssistantMessage).parentID
678
+ let lastCost: number | undefined
679
+ for (let i = msgs.length - 1; i >= 0; i--) {
680
+ const m = msgs[i]
681
+ if (m.role !== "assistant") continue
682
+ if ((m as AssistantMessage).parentID !== roundParent) break
683
+ let parts: readonly Part[] = []; try { parts = props.api.state.part(m.id) } catch {}
684
+ for (const p of parts) {
685
+ if (p.type !== "step-finish") continue
686
+ dist.stepCount++
687
+ const sc = (p as { cost?: unknown }).cost
688
+ if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc)) lastCost = sc
689
+ }
690
+ }
691
+ if (lastCost !== undefined) dist.stepCost = lastCost
692
+ }
693
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0
741
694
  } catch {}
742
695
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
743
696
  const skills = [...loadedSkills.values()]
@@ -808,6 +761,7 @@ function TokenCachePanel(props: {
808
761
  const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
809
762
  if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
810
763
  setBalanceProviderId(savedProvider)
764
+ setBalanceUnsupported(false)
811
765
  }
812
766
  // Restore auto-switch (default on)
813
767
  const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
@@ -828,11 +782,6 @@ function TokenCachePanel(props: {
828
782
  setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
829
783
  const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
830
784
  setBorderVisible(bv !== false)
831
- // Restore language preference
832
- const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
833
- if (savedLang === "zh" || savedLang === "en") {
834
- setLangZH(savedLang === "zh")
835
- }
836
785
  // Restore distribution snapshot so the token distribution block
837
786
  // doesn't blank out while api.state.part() re-hydrates.
838
787
  const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
@@ -880,8 +829,7 @@ function TokenCachePanel(props: {
880
829
  const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
881
830
  const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
882
831
  setRefreshTick(v => v + 1)
883
- const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
884
- onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() })
832
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
885
833
  })
886
834
 
887
835
  // ── colours ──
@@ -913,12 +861,14 @@ function TokenCachePanel(props: {
913
861
 
914
862
  const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
915
863
  function trendLabel(t: number): string {
916
- return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
864
+ // |t| < 0.05 视为无变化:避免显示 "0.0%" 的矛盾(箭头存在但数值截断为零)
865
+ if (Math.abs(t) < 0.05) return "-"
866
+ return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%"
917
867
  }
918
868
 
919
869
  const barW = createMemo(() => {
920
870
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
921
- const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
871
+ const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
922
872
  return Math.max(3, panelWidth() - overhead)
923
873
  })
924
874
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
@@ -964,7 +914,7 @@ function TokenCachePanel(props: {
964
914
  <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
965
915
  <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
966
916
  <span style={{ fg: pal().primary }}>
967
- <b>{t().title}</b>
917
+ <b>{t("title")}</b>
968
918
  <Show when={open()}>
969
919
  <span style={{ fg: dimColor(pal().muted, 0.75) }}> v{PLUGIN_VERSION}</span>
970
920
  </Show>
@@ -972,18 +922,18 @@ function TokenCachePanel(props: {
972
922
  <Show when={!open() && data().hasData}>
973
923
  <Show when={data().hasTrendData}>
974
924
  <span>
975
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))}
925
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend))))}
976
926
  </span>
977
- <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
978
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
927
+ <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
928
+ <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
979
929
  {" "}{trendLabel(data().trend)}
980
930
  </span>
981
931
  </Show>
982
932
  <Show when={!data().hasTrendData}>
983
933
  <span>
984
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))}
934
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded"))))}
985
935
  </span>
986
- <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
936
+ <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
987
937
  </Show>
988
938
  </Show>
989
939
  </text>
@@ -991,7 +941,7 @@ function TokenCachePanel(props: {
991
941
  <Show when={open()}>
992
942
  <Show when={props.signals.overrideSessionId()}>
993
943
  {(() => {
994
- const prefix = " \u21b3 " + (langZH() ? "\u5B50\u4EE3\u7406: " : "Sub: ")
944
+ const prefix = " \u21b3 " + t("subPrefix")
995
945
  const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix))
996
946
  return (
997
947
  <text>
@@ -1006,7 +956,7 @@ function TokenCachePanel(props: {
1006
956
  <text fg={pal().muted}>{sep()}</text>
1007
957
  <text>
1008
958
  <span style={{ fg: pal().muted }}>{"> "}</span>
1009
- <span style={{ fg: pal().muted }}>{t().noData}</span>
959
+ <span style={{ fg: pal().muted }}>{t("noData")}</span>
1010
960
  </text>
1011
961
  </>
1012
962
  }>
@@ -1014,11 +964,11 @@ function TokenCachePanel(props: {
1014
964
 
1015
965
  {/* hit rate + bar — inline to avoid box spacing */}
1016
966
  <text>
1017
- <span style={{ fg: pal().text }}>{t().hit} </span>
967
+ <span style={{ fg: pal().text }}>{t("hit")} </span>
1018
968
  <span style={{ fg: hitColor() }}>[{bar()}] </span>
1019
969
  <span style={{ fg: pal().text }}>{pct()}</span>
1020
970
  <Show when={data().hasTrendData}>
1021
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
971
+ <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
1022
972
  {" "}{trendLabel(data().trend)}
1023
973
  </span>
1024
974
  </Show>
@@ -1026,38 +976,45 @@ function TokenCachePanel(props: {
1026
976
 
1027
977
  {/* session cumulative hit rate */}
1028
978
  <text fg={pal().muted}>
1029
- {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
979
+ {justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
1030
980
  </text>
1031
981
 
1032
982
  {/* ── detail section (collapsible, default open) ── */}
1033
983
  <Show when={sectionDetail()}>
1034
984
  <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
1035
985
  <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
1036
- <span style={{ fg: pal().primary }}><b>{t().secDetail}</b></span>
1037
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))}</span>
986
+ <span style={{ fg: pal().primary }}><b>{t("secDetail")}</b></span>
987
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail")))}</span>
1038
988
  </text>
1039
989
 
1040
990
  <Show when={detailOpen()}>
1041
991
  <Show when={data().read > 0}>
1042
992
  <text fg={pal().muted}>
1043
- {justify(t().read, fmt(data().read), t().tok)}
993
+ {justify(t("read"), fmt(data().read), t("tok"))}
1044
994
  </text>
1045
995
  </Show>
1046
996
  <Show when={data().write > 0}>
1047
997
  <text fg={pal().muted}>
1048
- {justify(t().write, fmt(data().write), t().tok)}
998
+ {justify(t("write"), fmt(data().write), t("tok"))}
1049
999
  </text>
1050
1000
  </Show>
1001
+ {/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */}
1051
1002
  <text fg={pal().muted}>
1052
- {justify(t().miss, fmt(data().freshInput), t().tok)}
1003
+ {justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))}
1053
1004
  </text>
1054
1005
  <text fg={pal().muted}>
1055
- {justify(t().out, fmt(data().output), t().tok)}
1006
+ {justify(t("out"), fmt(data().output), t("tok"))}
1056
1007
  </text>
1008
+ {/* 本回合多次 API 调用时才显示调用次数与末次成本(单次调用不占行) */}
1009
+ <Show when={data().dist.stepCount >= 2}>
1010
+ <text fg={pal().muted}>
1011
+ {justify(t("stepsCount", { n: data().dist.stepCount }), fmtCost(data().dist.stepCost, currencySymbol(), exchangeRate()))}
1012
+ </text>
1013
+ </Show>
1057
1014
  <Show when={data().saved > 0}>
1058
1015
  <text>
1059
- <span style={{ fg: pal().muted }}>{t().saved}</span>
1060
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
1016
+ <span style={{ fg: pal().muted }}>{t("saved")}</span>
1017
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
1061
1018
  <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
1062
1019
  </text>
1063
1020
  </Show>
@@ -1068,34 +1025,34 @@ function TokenCachePanel(props: {
1068
1025
  <Show when={sectionModel()}>
1069
1026
  {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
1070
1027
  <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
1071
- <span style={{ fg: pal().primary }}><b>{t().secModel}</b></span>
1072
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))}</span>
1028
+ <span style={{ fg: pal().primary }}><b>{t("secModel")}</b></span>
1029
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel")))}</span>
1073
1030
  </text>}
1074
1031
 
1075
1032
  <Show when={modelOpen()}>
1076
1033
  <text fg={pal().text}>
1077
- {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
1034
+ {justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))}
1078
1035
  </text>
1079
1036
  <Show when={data().providerName}>
1080
1037
  <text fg={pal().muted}>
1081
- {justify(t().provider, data().providerName)}
1038
+ {justify(t("provider"), data().providerName)}
1082
1039
  </text>
1083
1040
  </Show>
1084
1041
  <text fg={pal().muted}>
1085
- {justify(t().model, data().model)}
1042
+ {justify(t("model"), data().model)}
1086
1043
  </text>
1087
1044
  <Show when={data().hasPricing}>
1088
1045
  <text fg={pal().muted}>
1089
- {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)}
1046
+ {justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))}
1090
1047
  </text>
1091
1048
  <Show when={data().cacheReadRate > 0}>
1092
1049
  <text fg={pal().muted}>
1093
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)}
1050
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))}
1094
1051
  </text>
1095
1052
  </Show>
1096
1053
  <Show when={data().cacheWriteRate > 0}>
1097
1054
  <text fg={pal().muted}>
1098
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)}
1055
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))}
1099
1056
  </text>
1100
1057
  </Show>
1101
1058
  </Show>
@@ -1107,38 +1064,40 @@ function TokenCachePanel(props: {
1107
1064
  <Show when={data().hasDistData}>
1108
1065
  {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
1109
1066
  <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
1110
- <span style={{ fg: pal().primary }}><b>{t().distTitle}</b></span>
1111
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))}</span>
1067
+ <span style={{ fg: pal().primary }}><b>{t("distTitle")}</b></span>
1068
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle")))}</span>
1112
1069
  </text>}
1113
1070
  <Show when={distOpen()}>
1114
1071
  <Show when={data().dist.system > 0}>
1115
1072
  <text fg={pal().muted}>
1116
- {justify(t().distSys, fmt(data().dist.system), t().tok)}
1073
+ {justify(t("distSys"), fmt(data().dist.system), t("tok"))}
1117
1074
  </text>
1118
1075
  </Show>
1119
1076
  <Show when={data().dist.user > 0}>
1120
1077
  <text fg={pal().muted}>
1121
- {justify(t().distUser, fmt(data().dist.user), t().tok)}
1078
+ {justify(t("distUser"), fmt(data().dist.user), t("tok"))}
1122
1079
  </text>
1123
1080
  </Show>
1124
1081
  <Show when={data().dist.agent > 0}>
1125
1082
  <text fg={pal().muted}>
1126
- {justify(t().distAgent, fmt(data().dist.agent), t().tok)}
1083
+ {justify(t("distAgent"), fmt(data().dist.agent), t("tok"))}
1127
1084
  </text>
1128
1085
  </Show>
1129
1086
  <Show when={data().dist.toolCall > 0}>
1130
1087
  <text fg={pal().muted}>
1131
- {justify(t().distTool, fmt(data().dist.toolCall), t().tok)}
1088
+ {justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))}
1132
1089
  </text>
1133
1090
  </Show>
1134
1091
  <Show when={data().dist.toolResult > 0}>
1135
1092
  <text fg={pal().muted}>
1136
- {justify(t().distRes, fmt(data().dist.toolResult), t().tok)}
1093
+ {justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
1094
+ </text>
1095
+ </Show>
1096
+ <Show when={data().dist.reasoning > 0}>
1097
+ <text fg={pal().muted}>
1098
+ {justify(t("distReason"), fmt(data().dist.reasoning), t("tok"))}
1137
1099
  </text>
1138
1100
  </Show>
1139
- <text fg={pal().text}>
1140
- {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)}
1141
- </text>
1142
1101
  </Show>
1143
1102
  </Show>
1144
1103
  </Show>
@@ -1148,18 +1107,18 @@ function TokenCachePanel(props: {
1148
1107
  <Show when={data().hasSkills}>
1149
1108
  {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
1150
1109
  <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
1151
- <span style={{ fg: pal().primary }}><b>{t().secSkills}</b></span>
1110
+ <span style={{ fg: pal().primary }}><b>{t("secSkills")}</b></span>
1152
1111
  <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
1153
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))}</span>
1112
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`))}</span>
1154
1113
  </text>}
1155
1114
  <Show when={skillsOpen()}>
1156
1115
  {data().skills.map((sk: { name: string; tokens: number }) => {
1157
- const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok)
1116
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"))
1158
1117
  const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
1159
1118
  const label = truncateVisual(sk.name, maxLabel)
1160
1119
  return (
1161
1120
  <text fg={pal().muted}>
1162
- {justify(label, fmt(sk.tokens), t().tok)}
1121
+ {justify(label, fmt(sk.tokens), t("tok"))}
1163
1122
  </text>
1164
1123
  )
1165
1124
  })}
@@ -1167,62 +1126,46 @@ function TokenCachePanel(props: {
1167
1126
  </Show>
1168
1127
  </Show>
1169
1128
 
1170
- {/* ── DeepSeek balance (single line) ── */}
1129
+ {/* ── provider balance (single line) ── */}
1171
1130
  <Show when={sectionBalance()}>
1172
1131
  <text fg={pal().muted}>{sep()}</text>
1173
- <Show when={balanceState().status === "idle"}>
1132
+ <Show when={balanceUnsupported()}>
1174
1133
  <text fg={pal().muted}>
1175
1134
  <span style={{ fg: pal().muted }}>{"> "}</span>
1176
- <span>{t().balNoKey.replace("{p}", providerName())}</span>
1135
+ <span>{t("balUnsupported")}</span>
1177
1136
  </text>
1178
1137
  </Show>
1179
- <Show when={balanceState().status === "loading"}>
1180
- <text fg={pal().muted}>
1181
- <span style={{ fg: pal().muted }}>{"> "}</span>
1182
- <span>{t().balLoading}</span>
1183
- </text>
1184
- </Show>
1185
- <Show when={balanceState().status === "error"}>
1186
- <text fg={pal().error}>
1187
- <span style={{ fg: pal().muted }}>{"> "}</span>
1188
- <span>{(() => {
1189
- const code = balanceState().error
1190
- if (code === "401") return t().balErr401
1191
- if (code === "403") return t().balErr403
1192
- if (code === "EMPTY") return t().balErrEmpty
1193
- if (code === "TIMEOUT") return t().balErrTimeout
1194
- return t().balError + (code ? ` (${code})` : "")
1195
- })()}</span>
1196
- </text>
1197
- </Show>
1198
- <Show when={balanceState().status === "ok" && balanceState().data}>
1199
- {(() => {
1200
- const list = balanceState().data!
1201
- const pref = balanceCurrency()
1202
- // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示
1203
- const native = pref ? list.find(x => x.currency === pref) : undefined
1204
- if (native) {
1205
- return (
1206
- <text fg={pal().text}>
1207
- {justify(t().balTotal, balanceSymbol(native.currency) + native.total)}
1208
- </text>
1209
- )
1210
- }
1211
- // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算
1212
- const base = list[0]
1213
- const baseAmt = parseFloat(base.total)
1214
- const converted = Number.isFinite(baseAmt)
1215
- ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency)
1216
- : baseAmt
1217
- const shown = pref && base.currency !== pref
1218
- ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
1219
- : base.total
1220
- return (
1221
- <text fg={pal().text}>
1222
- {justify(t().balTotal, balanceSymbol(pref || base.currency) + shown)}
1223
- </text>
1224
- )
1225
- })()}
1138
+ <Show when={!balanceUnsupported()}>
1139
+ <Show when={balanceState().status === "idle"}>
1140
+ <text fg={pal().muted}>
1141
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1142
+ <span>{t("balNoKey", { p: providerName() })}</span>
1143
+ </text>
1144
+ </Show>
1145
+ <Show when={balanceState().status === "loading"}>
1146
+ <text fg={pal().muted}>
1147
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1148
+ <span>{t("balLoading")}</span>
1149
+ </text>
1150
+ </Show>
1151
+ <Show when={balanceState().status === "error"}>
1152
+ <text fg={pal().error}>
1153
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1154
+ <span>{(() => {
1155
+ const code = balanceState().error
1156
+ if (code === "401") return t("balErr401")
1157
+ if (code === "403") return t("balErr403")
1158
+ if (code === "EMPTY") return t("balErrEmpty")
1159
+ if (code === "TIMEOUT") return t("balErrTimeout")
1160
+ return t("balError") + (code ? ` (${code})` : "")
1161
+ })()}</span>
1162
+ </text>
1163
+ </Show>
1164
+ <Show when={balanceState().status === "ok" && balanceState().data}>
1165
+ <text fg={pal().text}>
1166
+ {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
1167
+ </text>
1168
+ </Show>
1226
1169
  </Show>
1227
1170
  </Show>
1228
1171
  </Show>
@@ -1235,6 +1178,166 @@ function TokenCachePanel(props: {
1235
1178
  // Plugin entry
1236
1179
  // ---------------------------------------------------------------------------
1237
1180
 
1181
+ /**
1182
+ * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
1183
+ * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
1184
+ * 三合一信息与路径同行显示在中间位置。
1185
+ */
1186
+ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element {
1187
+ const KV_PREFIX = "cache_panel"
1188
+ const t = createT(() => props.signals.langCode())
1189
+
1190
+ const sid = props.sessionId
1191
+
1192
+ // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ──
1193
+ const stats = createMemo(() => {
1194
+ const id = sid
1195
+ if (!id) return null
1196
+ const msgs = props.api.state.session.messages(id) as Message[]
1197
+ const session = typeof props.api.state.session.get === "function"
1198
+ ? props.api.state.session.get(id)
1199
+ : undefined
1200
+ let input = session?.tokens?.input ?? 0
1201
+ let read = session?.tokens?.cache?.read ?? 0
1202
+ let write = session?.tokens?.cache?.write ?? 0
1203
+ // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
1204
+ if (session?.tokens == null) {
1205
+ for (const m of msgs) {
1206
+ if (m.role !== "assistant") continue
1207
+ const tk = (m as AssistantMessage).tokens
1208
+ if (!tk) continue
1209
+ input += num(tk.input)
1210
+ read += num(tk.cache?.read)
1211
+ write += num(tk.cache?.write)
1212
+ }
1213
+ }
1214
+ // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
1215
+ // 分母含缓存写(业界口径:read / (input+read+write))
1216
+ let hitRate = -1, prevHitRate = -1
1217
+ for (let i = msgs.length - 1; i >= 0; i--) {
1218
+ const m = msgs[i]
1219
+ if (m.role !== "assistant") continue
1220
+ const tk = (m as AssistantMessage).tokens
1221
+ if (!tk) continue
1222
+ const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
1223
+ const mrt = num(tk.cache?.read)
1224
+ if (mit <= 0) continue
1225
+ const rate = (mrt / mit) * 100
1226
+ if (hitRate < 0) { hitRate = rate; continue }
1227
+ prevHitRate = rate
1228
+ break
1229
+ }
1230
+ return { hitRate, prevHitRate, input, read, write }
1231
+ })
1232
+
1233
+ // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
1234
+
1235
+ // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
1236
+ createEffect(() => {
1237
+ if (!props.signals.autoBalance()) return
1238
+ const id = sid
1239
+ if (!id) return
1240
+ const msgs = props.api.state.session.messages(id) as Message[]
1241
+ let pid = ""
1242
+ for (let i = msgs.length - 1; i >= 0; i--) {
1243
+ const m = msgs[i]
1244
+ if (m.role === "assistant" && (m as AssistantMessage).providerID) { pid = (m as AssistantMessage).providerID; break }
1245
+ }
1246
+ if (!pid) {
1247
+ try { pid = props.api.state.session.get(id)?.model?.providerID ?? "" } catch {}
1248
+ }
1249
+ if (!pid) return
1250
+ const hit = matchBalanceProvider(pid)
1251
+ if (hit) {
1252
+ props.signals.setBalanceUnsupported(false)
1253
+ if (hit.id !== props.signals.balanceProviderId()) {
1254
+ props.signals.setBalanceProviderId(hit.id)
1255
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
1256
+ }
1257
+ } else {
1258
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
1259
+ props.signals.setBalanceUnsupported(true)
1260
+ }
1261
+ })
1262
+
1263
+ // ── 主题色(与侧边栏同口径)──
1264
+ const pal = createMemo(() => {
1265
+ const th = props.api.theme.current as Record<string, unknown>
1266
+ const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
1267
+ return {
1268
+ text: sat("text", FALLBACK.text),
1269
+ muted: sat("textMuted", FALLBACK.muted),
1270
+ success: sat("success", FALLBACK.success),
1271
+ warning: sat("warning", FALLBACK.warning),
1272
+ error: sat("error", FALLBACK.error),
1273
+ }
1274
+ })
1275
+
1276
+ const hitColor = createMemo(() => {
1277
+ const r = stats()?.hitRate ?? -1
1278
+ if (r >= 85) return pal().success
1279
+ if (r >= 70) return pal().warning
1280
+ return pal().error
1281
+ })
1282
+
1283
+ // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
1284
+ const trend = createMemo(() => {
1285
+ const s = stats()
1286
+ if (!s || s.prevHitRate < 0 || s.hitRate < 0) return null
1287
+ const d = s.hitRate - s.prevHitRate
1288
+ return Math.abs(d) < 0.05 ? null : d
1289
+ })
1290
+
1291
+ const balanceText = createMemo(() => {
1292
+ const s = props.signals.balanceState()
1293
+ if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate())
1294
+ if (s.status === "loading") return "\u2026"
1295
+ if (s.status === "error") return "\u26a0"
1296
+ return "-"
1297
+ })
1298
+
1299
+ // 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
1300
+ const directory = createMemo(() => {
1301
+ try { return props.api.state.path.directory } catch { return "" }
1302
+ })
1303
+
1304
+ // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
1305
+ onMount(() => {
1306
+ try {
1307
+ const v = props.api.kv.get<boolean>(`${KV_PREFIX}.section.bottom`, true)
1308
+ props.signals.setSectionBottom(v !== false)
1309
+ } catch {}
1310
+ })
1311
+
1312
+ return (
1313
+ <Show when={props.signals.sectionBottom()} fallback={<text fg={pal().muted}>{directory()}</text>}>
1314
+ <box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
1315
+ <text fg={pal().muted}>{directory()}</text>
1316
+ <box flexDirection="row">
1317
+ <text>
1318
+ <span style={{ fg: pal().muted }}>{t("barHit")} </span>
1319
+ <span style={{ fg: hitColor() }}>{(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"}</span>
1320
+ <Show when={trend() !== null}>
1321
+ <span style={{ fg: trend()! > 0 ? pal().success : pal().error }}>
1322
+ {" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"}
1323
+ </span>
1324
+ </Show>
1325
+ <span style={{ fg: pal().muted }}>{" \u00b7 " + t("barTok") + " "}</span>
1326
+ <span style={{ fg: pal().text }}>
1327
+ {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"}
1328
+ </span>
1329
+ <Show when={!props.signals.balanceUnsupported()}>
1330
+ <span style={{ fg: pal().muted }}>{" \u00b7 " + t("barBal") + " "}</span>
1331
+ <span style={{ fg: pal().text }}>{balanceText()}</span>
1332
+ </Show>
1333
+ </text>
1334
+ <text fg={pal().muted}>{" \u00b7 "}</text>
1335
+ </box>
1336
+ </box>
1337
+ </Show>
1338
+ )
1339
+ }
1340
+
1238
1341
  function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
1239
1342
  let lastSlotSid = ""
1240
1343
  return {
@@ -1271,26 +1374,39 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1271
1374
  const [sectionDist, setSectionDist] = createSignal(true)
1272
1375
  const [sectionSkills, setSectionSkills] = createSignal(true)
1273
1376
  const [sectionBalance, setSectionBalance] = createSignal(true)
1377
+ const [sectionBottom, setSectionBottom] = createSignal(true)
1274
1378
  const [balanceRefresh, setBalanceRefresh] = createSignal(0)
1275
1379
  const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
1276
1380
  const [autoBalance, setAutoBalance] = createSignal(true)
1381
+ const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
1277
1382
  const [balanceCurrency, setBalanceCurrency] = createSignal("")
1278
1383
  const [borderVisible, setBorderVisible] = createSignal(true)
1279
- const [langZH, setLangZH] = createSignal(LANG_ZH)
1384
+ const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
1280
1385
  const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
1281
1386
 
1387
+ // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1388
+ // 避免重复请求导致两处余额不一致 ──
1389
+ const [balanceState, setBalanceState] = createSignal<BalanceState>({
1390
+ status: "idle", data: null, lastFetch: 0,
1391
+ })
1392
+ // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
1393
+ let balanceSeq = 0
1394
+
1282
1395
  const signals: PanelSignals = {
1283
1396
  currencySymbol, setCurrencySymbol,
1284
1397
  exchangeRate, setExchangeRate,
1285
- langZH, setLangZH,
1398
+ langCode, setLangCode,
1286
1399
  sectionDetail, setSectionDetail,
1287
1400
  sectionModel, setSectionModel,
1288
1401
  sectionDist, setSectionDist,
1289
1402
  sectionSkills, setSectionSkills,
1290
1403
  sectionBalance, setSectionBalance,
1404
+ sectionBottom, setSectionBottom,
1291
1405
  balanceRefresh, setBalanceRefresh,
1292
1406
  balanceProviderId, setBalanceProviderId,
1293
1407
  autoBalance, setAutoBalance,
1408
+ balanceUnsupported, setBalanceUnsupported,
1409
+ balanceState,
1294
1410
  balanceCurrency, setBalanceCurrency,
1295
1411
  borderVisible, setBorderVisible,
1296
1412
  overrideSessionId, setOverrideSessionId,
@@ -1298,31 +1414,120 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1298
1414
 
1299
1415
  api.slots.register(createSidebarSlot(api, signals))
1300
1416
 
1417
+ // 输入框 hint 行(session_prompt slot,replace 模式):
1418
+ // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
1419
+ // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
1420
+ api.slots.register({
1421
+ order: 55,
1422
+ slots: {
1423
+ session_prompt(
1424
+ _ctx: TuiSlotContext,
1425
+ input: {
1426
+ session_id: string
1427
+ visible?: boolean
1428
+ disabled?: boolean
1429
+ on_submit?: () => void
1430
+ ref?: (ref: TuiPromptRef | undefined) => void
1431
+ },
1432
+ ): JSX.Element {
1433
+ return (
1434
+ <api.ui.Prompt
1435
+ sessionID={input.session_id}
1436
+ visible={input.visible}
1437
+ disabled={input.disabled}
1438
+ onSubmit={input.on_submit}
1439
+ ref={input.ref}
1440
+ hint={<BottomStatusBar api={api} signals={signals} sessionId={input.session_id} />}
1441
+ />
1442
+ )
1443
+ },
1444
+ },
1445
+ })
1446
+
1301
1447
  // ── slash commands for runtime config ──
1302
1448
  const KV_PREFIX = "cache_panel"
1303
1449
 
1450
+ // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
1451
+ const restoreLang = () => {
1452
+ try {
1453
+ const saved = api.kv.get<string>(`${KV_PREFIX}.lang`)
1454
+ if (saved && LANG_META.some((m) => m.code === saved)) setLangCode(saved as LangCode)
1455
+ } catch {}
1456
+ }
1457
+ if (api.kv.ready) {
1458
+ restoreLang()
1459
+ } else {
1460
+ const langTimer = setInterval(() => {
1461
+ if (api.kv.ready) { clearInterval(langTimer); restoreLang() }
1462
+ }, 10)
1463
+ api.lifecycle.onDispose(() => clearInterval(langTimer))
1464
+ }
1465
+
1466
+ const pollBalance = async () => {
1467
+ const provider = getBalanceProvider(balanceProviderId())
1468
+ // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
1469
+ const key = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1470
+ || findOpencodeKey(api, provider)
1471
+ if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
1472
+ if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
1473
+ const now = Date.now()
1474
+ const prev = balanceState()
1475
+ // key 已更换(重新输入)→ 强制重新查询,绕过缓存
1476
+ if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
1477
+ const seq = ++balanceSeq
1478
+ setBalanceState({ ...prev, status: "loading", error: undefined, key })
1479
+ const controller = new AbortController()
1480
+ let timedOut = false
1481
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
1482
+ try {
1483
+ const data = await provider.fetchBalance(key, controller.signal)
1484
+ clearTimeout(timer)
1485
+ if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
1486
+ setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
1487
+ } catch (err) {
1488
+ clearTimeout(timer)
1489
+ if (seq !== balanceSeq) return
1490
+ const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
1491
+ // 失败时清空旧数据,避免显示过期余额
1492
+ setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
1493
+ }
1494
+ }
1495
+
1496
+ // Re-fetch when the API key is (re)configured via /cache-balance-key.
1497
+ // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
1498
+ // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
1499
+ // 形成无限循环(每次重跑都发起新的 fetch 请求)。
1500
+ createEffect(() => {
1501
+ void balanceRefresh()
1502
+ untrack(() => { void pollBalance() })
1503
+ })
1504
+
1505
+ // 定时轮询(5 分钟);随插件生命周期清理
1506
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
1507
+ api.lifecycle.onDispose(() => clearInterval(balanceTimer))
1508
+
1304
1509
  /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
1305
1510
  const providerOptionTitle = (p: BalanceProvider, current?: string) => {
1306
- const zh = langZH()
1511
+ const t = createT(() => langCode())
1307
1512
  const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
1308
1513
  const hasAuto = !hasManual && !!findOpencodeKey(api, p)
1309
1514
  const mark = hasManual
1310
- ? (zh ? "(用户 key)" : " (user key)")
1515
+ ? t("keyUser")
1311
1516
  : hasAuto
1312
- ? (zh ? "(OpenCode)" : " (OpenCode)")
1313
- : (zh ? "(未配置)" : " (not set)")
1517
+ ? t("keyOpenCode")
1518
+ : t("keyNotSet")
1314
1519
  return p.name + mark + (current && p.id === current ? " *" : "")
1315
1520
  }
1316
1521
 
1317
1522
  /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
1318
1523
  const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
1319
- const zh = langZH()
1524
+ const t = createT(() => langCode())
1320
1525
  const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1321
1526
  const masked = maskKey(current)
1322
1527
  dialog?.replace(() => (
1323
1528
  <api.ui.DialogPrompt
1324
1529
  title={provider.name}
1325
- description={() => <text>{zh ? `输入 ${provider.name} API Key 以显示账户余额(留空清除)` : `Enter your ${provider.name} API key to show account balance (leave empty to clear)`}</text>}
1530
+ description={() => <text>{t("balKeyPrompt", { p: provider.name })}</text>}
1326
1531
  placeholder={provider.keyPlaceholder ?? "sk-..."}
1327
1532
  value={masked}
1328
1533
  onConfirm={(val) => {
@@ -1338,9 +1543,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1338
1543
  api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
1339
1544
  setBalanceRefresh(v => v + 1)
1340
1545
  if (key) {
1341
- api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." })
1546
+ api.ui.toast({ message: t("keySaved") })
1342
1547
  } else {
1343
- api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" })
1548
+ api.ui.toast({ message: t("keyCleared") })
1344
1549
  }
1345
1550
  dialog?.clear()
1346
1551
  }}
@@ -1364,6 +1569,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1364
1569
  value: code,
1365
1570
  }))}
1366
1571
  onSelect={(opt) => {
1572
+ const t = createT(() => langCode())
1367
1573
  const sym = CURRENCIES[opt.value] ?? "$"
1368
1574
  const defRate = DEFAULT_RATES[opt.value] ?? 1
1369
1575
  api.kv.set(`${KV_PREFIX}.currency`, sym)
@@ -1373,7 +1579,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1373
1579
  signals.setBalanceCurrency(opt.value)
1374
1580
  signals.setCurrencySymbol(sym)
1375
1581
  signals.setExchangeRate(defRate)
1376
- api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
1582
+ api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) })
1377
1583
  dialog?.clear()
1378
1584
  }}
1379
1585
  />
@@ -1393,11 +1599,12 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1393
1599
  placeholder="1.0"
1394
1600
  value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
1395
1601
  onConfirm={(val) => {
1602
+ const t = createT(() => langCode())
1396
1603
  const n = parseFloat(val)
1397
1604
  if (n > 0) {
1398
1605
  api.kv.set(`${KV_PREFIX}.rate`, n)
1399
1606
  signals.setExchangeRate(n)
1400
- api.ui.toast({ message: `Exchange rate set to ${n}` })
1607
+ api.ui.toast({ message: t("rateSet", { r: n }) })
1401
1608
  }
1402
1609
  dialog?.clear()
1403
1610
  }}
@@ -1411,29 +1618,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1411
1618
  description: "Show or hide a sidebar section",
1412
1619
  slash: { name: "cache-section" },
1413
1620
  onSelect: (dialog) => {
1621
+ const t = createT(() => langCode())
1414
1622
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1415
1623
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1416
1624
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1417
1625
  const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1418
1626
  const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1627
+ const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
1419
1628
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1629
+ const labels: Record<string, string> = {
1630
+ detail: t("secDetail"),
1631
+ model: t("secModel"),
1632
+ dist: t("distTitle"),
1633
+ skills: t("secSkills"),
1634
+ balance: t("secBalance"),
1635
+ bottom: t("secBottom"),
1636
+ border: t("secBorder"),
1637
+ }
1638
+ const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`
1420
1639
  dialog?.replace(() => (
1421
1640
  <api.ui.DialogSelect
1422
- title="Toggle Section"
1641
+ title={t("secToggle")}
1423
1642
  options={[
1424
- { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
1425
- { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
1426
- { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1427
- { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
1428
- { title: `Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" },
1429
- { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1643
+ { title: optTitle(labels.detail, detailOn), value: "detail" },
1644
+ { title: optTitle(labels.model, modelOn), value: "model" },
1645
+ { title: optTitle(labels.dist, distOn), value: "dist" },
1646
+ { title: optTitle(labels.skills, skillsOn), value: "skills" },
1647
+ { title: optTitle(labels.balance, balanceOn), value: "balance" },
1648
+ { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
1649
+ { title: optTitle(labels.border, borderOn), value: "border" },
1430
1650
  ]}
1431
1651
  onSelect={(opt) => {
1432
1652
  if (opt.value === "border") {
1433
1653
  const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1434
1654
  api.kv.set(`${KV_PREFIX}.border`, !cur)
1435
1655
  signals.setBorderVisible(!cur)
1436
- api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
1656
+ api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
1437
1657
  } else {
1438
1658
  const key = `${KV_PREFIX}.section.${opt.value}`
1439
1659
  const cur = Boolean(api.kv.get(key, true))
@@ -1443,7 +1663,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1443
1663
  if (opt.value === "dist") signals.setSectionDist(!cur)
1444
1664
  if (opt.value === "skills") signals.setSectionSkills(!cur)
1445
1665
  if (opt.value === "balance") signals.setSectionBalance(!cur)
1446
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1666
+ if (opt.value === "bottom") signals.setSectionBottom(!cur)
1667
+ const name = labels[opt.value] ?? opt.value
1668
+ api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) })
1447
1669
  }
1448
1670
  dialog?.clear()
1449
1671
  }}
@@ -1457,6 +1679,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1457
1679
  description: "Display the current plugin configuration",
1458
1680
  slash: { name: "cache-config" },
1459
1681
  onSelect: (dialog) => {
1682
+ const t = createT(() => langCode())
1460
1683
  const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
1461
1684
  const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
1462
1685
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
@@ -1464,9 +1687,16 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1464
1687
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1465
1688
  const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1466
1689
  const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1690
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
1691
+ const on = (v: boolean) => v ? "ON" : "OFF"
1467
1692
  api.ui.toast({
1468
- title: "Cache Panel Config",
1469
- 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"}`,
1693
+ title: t("panelConfigTitle"),
1694
+ message: t("panelConfigMsg", {
1695
+ c: sym, r: rate,
1696
+ d: on(detail), m: on(model),
1697
+ t: on(dist), k: on(skills),
1698
+ b: on(balance), f: on(bottom),
1699
+ }),
1470
1700
  duration: 8000,
1471
1701
  })
1472
1702
  dialog?.clear()
@@ -1478,19 +1708,20 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1478
1708
  description: "Switch between Chinese and English display",
1479
1709
  slash: { name: "cache-lang" },
1480
1710
  onSelect: (dialog) => {
1481
- const cur = langZH()
1711
+ const t = createT(() => langCode())
1712
+ const cur = langCode()
1482
1713
  dialog?.replace(() => (
1483
1714
  <api.ui.DialogSelect
1484
- title="Display Language"
1485
- options={[
1486
- { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1487
- { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1488
- ]}
1715
+ title={t("langTitle")}
1716
+ options={LANG_META.map((m) => ({
1717
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
1718
+ value: m.code,
1719
+ }))}
1489
1720
  onSelect={(opt) => {
1490
- const zh = opt.value === "zh"
1491
- api.kv.set(`${KV_PREFIX}.lang`, opt.value)
1492
- setLangZH(zh)
1493
- api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" })
1721
+ const code = opt.value as LangCode
1722
+ api.kv.set(`${KV_PREFIX}.lang`, code)
1723
+ setLangCode(code)
1724
+ api.ui.toast({ message: t("langSwitched") })
1494
1725
  dialog?.clear()
1495
1726
  }}
1496
1727
  />
@@ -1503,15 +1734,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1503
1734
  description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
1504
1735
  slash: { name: "cache-balance" },
1505
1736
  onSelect: (dialog) => {
1506
- const zh = langZH()
1737
+ const t = createT(() => langCode())
1507
1738
  const current = signals.balanceProviderId()
1508
1739
  const auto = signals.autoBalance()
1509
- const autoLabel = auto
1510
- ? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]")
1511
- : (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]")
1740
+ const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
1512
1741
  dialog?.replace(() => (
1513
1742
  <api.ui.DialogSelect
1514
- title={zh ? "余额提供商 / 自动切换" : "Balance Provider / Auto-switch"}
1743
+ title={t("balProvTitle")}
1515
1744
  options={[
1516
1745
  {
1517
1746
  title: autoLabel,
@@ -1527,7 +1756,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1527
1756
  const next = !auto
1528
1757
  api.kv.set(`${KV_PREFIX}.balance.auto`, next)
1529
1758
  signals.setAutoBalance(next)
1530
- api.ui.toast({ message: zh ? `自动切换余额提供商: ${next ? "" : ""}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` })
1759
+ api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") })
1531
1760
  dialog?.clear()
1532
1761
  } else {
1533
1762
  const provider = getBalanceProvider(opt.value)
@@ -1536,6 +1765,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1536
1765
  api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1537
1766
  signals.setBalanceProviderId(provider.id)
1538
1767
  signals.setAutoBalance(false)
1768
+ signals.setBalanceUnsupported(false)
1539
1769
  // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
1540
1770
  signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1541
1771
  const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
@@ -1543,7 +1773,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1543
1773
  // 未配置 key → 进入设置流程(对话框保持打开等待输入)
1544
1774
  promptBalanceKey(dialog, provider)
1545
1775
  } else {
1546
- api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动切换已关闭)` : `Balance provider: ${provider.name} (auto-switch off)` })
1776
+ api.ui.toast({ message: t("providerManual", { p: provider.name }) })
1547
1777
  dialog?.clear()
1548
1778
  }
1549
1779
  }
@@ -1558,11 +1788,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1558
1788
  description: "Select a provider and set its API key for balance display",
1559
1789
  slash: { name: "cache-balance-key" },
1560
1790
  onSelect: (dialog) => {
1561
- const zh = langZH()
1791
+ const t = createT(() => langCode())
1562
1792
  // 步骤 1:选择 provider
1563
1793
  dialog?.replace(() => (
1564
1794
  <api.ui.DialogSelect
1565
- title={zh ? "选择余额提供商" : "Select Balance Provider"}
1795
+ title={t("balSelectTitle")}
1566
1796
  options={balanceProviders.map((p) => ({
1567
1797
  title: providerOptionTitle(p),
1568
1798
  value: p.id,
@@ -1589,9 +1819,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1589
1819
  description: "Dump all tool parts found in the current session for skill detection debugging",
1590
1820
  slash: { name: "cache-debug-skills" },
1591
1821
  onSelect: () => {
1822
+ const t = createT(() => langCode())
1592
1823
  const rt = api.route.current
1593
1824
  if (rt.name !== "session" || !rt.params) {
1594
- api.ui.toast({ message: "Please run this command inside a session", variant: "warning" })
1825
+ api.ui.toast({ message: t("runInSession"), variant: "warning" })
1595
1826
  return
1596
1827
  }
1597
1828
  const sid = String(rt.params.sessionID)
@@ -1669,7 +1900,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1669
1900
 
1670
1901
  if (unique.length > 0) {
1671
1902
  // ── 有子代理 → DialogSelect 列表选择 ──
1672
- const zh = langZH()
1903
+ const t = createT(() => langCode())
1673
1904
  const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
1674
1905
  const options = unique.map((c, i) => ({
1675
1906
  title: `${i + 1}. ${c.title}`,
@@ -1678,24 +1909,24 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1678
1909
  }))
1679
1910
  // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
1680
1911
  const backValue = "__main__"
1681
- const backTitle = `\u2500 ${zh ? "\u56DE\u5230\u4E3B\u4F1A\u8BDD" : "Back to Main"}`
1912
+ const backTitle = `\u2500 ${t("backToMainTitle")}`
1682
1913
  options.unshift({ title: backTitle, value: backValue, description: "" })
1683
1914
  options.push({ title: backTitle, value: backValue, description: "" })
1684
1915
  const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
1685
1916
  dialog?.replace(() => (
1686
1917
  <api.ui.DialogSelect
1687
- title={zh ? "选择子代理" : "Select Sub-Agent"}
1918
+ title={t("subSelectTitle")}
1688
1919
  options={options}
1689
1920
  current={currentIdx >= 0 ? options[currentIdx].value : undefined}
1690
1921
  onSelect={(opt) => {
1691
1922
  if (opt.value === backValue) {
1692
1923
  signals.setOverrideSessionId(undefined)
1693
1924
  api.kv.set(`${KV_PREFIX}.session`, "")
1694
- api.ui.toast({ message: zh ? "已切回主会话" : "Switched to main session" })
1925
+ api.ui.toast({ message: t("backToMain") })
1695
1926
  } else {
1696
1927
  signals.setOverrideSessionId(opt.value)
1697
1928
  api.kv.set(`${KV_PREFIX}.session`, opt.value)
1698
- api.ui.toast({ message: (zh ? "已切换至子代理: " : "Showing sub-agent: ") + opt.value.slice(0, 24) + "\u2026" })
1929
+ api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) })
1699
1930
  }
1700
1931
  dialog?.clear()
1701
1932
  }}
@@ -1703,11 +1934,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1703
1934
  ))
1704
1935
  } else {
1705
1936
  // ── 无子代理 → DialogPrompt 手动粘贴 ──
1706
- const zh = langZH()
1937
+ const t = createT(() => langCode())
1707
1938
  dialog?.replace(() => (
1708
1939
  <api.ui.DialogPrompt
1709
- title={signals.overrideSessionId() ? zh ? "切换子代理" : "Switch Sub" : zh ? "查看子代理缓存" : "View Sub Cache"}
1710
- description={() => <text>{zh ? "未找到子代理,请手动粘贴 Session ID" : "No sub-agents found. Paste a Session ID manually"}</text>}
1940
+ title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")}
1941
+ description={() => <text>{t("subNoFound")}</text>}
1711
1942
  placeholder="ses_..."
1712
1943
  value={signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "") ?? ""}
1713
1944
  onConfirm={(val) => {
@@ -1715,7 +1946,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1715
1946
  if (sid) {
1716
1947
  signals.setOverrideSessionId(sid)
1717
1948
  api.kv.set(`${KV_PREFIX}.session`, sid)
1718
- api.ui.toast({ message: (langZH() ? "已切换至子代理: " : "Showing sub-agent: ") + sid.slice(0, 24) + "\u2026" })
1949
+ api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
1719
1950
  }
1720
1951
  dialog?.clear()
1721
1952
  }}
@@ -1731,9 +1962,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1731
1962
  description: "Return to main session stats",
1732
1963
  slash: { name: "cache-session-back" },
1733
1964
  onSelect: (dialog) => {
1965
+ const t = createT(() => langCode())
1734
1966
  signals.setOverrideSessionId(undefined)
1735
1967
  api.kv.set(`${KV_PREFIX}.session`, "")
1736
- api.ui.toast({ message: langZH() ? "已切回主会话" : "Switched to main session" })
1968
+ api.ui.toast({ message: t("backToMain") })
1737
1969
  dialog?.clear()
1738
1970
  },
1739
1971
  },