opencode-visual-cache 1.3.0 → 1.5.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,7 @@ import type {
8
8
  TuiSlotPlugin,
9
9
  TuiPluginModule,
10
10
  TuiThemeCurrent,
11
+ TuiDialogStack,
11
12
  } from "@opencode-ai/plugin/tui"
12
13
  import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
13
14
  import type {
@@ -19,6 +20,7 @@ import type {
19
20
  } from "@opencode-ai/sdk/v2"
20
21
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
21
22
  import { PLUGIN_VERSION } from "./_version"
23
+ import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
22
24
 
23
25
  // ---------------------------------------------------------------------------
24
26
  // Helpers
@@ -113,6 +115,14 @@ const ZH_T = {
113
115
  secDetail: "明细",
114
116
  secModel: "模型",
115
117
  secSkills: "已加载技能",
118
+ balTotal: "总余额:",
119
+ balNoKey: "未配置 {p} API Key",
120
+ balLoading: "查询中...",
121
+ balError: "查询失败",
122
+ balErr401: "API Key 无效",
123
+ balErr403: "余额查询被拒绝",
124
+ balErrEmpty:"未获取到余额数据",
125
+ balErrTimeout: "查询超时",
116
126
  } as const
117
127
 
118
128
  const EN_T = {
@@ -145,6 +155,14 @@ const EN_T = {
145
155
  secDetail: "Detail",
146
156
  secModel: "Model",
147
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",
148
166
  } as const
149
167
 
150
168
  // ── color helpers ────────────────────────────────────────────────
@@ -331,6 +349,58 @@ interface TokenDist {
331
349
  stepCost: number
332
350
  }
333
351
 
352
+ // ---------------------------------------------------------------------------
353
+ // Balance state
354
+ // ---------------------------------------------------------------------------
355
+
356
+ interface BalanceState {
357
+ status: "idle" | "loading" | "ok" | "error"
358
+ data: BalanceEntry[] | null
359
+ lastFetch: number
360
+ error?: string
361
+ key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换
362
+ }
363
+
364
+ const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes
365
+
366
+ /**
367
+ * 将余额从来源币种换算为目标币种。
368
+ * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
369
+ */
370
+ function convertBalance(target: string, targetRate: number, amount: number, from: string): number {
371
+ if (from === target) return amount
372
+ const fromRate = DEFAULT_RATES[from] ?? 1
373
+ const usd = from === "USD" ? amount : amount / fromRate
374
+ return target === "USD" ? usd : usd * targetRate
375
+ }
376
+
377
+ /**
378
+ * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
379
+ * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
380
+ * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。
381
+ * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
382
+ */
383
+ function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
384
+ try {
385
+ const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
386
+ // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
387
+ const id = provider.id.toLowerCase()
388
+ const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
389
+ if (!hit) return ""
390
+ const k = typeof hit.key === "string" ? hit.key : ""
391
+ if (k) return k
392
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : ""
393
+ } catch {
394
+ return ""
395
+ }
396
+ }
397
+
398
+ /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
399
+ function balanceSymbol(currency: string): string {
400
+ const sym = CURRENCIES[currency]
401
+ return sym ?? currency + " "
402
+ }
403
+
334
404
  // ---------------------------------------------------------------------------
335
405
  // Sidebar component
336
406
  // ---------------------------------------------------------------------------
@@ -353,6 +423,20 @@ interface PanelSignals {
353
423
  setSectionDist: (v: boolean) => void
354
424
  sectionSkills: () => boolean
355
425
  setSectionSkills: (v: boolean) => void
426
+ sectionBalance: () => boolean
427
+ setSectionBalance: (v: boolean) => void
428
+ /** Increment to force a balance re-fetch. */
429
+ balanceRefresh: () => number
430
+ setBalanceRefresh: (v: number) => void
431
+ /** Currently selected balance provider id (e.g. "deepseek"). */
432
+ balanceProviderId: () => string
433
+ setBalanceProviderId: (v: string) => void
434
+ /** Auto-switch to the session's provider for balance display. Manual switch disables it. */
435
+ autoBalance: () => boolean
436
+ setAutoBalance: (v: boolean) => void
437
+ /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
438
+ balanceCurrency: () => string
439
+ setBalanceCurrency: (v: string) => void
356
440
  borderVisible: () => boolean
357
441
  setBorderVisible: (v: boolean) => void
358
442
  /** When set, the panel renders stats for this session instead of the main one. */
@@ -404,6 +488,11 @@ function TokenCachePanel(props: {
404
488
  sectionModel, setSectionModel,
405
489
  sectionDist, setSectionDist,
406
490
  sectionSkills, setSectionSkills,
491
+ sectionBalance, setSectionBalance,
492
+ balanceRefresh,
493
+ balanceProviderId, setBalanceProviderId,
494
+ autoBalance, setAutoBalance,
495
+ balanceCurrency, setBalanceCurrency,
407
496
  borderVisible, setBorderVisible,
408
497
  } = props.signals
409
498
 
@@ -437,6 +526,85 @@ function TokenCachePanel(props: {
437
526
  })
438
527
  const [refreshTick, setRefreshTick] = createSignal(0)
439
528
 
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 显示名
537
+ const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
538
+
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
+ // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
578
+ // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
579
+ // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
580
+ createEffect(() => {
581
+ if (!autoBalance()) return
582
+ const sid = props.signals.overrideSessionId() ?? props.sessionId
583
+ const msgs = props.api.state.session.messages(sid) as Message[]
584
+ let pid = ""
585
+ for (let i = msgs.length - 1; i >= 0; i--) {
586
+ const m = msgs[i]
587
+ if (m.role === "assistant" && (m as AssistantMessage).providerID) {
588
+ pid = (m as AssistantMessage).providerID
589
+ break
590
+ }
591
+ }
592
+ // 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
593
+ // → 回退到会话级模型元数据,反映当前正在使用的 provider
594
+ if (!pid) {
595
+ try {
596
+ const session = props.api.state.session.get(sid)
597
+ pid = session?.model?.providerID ?? ""
598
+ } catch { /* ignore */ }
599
+ }
600
+ if (!pid) return
601
+ const hit = matchBalanceProvider(pid)
602
+ if (hit && hit.id !== balanceProviderId()) {
603
+ setBalanceProviderId(hit.id)
604
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
605
+ }
606
+ })
607
+
440
608
  // ── auto-clear override when the user navigates to a different main session ──
441
609
  let lastMainSid = props.sessionId
442
610
  createEffect(() => {
@@ -634,10 +802,30 @@ function TokenCachePanel(props: {
634
802
  const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
635
803
  if (typeof sym === "string") setCurrencySymbol(sym)
636
804
  if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
805
+ const balCur = props.api.kv.get<string>(`${KV_PREFIX}.balance_currency`)
806
+ if (typeof balCur === "string") setBalanceCurrency(balCur)
807
+ // Restore balance provider (fall back to default when unknown)
808
+ const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
809
+ if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
810
+ setBalanceProviderId(savedProvider)
811
+ }
812
+ // Restore auto-switch (default on)
813
+ const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
814
+ if (typeof savedAuto === "boolean") setAutoBalance(savedAuto)
815
+ // Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
816
+ const legacyKey = props.api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
817
+ if (legacyKey) {
818
+ const dsKey = props.api.kv.get<string>(`${KV_PREFIX}.balance.deepseek.key`, "")
819
+ if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey)
820
+ props.api.kv.set(`${KV_PREFIX}.ds_key`, "")
821
+ }
822
+ // 恢复的 provider 可能与默认值不同,强制重新查询
823
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
637
824
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
638
825
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
639
826
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
640
827
  setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
828
+ setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
641
829
  const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
642
830
  setBorderVisible(bv !== false)
643
831
  // Restore language preference
@@ -692,7 +880,8 @@ function TokenCachePanel(props: {
692
880
  const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
693
881
  const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
694
882
  setRefreshTick(v => v + 1)
695
- onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
883
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
884
+ onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() })
696
885
  })
697
886
 
698
887
  // ── colours ──
@@ -977,6 +1166,65 @@ function TokenCachePanel(props: {
977
1166
  </Show>
978
1167
  </Show>
979
1168
  </Show>
1169
+
1170
+ {/* ── DeepSeek balance (single line) ── */}
1171
+ <Show when={sectionBalance()}>
1172
+ <text fg={pal().muted}>{sep()}</text>
1173
+ <Show when={balanceState().status === "idle"}>
1174
+ <text fg={pal().muted}>
1175
+ <span style={{ fg: pal().muted }}>{"> "}</span>
1176
+ <span>{t().balNoKey.replace("{p}", providerName())}</span>
1177
+ </text>
1178
+ </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
+ })()}
1226
+ </Show>
1227
+ </Show>
980
1228
  </Show>
981
1229
  </Show>
982
1230
  </box>
@@ -1022,6 +1270,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1022
1270
  const [sectionModel, setSectionModel] = createSignal(true)
1023
1271
  const [sectionDist, setSectionDist] = createSignal(true)
1024
1272
  const [sectionSkills, setSectionSkills] = createSignal(true)
1273
+ const [sectionBalance, setSectionBalance] = createSignal(true)
1274
+ const [balanceRefresh, setBalanceRefresh] = createSignal(0)
1275
+ const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
1276
+ const [autoBalance, setAutoBalance] = createSignal(true)
1277
+ const [balanceCurrency, setBalanceCurrency] = createSignal("")
1025
1278
  const [borderVisible, setBorderVisible] = createSignal(true)
1026
1279
  const [langZH, setLangZH] = createSignal(LANG_ZH)
1027
1280
  const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
@@ -1034,6 +1287,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1034
1287
  sectionModel, setSectionModel,
1035
1288
  sectionDist, setSectionDist,
1036
1289
  sectionSkills, setSectionSkills,
1290
+ sectionBalance, setSectionBalance,
1291
+ balanceRefresh, setBalanceRefresh,
1292
+ balanceProviderId, setBalanceProviderId,
1293
+ autoBalance, setAutoBalance,
1294
+ balanceCurrency, setBalanceCurrency,
1037
1295
  borderVisible, setBorderVisible,
1038
1296
  overrideSessionId, setOverrideSessionId,
1039
1297
  }
@@ -1042,6 +1300,55 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1042
1300
 
1043
1301
  // ── slash commands for runtime config ──
1044
1302
  const KV_PREFIX = "cache_panel"
1303
+
1304
+ /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
1305
+ const providerOptionTitle = (p: BalanceProvider, current?: string) => {
1306
+ const zh = langZH()
1307
+ const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
1308
+ const hasAuto = !hasManual && !!findOpencodeKey(api, p)
1309
+ const mark = hasManual
1310
+ ? (zh ? "(用户 key)" : " (user key)")
1311
+ : hasAuto
1312
+ ? (zh ? "(OpenCode)" : " (OpenCode)")
1313
+ : (zh ? "(未配置)" : " (not set)")
1314
+ return p.name + mark + (current && p.id === current ? " *" : "")
1315
+ }
1316
+
1317
+ /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
1318
+ const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
1319
+ const zh = langZH()
1320
+ const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1321
+ const masked = maskKey(current)
1322
+ dialog?.replace(() => (
1323
+ <api.ui.DialogPrompt
1324
+ 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>}
1326
+ placeholder={provider.keyPlaceholder ?? "sk-..."}
1327
+ value={masked}
1328
+ onConfirm={(val) => {
1329
+ const input = val.trim()
1330
+ let key: string
1331
+ if (input === "") {
1332
+ key = ""
1333
+ } else if (input.includes("*")) {
1334
+ key = current
1335
+ } else {
1336
+ key = input
1337
+ }
1338
+ api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
1339
+ setBalanceRefresh(v => v + 1)
1340
+ if (key) {
1341
+ api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." })
1342
+ } else {
1343
+ api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" })
1344
+ }
1345
+ dialog?.clear()
1346
+ }}
1347
+ onCancel={() => dialog?.clear()}
1348
+ />
1349
+ ))
1350
+ }
1351
+
1045
1352
  api.command?.register(() => [
1046
1353
  {
1047
1354
  title: "Cache: Set Currency",
@@ -1061,6 +1368,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1061
1368
  const defRate = DEFAULT_RATES[opt.value] ?? 1
1062
1369
  api.kv.set(`${KV_PREFIX}.currency`, sym)
1063
1370
  api.kv.set(`${KV_PREFIX}.rate`, defRate)
1371
+ // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算
1372
+ api.kv.set(`${KV_PREFIX}.balance_currency`, opt.value)
1373
+ signals.setBalanceCurrency(opt.value)
1064
1374
  signals.setCurrencySymbol(sym)
1065
1375
  signals.setExchangeRate(defRate)
1066
1376
  api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
@@ -1105,6 +1415,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1105
1415
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1106
1416
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1107
1417
  const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1418
+ const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1108
1419
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1109
1420
  dialog?.replace(() => (
1110
1421
  <api.ui.DialogSelect
@@ -1114,6 +1425,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1114
1425
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
1115
1426
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1116
1427
  { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
1428
+ { title: `Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" },
1117
1429
  { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1118
1430
  ]}
1119
1431
  onSelect={(opt) => {
@@ -1130,6 +1442,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1130
1442
  if (opt.value === "model") signals.setSectionModel(!cur)
1131
1443
  if (opt.value === "dist") signals.setSectionDist(!cur)
1132
1444
  if (opt.value === "skills") signals.setSectionSkills(!cur)
1445
+ if (opt.value === "balance") signals.setSectionBalance(!cur)
1133
1446
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1134
1447
  }
1135
1448
  dialog?.clear()
@@ -1150,9 +1463,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1150
1463
  const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1151
1464
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1152
1465
  const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1466
+ const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1153
1467
  api.ui.toast({
1154
1468
  title: "Cache Panel Config",
1155
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
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"}`,
1156
1470
  duration: 8000,
1157
1471
  })
1158
1472
  dialog?.clear()
@@ -1183,6 +1497,92 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1183
1497
  ))
1184
1498
  },
1185
1499
  },
1500
+ {
1501
+ title: "Cache: Switch Balance Provider",
1502
+ value: "cache.balance",
1503
+ description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
1504
+ slash: { name: "cache-balance" },
1505
+ onSelect: (dialog) => {
1506
+ const zh = langZH()
1507
+ const current = signals.balanceProviderId()
1508
+ const auto = signals.autoBalance()
1509
+ const autoLabel = auto
1510
+ ? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]")
1511
+ : (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]")
1512
+ dialog?.replace(() => (
1513
+ <api.ui.DialogSelect
1514
+ title={zh ? "余额提供商 / 自动切换" : "Balance Provider / Auto-switch"}
1515
+ options={[
1516
+ {
1517
+ title: autoLabel,
1518
+ value: "__auto__",
1519
+ },
1520
+ ...balanceProviders.map((p) => ({
1521
+ title: providerOptionTitle(p, current),
1522
+ value: p.id,
1523
+ })),
1524
+ ]}
1525
+ onSelect={(opt) => {
1526
+ if (opt.value === "__auto__") {
1527
+ const next = !auto
1528
+ api.kv.set(`${KV_PREFIX}.balance.auto`, next)
1529
+ signals.setAutoBalance(next)
1530
+ api.ui.toast({ message: zh ? `自动切换余额提供商: ${next ? "开" : "关"}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` })
1531
+ dialog?.clear()
1532
+ } else {
1533
+ const provider = getBalanceProvider(opt.value)
1534
+ // 手动切换会关闭自动切换
1535
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
1536
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1537
+ signals.setBalanceProviderId(provider.id)
1538
+ signals.setAutoBalance(false)
1539
+ // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
1540
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1541
+ const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1542
+ if (!hasKey) {
1543
+ // 未配置 key → 进入设置流程(对话框保持打开等待输入)
1544
+ promptBalanceKey(dialog, provider)
1545
+ } else {
1546
+ api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动切换已关闭)` : `Balance provider: ${provider.name} (auto-switch off)` })
1547
+ dialog?.clear()
1548
+ }
1549
+ }
1550
+ }}
1551
+ />
1552
+ ))
1553
+ },
1554
+ },
1555
+ {
1556
+ title: "Cache: Set Balance API Key",
1557
+ value: "cache.balance.key",
1558
+ description: "Select a provider and set its API key for balance display",
1559
+ slash: { name: "cache-balance-key" },
1560
+ onSelect: (dialog) => {
1561
+ const zh = langZH()
1562
+ // 步骤 1:选择 provider
1563
+ dialog?.replace(() => (
1564
+ <api.ui.DialogSelect
1565
+ title={zh ? "选择余额提供商" : "Select Balance Provider"}
1566
+ options={balanceProviders.map((p) => ({
1567
+ title: providerOptionTitle(p),
1568
+ value: p.id,
1569
+ }))}
1570
+ onSelect={(opt) => {
1571
+ const provider = getBalanceProvider(opt.value)
1572
+ // 手动指定 provider 会关闭自动切换
1573
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
1574
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1575
+ signals.setBalanceProviderId(provider.id)
1576
+ signals.setAutoBalance(false)
1577
+ // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
1578
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1579
+ // 步骤 2:输入 key
1580
+ promptBalanceKey(dialog, provider)
1581
+ }}
1582
+ />
1583
+ ))
1584
+ },
1585
+ },
1186
1586
  {
1187
1587
  title: "Cache: Debug Skills Detection",
1188
1588
  value: "cache.debug-skills",