opencode-visual-cache 1.6.4 → 1.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.6.4",
3
+ "version": "1.6.5",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "types": "dist/index.d.ts",
@@ -30,6 +30,7 @@
30
30
  "build": "tsc && node build.tui.mjs",
31
31
  "build:tui": "node build.tui.mjs",
32
32
  "typecheck": "tsc --noEmit",
33
+ "test:balance": "tsx tests/codex-balance.test.ts",
33
34
  "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"",
34
35
  "prepublishOnly": "tsc"
35
36
  },
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.6.4";
2
+ export const PLUGIN_VERSION="1.6.5";
@@ -6,6 +6,16 @@
6
6
  export interface BalanceEntry {
7
7
  currency: string // 原生币种(CNY/USD…),复用现有汇率换算
8
8
  total: string // 余额字符串
9
+ display?: string // 非货币额度的预格式化显示文本
10
+ details?: BalanceDetail[]
11
+ }
12
+
13
+ export type BalanceDetailKey = "plan" | "used" | "remaining" | "window" | "reset" | "codeReview" | "credits" | "resetCredits"
14
+
15
+ export interface BalanceDetail {
16
+ key: BalanceDetailKey
17
+ value: string
18
+ windowSeconds?: number
9
19
  }
10
20
 
11
21
  /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
@@ -149,8 +159,245 @@ const hyperProvider: BalanceProvider = {
149
159
  },
150
160
  }
151
161
 
162
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
163
+ try {
164
+ const encoded = token.split(".")[1]
165
+ if (!encoded || typeof atob !== "function") return undefined
166
+ const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/"))
167
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
168
+ return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
169
+ } catch {
170
+ return undefined
171
+ }
172
+ }
173
+
174
+ function getChatGPTAccountId(token: string): string | undefined {
175
+ const payload = decodeJwtPayload(token)
176
+ const auth = payload?.["https://api.openai.com/auth"]
177
+ if (auth && typeof auth === "object") {
178
+ const accountId = (auth as Record<string, unknown>).chatgpt_account_id
179
+ if (typeof accountId === "string" && accountId) return accountId
180
+ }
181
+ const accountId = payload?.chatgpt_account_id
182
+ return typeof accountId === "string" && accountId ? accountId : undefined
183
+ }
184
+
185
+ type OpenAIRecord = Record<string, unknown>
186
+
187
+ interface CodexPercentages {
188
+ used: number
189
+ remaining: number
190
+ }
191
+
192
+ interface CodexRateWindow {
193
+ data: OpenAIRecord
194
+ windowSeconds?: number
195
+ order: number
196
+ }
197
+
198
+ function asRecord(value: unknown): OpenAIRecord | undefined {
199
+ return value && typeof value === "object" && !Array.isArray(value) ? value as OpenAIRecord : undefined
200
+ }
201
+
202
+ function asFiniteNumber(value: unknown): number | undefined {
203
+ const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN
204
+ return Number.isFinite(number) ? number : undefined
205
+ }
206
+
207
+ function clampPercent(value: number): number {
208
+ return Math.max(0, Math.min(100, value))
209
+ }
210
+
211
+ function formatPercent(value: number): string {
212
+ return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)
213
+ }
214
+
215
+ function formatCreditAmount(value: number): string {
216
+ if (Number.isInteger(value)) return String(value)
217
+ return value.toFixed(2).replace(/\.?0+$/, "")
218
+ }
219
+
220
+ function getPercentages(snapshot: OpenAIRecord): CodexPercentages | undefined {
221
+ const explicitUsed = asFiniteNumber(snapshot.used_percent)
222
+ const explicitRemaining = asFiniteNumber(snapshot.remaining_percent)
223
+ if (explicitUsed !== undefined || explicitRemaining !== undefined) {
224
+ const used = clampPercent(explicitUsed ?? 100 - explicitRemaining!)
225
+ const remaining = clampPercent(explicitRemaining ?? 100 - used)
226
+ return { used, remaining }
227
+ }
228
+
229
+ const limit = asFiniteNumber(snapshot.limit)
230
+ const usedAmount = asFiniteNumber(snapshot.used)
231
+ const remainingAmount = asFiniteNumber(snapshot.remaining)
232
+ if (limit !== undefined && limit > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) {
233
+ const used = usedAmount !== undefined ? (usedAmount / limit) * 100 : 100 - (remainingAmount! / limit) * 100
234
+ const remaining = remainingAmount !== undefined ? (remainingAmount / limit) * 100 : 100 - used
235
+ return { used: clampPercent(used), remaining: clampPercent(remaining) }
236
+ }
237
+
238
+ const amountTotal = (usedAmount ?? 0) + (remainingAmount ?? 0)
239
+ if (amountTotal > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) {
240
+ const used = usedAmount !== undefined ? (usedAmount / amountTotal) * 100 : 0
241
+ return { used: clampPercent(used), remaining: clampPercent(100 - used) }
242
+ }
243
+ return undefined
244
+ }
245
+
246
+ function getRateWindows(rateLimit: unknown): CodexRateWindow[] {
247
+ const record = asRecord(rateLimit)
248
+ if (!record) return []
249
+ return Object.entries(record)
250
+ .map(([name, value], order): CodexRateWindow | undefined => {
251
+ const data = asRecord(value)
252
+ if (!data) return undefined
253
+ const normalizedName = name.toLowerCase()
254
+ if (normalizedName.includes("individual")) return undefined
255
+ const windowSeconds = asFiniteNumber(data.limit_window_seconds)
256
+ const looksLikeWindow = normalizedName.includes("window") ||
257
+ windowSeconds !== undefined ||
258
+ "used_percent" in data ||
259
+ "remaining_percent" in data
260
+ if (!looksLikeWindow) return undefined
261
+ return { data, windowSeconds, order }
262
+ })
263
+ .filter((window): window is CodexRateWindow => window !== undefined)
264
+ .sort((a, b) => (a.windowSeconds ?? Number.MAX_SAFE_INTEGER) - (b.windowSeconds ?? Number.MAX_SAFE_INTEGER) || a.order - b.order)
265
+ }
266
+
267
+ function getResetAfterSeconds(snapshot: OpenAIRecord, nowMs: number): number | undefined {
268
+ const relative = asFiniteNumber(snapshot.reset_after_seconds)
269
+ if (relative !== undefined) return Math.max(0, Math.round(relative))
270
+
271
+ for (const key of ["reset_at", "resets_at", "resetAt", "resetsAt"]) {
272
+ const timestamp = asFiniteNumber(snapshot[key])
273
+ if (timestamp === undefined) continue
274
+ const timestampSeconds = timestamp > 1e12 ? timestamp / 1000 : timestamp
275
+ return Math.max(0, Math.round(timestampSeconds - nowMs / 1000))
276
+ }
277
+ return undefined
278
+ }
279
+
280
+ function appendQuotaDetails(details: BalanceDetail[], percentages: CodexPercentages, windowSeconds?: number): void {
281
+ const scope = windowSeconds === undefined ? {} : { windowSeconds }
282
+ details.push({ key: "used", value: `${formatPercent(percentages.used)}%`, ...scope })
283
+ details.push({ key: "remaining", value: `${formatPercent(percentages.remaining)}%`, ...scope })
284
+ }
285
+
286
+ export function parseOpenAIUsage(raw: unknown, nowMs = Date.now()): BalanceEntry[] {
287
+ const json = asRecord(raw)
288
+ if (!json) throw new BalanceError("EMPTY")
289
+
290
+ const details: BalanceDetail[] = []
291
+ if (typeof json.plan_type === "string" && json.plan_type) {
292
+ details.push({ key: "plan", value: json.plan_type.toUpperCase() })
293
+ }
294
+
295
+ const rateLimit = asRecord(json.rate_limit)
296
+ const remainingValues: number[] = []
297
+ let hasRateQuota = false
298
+ for (const window of getRateWindows(rateLimit)) {
299
+ const percentages = getPercentages(window.data)
300
+ if (percentages) {
301
+ appendQuotaDetails(details, percentages, window.windowSeconds)
302
+ remainingValues.push(percentages.remaining)
303
+ hasRateQuota = true
304
+ }
305
+ const resetAfter = getResetAfterSeconds(window.data, nowMs)
306
+ if (resetAfter !== undefined) {
307
+ details.push({
308
+ key: "reset",
309
+ value: String(resetAfter),
310
+ ...(window.windowSeconds === undefined ? {} : { windowSeconds: window.windowSeconds }),
311
+ })
312
+ }
313
+ }
314
+
315
+ const spendControl = asRecord(asRecord(json.spend_control)?.individual_limit)
316
+ const individualLimit = asRecord(json.individual_limit) ?? asRecord(rateLimit?.individual_limit) ?? spendControl
317
+ const individualPercentages = individualLimit ? getPercentages(individualLimit) : undefined
318
+ if (individualPercentages) {
319
+ remainingValues.push(individualPercentages.remaining)
320
+ if (!hasRateQuota) appendQuotaDetails(details, individualPercentages)
321
+ }
322
+ if (individualLimit) {
323
+ const resetAfter = getResetAfterSeconds(individualLimit, nowMs)
324
+ if (resetAfter !== undefined) details.push({ key: "reset", value: String(resetAfter) })
325
+ }
326
+
327
+ const codeReviewWindow = asRecord(asRecord(json.code_review_rate_limit)?.primary_window)
328
+ const codeReviewUsed = asFiniteNumber(codeReviewWindow?.used_percent)
329
+ if (codeReviewUsed !== undefined) {
330
+ details.push({ key: "codeReview", value: `${formatPercent(clampPercent(100 - codeReviewUsed))}%` })
331
+ }
332
+
333
+ const credits = asRecord(json.credits)
334
+ let hasCreditDetail = false
335
+ if (credits?.unlimited === true) {
336
+ details.push({ key: "credits", value: "unlimited" })
337
+ hasCreditDetail = true
338
+ } else {
339
+ const creditBalance = asFiniteNumber(credits?.balance)
340
+ if (creditBalance !== undefined) {
341
+ details.push({ key: "credits", value: `$${creditBalance.toFixed(2)}` })
342
+ hasCreditDetail = true
343
+ } else if (individualLimit) {
344
+ const remaining = asFiniteNumber(individualLimit.remaining)
345
+ const limit = asFiniteNumber(individualLimit.limit)
346
+ const amounts = [remaining, limit].filter((value): value is number => value !== undefined)
347
+ if (amounts.length > 0) {
348
+ details.push({ key: "credits", value: amounts.map(formatCreditAmount).join(" / ") })
349
+ hasCreditDetail = true
350
+ }
351
+ }
352
+ }
353
+
354
+ const resetCredits = asFiniteNumber(asRecord(json.rate_limit_reset_credits)?.available_count)
355
+ if (resetCredits !== undefined) details.push({ key: "resetCredits", value: String(resetCredits) })
356
+
357
+ if (details.length === 0 || (!hasRateQuota && !individualPercentages && !hasCreditDetail && resetCredits === undefined)) {
358
+ throw new BalanceError("EMPTY")
359
+ }
360
+
361
+ const summaryRemaining = remainingValues.length > 0 ? Math.min(...remainingValues) : undefined
362
+ const summary = summaryRemaining === undefined ? undefined : formatPercent(summaryRemaining)
363
+ return [{
364
+ currency: "CODEX",
365
+ total: summary === undefined ? "0" : `${summary}%`,
366
+ display: summary === undefined ? "Codex" : `Codex ${summary}%`,
367
+ details,
368
+ }]
369
+ }
370
+
371
+ const openaiProvider: BalanceProvider = {
372
+ id: "openai",
373
+ name: "OpenAI Codex",
374
+ keyPlaceholder: "OAuth access token (eyJ...)",
375
+ async fetchBalance(accessToken, signal) {
376
+ const headers: Record<string, string> = {
377
+ Authorization: `Bearer ${accessToken}`,
378
+ Accept: "application/json",
379
+ Referer: "https://chatgpt.com/",
380
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
381
+ "OpenAI-Beta": "codex-1",
382
+ "oai-language": "zh-CN",
383
+ originator: "Codex Desktop",
384
+ }
385
+ const accountId = getChatGPTAccountId(accessToken)
386
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId
387
+
388
+ const res = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers, signal })
389
+ if (!res.ok) {
390
+ if (res.status === 401) throw new BalanceError("401")
391
+ if (res.status === 403) throw new BalanceError("403")
392
+ throw new BalanceError(String(res.status))
393
+ }
394
+ const json = await res.json()
395
+ return parseOpenAIUsage(json)
396
+ },
397
+ }
398
+
152
399
  /** 已注册的 provider 列表(按需追加新适配器)。 */
153
- export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider]
400
+ export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider, openaiProvider]
154
401
 
155
402
  /** 按 id 取 provider;未知 id 回退到第一个。 */
156
403
  export function getBalanceProvider(id: string): BalanceProvider {
package/src/i18n.ts CHANGED
@@ -44,6 +44,19 @@ const ZH_T = {
44
44
  balErrEmpty:"未获取到余额数据",
45
45
  balErrTimeout: "查询超时",
46
46
  balUnsupported: "当前提供商不支持余额查询",
47
+ balDetailPlan: "套餐",
48
+ balDetailUsed: "已用",
49
+ balDetailRemaining: "剩余",
50
+ balDetailWindow: "周期",
51
+ balDetailReset: "重置",
52
+ balDetailCodeReview: "Code Review",
53
+ balDetailCredits: "Credits",
54
+ balDetailResetCredits: "重置次数",
55
+ balUnlimited: "无限",
56
+ balDay: "天",
57
+ balHour: "小时",
58
+ balMinute: "分钟",
59
+ balResetSoon: "即将重置",
47
60
  barHit: "命中率",
48
61
  barBal: "余额",
49
62
  barTok: "Tokens",
@@ -129,6 +142,19 @@ const EN_T: Translation = {
129
142
  balErrEmpty:"No balance data",
130
143
  balErrTimeout: "Request timed out",
131
144
  balUnsupported: "Balance query unsupported",
145
+ balDetailPlan: "Plan",
146
+ balDetailUsed: "Used",
147
+ balDetailRemaining: "Remaining",
148
+ balDetailWindow: "Window",
149
+ balDetailReset: "Reset",
150
+ balDetailCodeReview: "Code Review",
151
+ balDetailCredits: "Credits",
152
+ balDetailResetCredits: "Reset credits",
153
+ balUnlimited: "Unlimited",
154
+ balDay: "d",
155
+ balHour: "h",
156
+ balMinute: "m",
157
+ balResetSoon: "soon",
132
158
  barHit: "Hit",
133
159
  barBal: "Balance",
134
160
  barTok: "Tokens",
@@ -211,6 +237,19 @@ const JA_T: Translation = {
211
237
  balErrEmpty:"残高データなし",
212
238
  balErrTimeout: "タイムアウト",
213
239
  balUnsupported: "このプロバイダは残高照会非対応",
240
+ balDetailPlan: "プラン",
241
+ balDetailUsed: "使用済み",
242
+ balDetailRemaining: "残り",
243
+ balDetailWindow: "期間",
244
+ balDetailReset: "リセット",
245
+ balDetailCodeReview: "Code Review",
246
+ balDetailCredits: "Credits",
247
+ balDetailResetCredits: "リセット回数",
248
+ balUnlimited: "無制限",
249
+ balDay: "日",
250
+ balHour: "時間",
251
+ balMinute: "分",
252
+ balResetSoon: "まもなく",
214
253
  barHit: "ヒット率",
215
254
  barBal: "残高",
216
255
  barTok: "Tokens",
@@ -293,6 +332,19 @@ const KO_T: Translation = {
293
332
  balErrEmpty:"잔액 데이터 없음",
294
333
  balErrTimeout: "시간 초과",
295
334
  balUnsupported: "이 프로바이더는 잔액 조회 미지원",
335
+ balDetailPlan: "플랜",
336
+ balDetailUsed: "사용",
337
+ balDetailRemaining: "잔여",
338
+ balDetailWindow: "주기",
339
+ balDetailReset: "재설정",
340
+ balDetailCodeReview: "Code Review",
341
+ balDetailCredits: "Credits",
342
+ balDetailResetCredits: "재설정 횟수",
343
+ balUnlimited: "무제한",
344
+ balDay: "일",
345
+ balHour: "시간",
346
+ balMinute: "분",
347
+ balResetSoon: "곧 재설정",
296
348
  barHit: "히트율",
297
349
  barBal: "잔액",
298
350
  barTok: "Tokens",
package/src/index.tsx CHANGED
@@ -22,15 +22,18 @@ import type {
22
22
  } from "@opencode-ai/sdk/v2"
23
23
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
24
24
  import { PLUGIN_VERSION } from "./_version"
25
- import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
26
- import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
25
+ import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceDetail, type BalanceDetailKey, type BalanceEntry, type BalanceProvider } from "./balance-providers"
26
+ import { LANG_META, createT, detectLang, type LangCode, type Translation } from "./i18n"
27
27
 
28
28
  // ---------------------------------------------------------------------------
29
29
  // Helpers
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
32
  // Bun / Node globals — available at runtime in the OpenCode TUI process
33
- declare const process: { env: Record<string, string | undefined> } | undefined
33
+ declare const process: {
34
+ env: Record<string, string | undefined>
35
+ getBuiltinModule?: (id: string) => unknown
36
+ } | undefined
34
37
 
35
38
  // ── terminal-width helpers ────────────────────────────────────────
36
39
  // CJK characters occupy 2 terminal columns; padEnd/padStart count
@@ -299,15 +302,52 @@ function convertBalance(target: string, targetRate: number, amount: number, from
299
302
  /**
300
303
  * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
301
304
  * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
302
- * key 来源:auth.jsonprovider.key)或配置(provider.options.apiKey)。
303
- * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
305
+ * OpenAI 优先读取 auth.json OAuth;其他 provider 读取 provider.key / provider.options.apiKey
306
+ * 读取失败或未匹配返回空串。
304
307
  */
308
+ function readOpenAIOAuthToken(api: TuiPluginApi): string {
309
+ try {
310
+ // OpenAI OAuth credentials are stored separately from provider.key.
311
+ const loader = typeof process !== "undefined" ? process?.getBuiltinModule : undefined
312
+ const fs = loader?.("node:fs") as { readFileSync(path: string, encoding: "utf8"): string } | undefined
313
+ if (!fs) return ""
314
+ const stateDir = api.state.path.state.replace(/[\\/]+$/, "")
315
+ const home = typeof process !== "undefined" ? (process?.env.HOME || process?.env.USERPROFILE || "") : ""
316
+ const dataHome = typeof process !== "undefined" ? process?.env.XDG_DATA_HOME : undefined
317
+ const paths = [
318
+ stateDir ? `${stateDir}/auth.json` : "",
319
+ dataHome ? `${dataHome}/opencode/auth.json` : "",
320
+ home ? `${home}/.local/share/opencode/auth.json` : "",
321
+ ]
322
+ for (const path of paths) {
323
+ if (!path) continue
324
+ try {
325
+ const auth = JSON.parse(fs.readFileSync(path, "utf8")) as Record<string, unknown>
326
+ const openai = auth.openai
327
+ if (openai && typeof openai === "object") {
328
+ const record = openai as Record<string, unknown>
329
+ if (record.type === "oauth" && typeof record.access === "string") return record.access
330
+ }
331
+ } catch { /* try the next known auth path */ }
332
+ }
333
+ return ""
334
+ } catch {
335
+ return ""
336
+ }
337
+ }
338
+
305
339
  function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
306
340
  try {
307
341
  const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
308
342
  // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
309
343
  const id = provider.id.toLowerCase()
310
344
  const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
345
+ const isOpenAI = id === "openai"
346
+ // OAuth token 优先于 provider.key,避免把配置中的占位值当成 access token。
347
+ if (isOpenAI) {
348
+ const oauth = readOpenAIOAuthToken(api)
349
+ if (oauth) return oauth
350
+ }
311
351
  if (!hit) return ""
312
352
  const k = typeof hit.key === "string" ? hit.key : ""
313
353
  if (k) return k
@@ -343,6 +383,8 @@ function formatBalanceAmount(total: string): string {
343
383
  * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
344
384
  */
345
385
  function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
386
+ const custom = list.find((x) => x.display)
387
+ if (custom?.display) return custom.display
346
388
  const native = pref ? list.find((x) => x.currency === pref) : undefined
347
389
  if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
348
390
  const base = list[0]
@@ -356,6 +398,17 @@ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): st
356
398
  return balanceSymbol(pref || base.currency) + shown
357
399
  }
358
400
 
401
+ const BALANCE_DETAIL_LABELS: Record<BalanceDetailKey, keyof Translation> = {
402
+ plan: "balDetailPlan",
403
+ used: "balDetailUsed",
404
+ remaining: "balDetailRemaining",
405
+ window: "balDetailWindow",
406
+ reset: "balDetailReset",
407
+ codeReview: "balDetailCodeReview",
408
+ credits: "balDetailCredits",
409
+ resetCredits: "balDetailResetCredits",
410
+ }
411
+
359
412
  // ---------------------------------------------------------------------------
360
413
  // Sidebar component
361
414
  // ---------------------------------------------------------------------------
@@ -443,6 +496,7 @@ function TokenCachePanel(props: {
443
496
  const [modelOpen, setModelOpen] = createSignal(true)
444
497
  const [distOpen, setDistOpen] = createSignal(false)
445
498
  const [skillsOpen, setSkillsOpen] = createSignal(true)
499
+ const [balanceOpen, setBalanceOpen] = createSignal(false)
446
500
  let boxEl: any
447
501
 
448
502
  // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
@@ -473,6 +527,34 @@ function TokenCachePanel(props: {
473
527
  // ── reactive translation (follows langCode signal) ──
474
528
  const t = createT(() => langCode())
475
529
 
530
+ const formatBalanceDuration = (seconds: number, fallback = ""): string => {
531
+ if (!Number.isFinite(seconds)) return ""
532
+ let remaining = Math.max(0, Math.round(seconds))
533
+ const days = Math.floor(remaining / 86400)
534
+ remaining %= 86400
535
+ const hours = Math.floor(remaining / 3600)
536
+ remaining %= 3600
537
+ const minutes = Math.floor(remaining / 60)
538
+ const parts: string[] = []
539
+ if (days > 0) parts.push(`${days}${t("balDay")}`)
540
+ if (hours > 0 && parts.length < 2) parts.push(`${hours}${t("balHour")}`)
541
+ if (minutes > 0 && parts.length < 2) parts.push(`${minutes}${t("balMinute")}`)
542
+ return parts.join(langCode() === "en" ? " " : "") || fallback
543
+ }
544
+
545
+ const formatBalanceDetailValue = (detail: BalanceDetail): string => {
546
+ if (detail.value === "unlimited") return t("balUnlimited")
547
+ if (detail.key !== "reset") return detail.value
548
+ return formatBalanceDuration(Number(detail.value), t("balResetSoon")) || detail.value
549
+ }
550
+
551
+ const formatBalanceDetailLabel = (detail: BalanceDetail): string => {
552
+ const label = t(BALANCE_DETAIL_LABELS[detail.key])
553
+ if (detail.windowSeconds === undefined) return label
554
+ const window = formatBalanceDuration(detail.windowSeconds)
555
+ return window ? `${label} (${window})` : label
556
+ }
557
+
476
558
  // ── scan session messages reactively ──
477
559
  // SolidJS createMemo re-evaluates whenever the underlying
478
560
  // api.state.session state changes — no event listener needed.
@@ -721,6 +803,8 @@ function TokenCachePanel(props: {
721
803
  return dataSignal()
722
804
  })
723
805
 
806
+ const balanceDetails = createMemo(() => balanceState().data?.find((entry) => entry.details)?.details ?? [])
807
+
724
808
  // Persist the last valid distribution so that data() can fall back
725
809
  // to it while api.state.part() is re-hydrating after a view switch.
726
810
  createEffect(() => {
@@ -754,6 +838,7 @@ function TokenCachePanel(props: {
754
838
  setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
755
839
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
756
840
  setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
841
+ setBalanceOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.balance.open`, false)))
757
842
  } catch {}
758
843
 
759
844
  // Restore user config (currency, rate, section visibility).
@@ -903,6 +988,15 @@ function TokenCachePanel(props: {
903
988
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
904
989
  }
905
990
 
991
+ const balanceHeader = () => {
992
+ const arrow = balanceDetails().length > 0 ? (balanceOpen() ? "\u25bc " : "\u25b6 ") : ""
993
+ const title = t("secBalance")
994
+ const summary = balanceState().data ? formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()) : ""
995
+ const gauge = panelWidth() - gutter()
996
+ const dividerLength = Math.max(1, gauge - visualWidth(arrow + title) - visualWidth(summary) - 1)
997
+ return { arrow, title, summary, divider: sep().slice(0, dividerLength) }
998
+ }
999
+
906
1000
  return (
907
1001
  <box
908
1002
  border={borderVisible()}
@@ -1138,7 +1232,6 @@ function TokenCachePanel(props: {
1138
1232
 
1139
1233
  {/* ── provider balance (single line) ── */}
1140
1234
  <Show when={sectionBalance()}>
1141
- <text fg={pal().muted}>{sep()}</text>
1142
1235
  <Show when={balanceUnsupported()}>
1143
1236
  <text fg={pal().muted}>
1144
1237
  <span style={{ fg: pal().muted }}>{"> "}</span>
@@ -1172,9 +1265,31 @@ function TokenCachePanel(props: {
1172
1265
  </text>
1173
1266
  </Show>
1174
1267
  <Show when={balanceState().status === "ok" && balanceState().data}>
1175
- <text fg={pal().text}>
1176
- {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
1177
- </text>
1268
+ <Show when={balanceDetails().length > 0}>
1269
+ <text fg={pal().text} onMouseUp={() => {
1270
+ const next = !balanceOpen()
1271
+ setBalanceOpen(next)
1272
+ persistFold("balance.open", next)
1273
+ }}>
1274
+ <span style={{ fg: pal().muted }}>{balanceHeader().arrow}</span>
1275
+ <span style={{ fg: pal().primary }}><b>{balanceHeader().title}</b></span>
1276
+ <span style={{ fg: pal().muted }}>{balanceHeader().divider}</span>
1277
+ <span>{" " + balanceHeader().summary}</span>
1278
+ </text>
1279
+ <Show when={balanceOpen()}>
1280
+ {balanceDetails().map((detail) => (
1281
+ <text fg={pal().muted}>
1282
+ {justify(formatBalanceDetailLabel(detail) + ":", formatBalanceDetailValue(detail))}
1283
+ </text>
1284
+ ))}
1285
+ </Show>
1286
+ </Show>
1287
+ <Show when={balanceDetails().length === 0}>
1288
+ <text fg={pal().muted}>{sep()}</text>
1289
+ <text fg={pal().text}>
1290
+ {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
1291
+ </text>
1292
+ </Show>
1178
1293
  </Show>
1179
1294
  </Show>
1180
1295
  </Show>