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.
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.6.4";
1
+ export declare const PLUGIN_VERSION = "1.6.5";
package/dist/_version.js 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";
@@ -2,6 +2,14 @@
2
2
  export interface BalanceEntry {
3
3
  currency: string;
4
4
  total: string;
5
+ display?: string;
6
+ details?: BalanceDetail[];
7
+ }
8
+ export type BalanceDetailKey = "plan" | "used" | "remaining" | "window" | "reset" | "codeReview" | "credits" | "resetCredits";
9
+ export interface BalanceDetail {
10
+ key: BalanceDetailKey;
11
+ value: string;
12
+ windowSeconds?: number;
5
13
  }
6
14
  /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
7
15
  export declare class BalanceError extends Error {
@@ -13,6 +21,7 @@ export interface BalanceProvider {
13
21
  keyPlaceholder?: string;
14
22
  fetchBalance(apiKey: string, signal?: AbortSignal): Promise<BalanceEntry[]>;
15
23
  }
24
+ export declare function parseOpenAIUsage(raw: unknown, nowMs?: number): BalanceEntry[];
16
25
  /** 已注册的 provider 列表(按需追加新适配器)。 */
17
26
  export declare const balanceProviders: BalanceProvider[];
18
27
  /** 按 id 取 provider;未知 id 回退到第一个。 */
@@ -129,8 +129,226 @@ const hyperProvider = {
129
129
  return [{ currency: "USD", total: usd }];
130
130
  },
131
131
  };
132
+ function decodeJwtPayload(token) {
133
+ try {
134
+ const encoded = token.split(".")[1];
135
+ if (!encoded || typeof atob !== "function")
136
+ return undefined;
137
+ const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/"));
138
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
139
+ return JSON.parse(new TextDecoder().decode(bytes));
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
145
+ function getChatGPTAccountId(token) {
146
+ const payload = decodeJwtPayload(token);
147
+ const auth = payload?.["https://api.openai.com/auth"];
148
+ if (auth && typeof auth === "object") {
149
+ const accountId = auth.chatgpt_account_id;
150
+ if (typeof accountId === "string" && accountId)
151
+ return accountId;
152
+ }
153
+ const accountId = payload?.chatgpt_account_id;
154
+ return typeof accountId === "string" && accountId ? accountId : undefined;
155
+ }
156
+ function asRecord(value) {
157
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
158
+ }
159
+ function asFiniteNumber(value) {
160
+ const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
161
+ return Number.isFinite(number) ? number : undefined;
162
+ }
163
+ function clampPercent(value) {
164
+ return Math.max(0, Math.min(100, value));
165
+ }
166
+ function formatPercent(value) {
167
+ return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
168
+ }
169
+ function formatCreditAmount(value) {
170
+ if (Number.isInteger(value))
171
+ return String(value);
172
+ return value.toFixed(2).replace(/\.?0+$/, "");
173
+ }
174
+ function getPercentages(snapshot) {
175
+ const explicitUsed = asFiniteNumber(snapshot.used_percent);
176
+ const explicitRemaining = asFiniteNumber(snapshot.remaining_percent);
177
+ if (explicitUsed !== undefined || explicitRemaining !== undefined) {
178
+ const used = clampPercent(explicitUsed ?? 100 - explicitRemaining);
179
+ const remaining = clampPercent(explicitRemaining ?? 100 - used);
180
+ return { used, remaining };
181
+ }
182
+ const limit = asFiniteNumber(snapshot.limit);
183
+ const usedAmount = asFiniteNumber(snapshot.used);
184
+ const remainingAmount = asFiniteNumber(snapshot.remaining);
185
+ if (limit !== undefined && limit > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) {
186
+ const used = usedAmount !== undefined ? (usedAmount / limit) * 100 : 100 - (remainingAmount / limit) * 100;
187
+ const remaining = remainingAmount !== undefined ? (remainingAmount / limit) * 100 : 100 - used;
188
+ return { used: clampPercent(used), remaining: clampPercent(remaining) };
189
+ }
190
+ const amountTotal = (usedAmount ?? 0) + (remainingAmount ?? 0);
191
+ if (amountTotal > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) {
192
+ const used = usedAmount !== undefined ? (usedAmount / amountTotal) * 100 : 0;
193
+ return { used: clampPercent(used), remaining: clampPercent(100 - used) };
194
+ }
195
+ return undefined;
196
+ }
197
+ function getRateWindows(rateLimit) {
198
+ const record = asRecord(rateLimit);
199
+ if (!record)
200
+ return [];
201
+ return Object.entries(record)
202
+ .map(([name, value], order) => {
203
+ const data = asRecord(value);
204
+ if (!data)
205
+ return undefined;
206
+ const normalizedName = name.toLowerCase();
207
+ if (normalizedName.includes("individual"))
208
+ return undefined;
209
+ const windowSeconds = asFiniteNumber(data.limit_window_seconds);
210
+ const looksLikeWindow = normalizedName.includes("window") ||
211
+ windowSeconds !== undefined ||
212
+ "used_percent" in data ||
213
+ "remaining_percent" in data;
214
+ if (!looksLikeWindow)
215
+ return undefined;
216
+ return { data, windowSeconds, order };
217
+ })
218
+ .filter((window) => window !== undefined)
219
+ .sort((a, b) => (a.windowSeconds ?? Number.MAX_SAFE_INTEGER) - (b.windowSeconds ?? Number.MAX_SAFE_INTEGER) || a.order - b.order);
220
+ }
221
+ function getResetAfterSeconds(snapshot, nowMs) {
222
+ const relative = asFiniteNumber(snapshot.reset_after_seconds);
223
+ if (relative !== undefined)
224
+ return Math.max(0, Math.round(relative));
225
+ for (const key of ["reset_at", "resets_at", "resetAt", "resetsAt"]) {
226
+ const timestamp = asFiniteNumber(snapshot[key]);
227
+ if (timestamp === undefined)
228
+ continue;
229
+ const timestampSeconds = timestamp > 1e12 ? timestamp / 1000 : timestamp;
230
+ return Math.max(0, Math.round(timestampSeconds - nowMs / 1000));
231
+ }
232
+ return undefined;
233
+ }
234
+ function appendQuotaDetails(details, percentages, windowSeconds) {
235
+ const scope = windowSeconds === undefined ? {} : { windowSeconds };
236
+ details.push({ key: "used", value: `${formatPercent(percentages.used)}%`, ...scope });
237
+ details.push({ key: "remaining", value: `${formatPercent(percentages.remaining)}%`, ...scope });
238
+ }
239
+ export function parseOpenAIUsage(raw, nowMs = Date.now()) {
240
+ const json = asRecord(raw);
241
+ if (!json)
242
+ throw new BalanceError("EMPTY");
243
+ const details = [];
244
+ if (typeof json.plan_type === "string" && json.plan_type) {
245
+ details.push({ key: "plan", value: json.plan_type.toUpperCase() });
246
+ }
247
+ const rateLimit = asRecord(json.rate_limit);
248
+ const remainingValues = [];
249
+ let hasRateQuota = false;
250
+ for (const window of getRateWindows(rateLimit)) {
251
+ const percentages = getPercentages(window.data);
252
+ if (percentages) {
253
+ appendQuotaDetails(details, percentages, window.windowSeconds);
254
+ remainingValues.push(percentages.remaining);
255
+ hasRateQuota = true;
256
+ }
257
+ const resetAfter = getResetAfterSeconds(window.data, nowMs);
258
+ if (resetAfter !== undefined) {
259
+ details.push({
260
+ key: "reset",
261
+ value: String(resetAfter),
262
+ ...(window.windowSeconds === undefined ? {} : { windowSeconds: window.windowSeconds }),
263
+ });
264
+ }
265
+ }
266
+ const spendControl = asRecord(asRecord(json.spend_control)?.individual_limit);
267
+ const individualLimit = asRecord(json.individual_limit) ?? asRecord(rateLimit?.individual_limit) ?? spendControl;
268
+ const individualPercentages = individualLimit ? getPercentages(individualLimit) : undefined;
269
+ if (individualPercentages) {
270
+ remainingValues.push(individualPercentages.remaining);
271
+ if (!hasRateQuota)
272
+ appendQuotaDetails(details, individualPercentages);
273
+ }
274
+ if (individualLimit) {
275
+ const resetAfter = getResetAfterSeconds(individualLimit, nowMs);
276
+ if (resetAfter !== undefined)
277
+ details.push({ key: "reset", value: String(resetAfter) });
278
+ }
279
+ const codeReviewWindow = asRecord(asRecord(json.code_review_rate_limit)?.primary_window);
280
+ const codeReviewUsed = asFiniteNumber(codeReviewWindow?.used_percent);
281
+ if (codeReviewUsed !== undefined) {
282
+ details.push({ key: "codeReview", value: `${formatPercent(clampPercent(100 - codeReviewUsed))}%` });
283
+ }
284
+ const credits = asRecord(json.credits);
285
+ let hasCreditDetail = false;
286
+ if (credits?.unlimited === true) {
287
+ details.push({ key: "credits", value: "unlimited" });
288
+ hasCreditDetail = true;
289
+ }
290
+ else {
291
+ const creditBalance = asFiniteNumber(credits?.balance);
292
+ if (creditBalance !== undefined) {
293
+ details.push({ key: "credits", value: `$${creditBalance.toFixed(2)}` });
294
+ hasCreditDetail = true;
295
+ }
296
+ else if (individualLimit) {
297
+ const remaining = asFiniteNumber(individualLimit.remaining);
298
+ const limit = asFiniteNumber(individualLimit.limit);
299
+ const amounts = [remaining, limit].filter((value) => value !== undefined);
300
+ if (amounts.length > 0) {
301
+ details.push({ key: "credits", value: amounts.map(formatCreditAmount).join(" / ") });
302
+ hasCreditDetail = true;
303
+ }
304
+ }
305
+ }
306
+ const resetCredits = asFiniteNumber(asRecord(json.rate_limit_reset_credits)?.available_count);
307
+ if (resetCredits !== undefined)
308
+ details.push({ key: "resetCredits", value: String(resetCredits) });
309
+ if (details.length === 0 || (!hasRateQuota && !individualPercentages && !hasCreditDetail && resetCredits === undefined)) {
310
+ throw new BalanceError("EMPTY");
311
+ }
312
+ const summaryRemaining = remainingValues.length > 0 ? Math.min(...remainingValues) : undefined;
313
+ const summary = summaryRemaining === undefined ? undefined : formatPercent(summaryRemaining);
314
+ return [{
315
+ currency: "CODEX",
316
+ total: summary === undefined ? "0" : `${summary}%`,
317
+ display: summary === undefined ? "Codex" : `Codex ${summary}%`,
318
+ details,
319
+ }];
320
+ }
321
+ const openaiProvider = {
322
+ id: "openai",
323
+ name: "OpenAI Codex",
324
+ keyPlaceholder: "OAuth access token (eyJ...)",
325
+ async fetchBalance(accessToken, signal) {
326
+ const headers = {
327
+ Authorization: `Bearer ${accessToken}`,
328
+ Accept: "application/json",
329
+ Referer: "https://chatgpt.com/",
330
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
331
+ "OpenAI-Beta": "codex-1",
332
+ "oai-language": "zh-CN",
333
+ originator: "Codex Desktop",
334
+ };
335
+ const accountId = getChatGPTAccountId(accessToken);
336
+ if (accountId)
337
+ headers["ChatGPT-Account-Id"] = accountId;
338
+ const res = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers, signal });
339
+ if (!res.ok) {
340
+ if (res.status === 401)
341
+ throw new BalanceError("401");
342
+ if (res.status === 403)
343
+ throw new BalanceError("403");
344
+ throw new BalanceError(String(res.status));
345
+ }
346
+ const json = await res.json();
347
+ return parseOpenAIUsage(json);
348
+ },
349
+ };
132
350
  /** 已注册的 provider 列表(按需追加新适配器)。 */
133
- export const balanceProviders = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider];
351
+ export const balanceProviders = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider, openaiProvider];
134
352
  /** 按 id 取 provider;未知 id 回退到第一个。 */
135
353
  export function getBalanceProvider(id) {
136
354
  return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider;
package/dist/i18n.d.ts CHANGED
@@ -38,6 +38,19 @@ declare const ZH_T: {
38
38
  readonly balErrEmpty: "未获取到余额数据";
39
39
  readonly balErrTimeout: "查询超时";
40
40
  readonly balUnsupported: "当前提供商不支持余额查询";
41
+ readonly balDetailPlan: "套餐";
42
+ readonly balDetailUsed: "已用";
43
+ readonly balDetailRemaining: "剩余";
44
+ readonly balDetailWindow: "周期";
45
+ readonly balDetailReset: "重置";
46
+ readonly balDetailCodeReview: "Code Review";
47
+ readonly balDetailCredits: "Credits";
48
+ readonly balDetailResetCredits: "重置次数";
49
+ readonly balUnlimited: "无限";
50
+ readonly balDay: "天";
51
+ readonly balHour: "小时";
52
+ readonly balMinute: "分钟";
53
+ readonly balResetSoon: "即将重置";
41
54
  readonly barHit: "命中率";
42
55
  readonly barBal: "余额";
43
56
  readonly barTok: "Tokens";
package/dist/i18n.js CHANGED
@@ -41,6 +41,19 @@ const ZH_T = {
41
41
  balErrEmpty: "未获取到余额数据",
42
42
  balErrTimeout: "查询超时",
43
43
  balUnsupported: "当前提供商不支持余额查询",
44
+ balDetailPlan: "套餐",
45
+ balDetailUsed: "已用",
46
+ balDetailRemaining: "剩余",
47
+ balDetailWindow: "周期",
48
+ balDetailReset: "重置",
49
+ balDetailCodeReview: "Code Review",
50
+ balDetailCredits: "Credits",
51
+ balDetailResetCredits: "重置次数",
52
+ balUnlimited: "无限",
53
+ balDay: "天",
54
+ balHour: "小时",
55
+ balMinute: "分钟",
56
+ balResetSoon: "即将重置",
44
57
  barHit: "命中率",
45
58
  barBal: "余额",
46
59
  barTok: "Tokens",
@@ -122,6 +135,19 @@ const EN_T = {
122
135
  balErrEmpty: "No balance data",
123
136
  balErrTimeout: "Request timed out",
124
137
  balUnsupported: "Balance query unsupported",
138
+ balDetailPlan: "Plan",
139
+ balDetailUsed: "Used",
140
+ balDetailRemaining: "Remaining",
141
+ balDetailWindow: "Window",
142
+ balDetailReset: "Reset",
143
+ balDetailCodeReview: "Code Review",
144
+ balDetailCredits: "Credits",
145
+ balDetailResetCredits: "Reset credits",
146
+ balUnlimited: "Unlimited",
147
+ balDay: "d",
148
+ balHour: "h",
149
+ balMinute: "m",
150
+ balResetSoon: "soon",
125
151
  barHit: "Hit",
126
152
  barBal: "Balance",
127
153
  barTok: "Tokens",
@@ -203,6 +229,19 @@ const JA_T = {
203
229
  balErrEmpty: "残高データなし",
204
230
  balErrTimeout: "タイムアウト",
205
231
  balUnsupported: "このプロバイダは残高照会非対応",
232
+ balDetailPlan: "プラン",
233
+ balDetailUsed: "使用済み",
234
+ balDetailRemaining: "残り",
235
+ balDetailWindow: "期間",
236
+ balDetailReset: "リセット",
237
+ balDetailCodeReview: "Code Review",
238
+ balDetailCredits: "Credits",
239
+ balDetailResetCredits: "リセット回数",
240
+ balUnlimited: "無制限",
241
+ balDay: "日",
242
+ balHour: "時間",
243
+ balMinute: "分",
244
+ balResetSoon: "まもなく",
206
245
  barHit: "ヒット率",
207
246
  barBal: "残高",
208
247
  barTok: "Tokens",
@@ -284,6 +323,19 @@ const KO_T = {
284
323
  balErrEmpty: "잔액 데이터 없음",
285
324
  balErrTimeout: "시간 초과",
286
325
  balUnsupported: "이 프로바이더는 잔액 조회 미지원",
326
+ balDetailPlan: "플랜",
327
+ balDetailUsed: "사용",
328
+ balDetailRemaining: "잔여",
329
+ balDetailWindow: "주기",
330
+ balDetailReset: "재설정",
331
+ balDetailCodeReview: "Code Review",
332
+ balDetailCredits: "Credits",
333
+ balDetailResetCredits: "재설정 횟수",
334
+ balUnlimited: "무제한",
335
+ balDay: "일",
336
+ balHour: "시간",
337
+ balMinute: "분",
338
+ balResetSoon: "곧 재설정",
287
339
  barHit: "히트율",
288
340
  barBal: "잔액",
289
341
  barTok: "Tokens",
package/dist/index.js CHANGED
@@ -248,15 +248,57 @@ function convertBalance(target, targetRate, amount, from) {
248
248
  /**
249
249
  * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
250
250
  * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
251
- * key 来源:auth.jsonprovider.key)或配置(provider.options.apiKey)。
252
- * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
251
+ * OpenAI 优先读取 auth.json OAuth;其他 provider 读取 provider.key / provider.options.apiKey
252
+ * 读取失败或未匹配返回空串。
253
253
  */
254
+ function readOpenAIOAuthToken(api) {
255
+ try {
256
+ // OpenAI OAuth credentials are stored separately from provider.key.
257
+ const loader = typeof process !== "undefined" ? process?.getBuiltinModule : undefined;
258
+ const fs = loader?.("node:fs");
259
+ if (!fs)
260
+ return "";
261
+ const stateDir = api.state.path.state.replace(/[\\/]+$/, "");
262
+ const home = typeof process !== "undefined" ? (process?.env.HOME || process?.env.USERPROFILE || "") : "";
263
+ const dataHome = typeof process !== "undefined" ? process?.env.XDG_DATA_HOME : undefined;
264
+ const paths = [
265
+ stateDir ? `${stateDir}/auth.json` : "",
266
+ dataHome ? `${dataHome}/opencode/auth.json` : "",
267
+ home ? `${home}/.local/share/opencode/auth.json` : "",
268
+ ];
269
+ for (const path of paths) {
270
+ if (!path)
271
+ continue;
272
+ try {
273
+ const auth = JSON.parse(fs.readFileSync(path, "utf8"));
274
+ const openai = auth.openai;
275
+ if (openai && typeof openai === "object") {
276
+ const record = openai;
277
+ if (record.type === "oauth" && typeof record.access === "string")
278
+ return record.access;
279
+ }
280
+ }
281
+ catch { /* try the next known auth path */ }
282
+ }
283
+ return "";
284
+ }
285
+ catch {
286
+ return "";
287
+ }
288
+ }
254
289
  function findOpencodeKey(api, provider) {
255
290
  try {
256
291
  const provs = api.state.provider;
257
292
  // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
258
293
  const id = provider.id.toLowerCase();
259
294
  const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id));
295
+ const isOpenAI = id === "openai";
296
+ // OAuth token 优先于 provider.key,避免把配置中的占位值当成 access token。
297
+ if (isOpenAI) {
298
+ const oauth = readOpenAIOAuthToken(api);
299
+ if (oauth)
300
+ return oauth;
301
+ }
260
302
  if (!hit)
261
303
  return "";
262
304
  const k = typeof hit.key === "string" ? hit.key : "";
@@ -295,6 +337,9 @@ function formatBalanceAmount(total) {
295
337
  * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
296
338
  */
297
339
  function formatBalanceText(list, pref, rate) {
340
+ const custom = list.find((x) => x.display);
341
+ if (custom?.display)
342
+ return custom.display;
298
343
  const native = pref ? list.find((x) => x.currency === pref) : undefined;
299
344
  if (native)
300
345
  return balanceSymbol(native.currency) + formatBalanceAmount(native.total);
@@ -308,6 +353,16 @@ function formatBalanceText(list, pref, rate) {
308
353
  : formatBalanceAmount(base.total);
309
354
  return balanceSymbol(pref || base.currency) + shown;
310
355
  }
356
+ const BALANCE_DETAIL_LABELS = {
357
+ plan: "balDetailPlan",
358
+ used: "balDetailUsed",
359
+ remaining: "balDetailRemaining",
360
+ window: "balDetailWindow",
361
+ reset: "balDetailReset",
362
+ codeReview: "balDetailCodeReview",
363
+ credits: "balDetailCredits",
364
+ resetCredits: "balDetailResetCredits",
365
+ };
311
366
  const CURRENCIES = {
312
367
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
313
368
  };
@@ -332,6 +387,7 @@ function TokenCachePanel(props) {
332
387
  const [modelOpen, setModelOpen] = createSignal(true);
333
388
  const [distOpen, setDistOpen] = createSignal(false);
334
389
  const [skillsOpen, setSkillsOpen] = createSignal(true);
390
+ const [balanceOpen, setBalanceOpen] = createSignal(false);
335
391
  let boxEl;
336
392
  // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
337
393
  createEffect(() => {
@@ -342,6 +398,38 @@ function TokenCachePanel(props) {
342
398
  const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceUnsupported, setBalanceUnsupported, balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
343
399
  // ── reactive translation (follows langCode signal) ──
344
400
  const t = createT(() => langCode());
401
+ const formatBalanceDuration = (seconds, fallback = "") => {
402
+ if (!Number.isFinite(seconds))
403
+ return "";
404
+ let remaining = Math.max(0, Math.round(seconds));
405
+ const days = Math.floor(remaining / 86400);
406
+ remaining %= 86400;
407
+ const hours = Math.floor(remaining / 3600);
408
+ remaining %= 3600;
409
+ const minutes = Math.floor(remaining / 60);
410
+ const parts = [];
411
+ if (days > 0)
412
+ parts.push(`${days}${t("balDay")}`);
413
+ if (hours > 0 && parts.length < 2)
414
+ parts.push(`${hours}${t("balHour")}`);
415
+ if (minutes > 0 && parts.length < 2)
416
+ parts.push(`${minutes}${t("balMinute")}`);
417
+ return parts.join(langCode() === "en" ? " " : "") || fallback;
418
+ };
419
+ const formatBalanceDetailValue = (detail) => {
420
+ if (detail.value === "unlimited")
421
+ return t("balUnlimited");
422
+ if (detail.key !== "reset")
423
+ return detail.value;
424
+ return formatBalanceDuration(Number(detail.value), t("balResetSoon")) || detail.value;
425
+ };
426
+ const formatBalanceDetailLabel = (detail) => {
427
+ const label = t(BALANCE_DETAIL_LABELS[detail.key]);
428
+ if (detail.windowSeconds === undefined)
429
+ return label;
430
+ const window = formatBalanceDuration(detail.windowSeconds);
431
+ return window ? `${label} (${window})` : label;
432
+ };
345
433
  // ── scan session messages reactively ──
346
434
  // SolidJS createMemo re-evaluates whenever the underlying
347
435
  // api.state.session state changes — no event listener needed.
@@ -646,6 +734,7 @@ function TokenCachePanel(props) {
646
734
  const data = createMemo(() => {
647
735
  return dataSignal();
648
736
  });
737
+ const balanceDetails = createMemo(() => balanceState().data?.find((entry) => entry.details)?.details ?? []);
649
738
  // Persist the last valid distribution so that data() can fall back
650
739
  // to it while api.state.part() is re-hydrating after a view switch.
651
740
  createEffect(() => {
@@ -681,6 +770,7 @@ function TokenCachePanel(props) {
681
770
  setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)));
682
771
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
683
772
  setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)));
773
+ setBalanceOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.balance.open`, false)));
684
774
  }
685
775
  catch { }
686
776
  // Restore user config (currency, rate, section visibility).
@@ -833,6 +923,14 @@ function TokenCachePanel(props) {
833
923
  const gap = Math.max(1, gauge - used);
834
924
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
835
925
  };
926
+ const balanceHeader = () => {
927
+ const arrow = balanceDetails().length > 0 ? (balanceOpen() ? "\u25bc " : "\u25b6 ") : "";
928
+ const title = t("secBalance");
929
+ const summary = balanceState().data ? formatBalanceText(balanceState().data, balanceCurrency(), exchangeRate()) : "";
930
+ const gauge = panelWidth() - gutter();
931
+ const dividerLength = Math.max(1, gauge - visualWidth(arrow + title) - visualWidth(summary) - 1);
932
+ return { arrow, title, summary, divider: sep().slice(0, dividerLength) };
933
+ };
836
934
  return (_jsxs("box", { border: borderVisible(), ...(borderVisible() ? { borderColor: pal().border } : {}), paddingTop: 0, paddingBottom: 0, paddingLeft: borderVisible() ? 2 : 0, paddingRight: borderVisible() ? 2 : 0, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
837
935
  // boxEl.width may be undefined before the first measurement — guard with 0
838
936
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
@@ -846,7 +944,7 @@ function TokenCachePanel(props) {
846
944
  const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
847
945
  const label = truncateVisual(sk.name, maxLabel);
848
946
  return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t("tok")) }));
849
- }) })] }) }), _jsxs(Show, { when: sectionBalance(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx(Show, { when: balanceUnsupported(), children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balUnsupported") })] }) }), _jsxs(Show, { when: !balanceUnsupported(), children: [_jsx(Show, { when: balanceState().status === "idle", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balNoKey", { p: providerName() }) })] }) }), _jsx(Show, { when: balanceState().status === "loading", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balLoading") })] }) }), _jsx(Show, { when: balanceState().status === "error", children: _jsxs("text", { fg: pal().error, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: (() => {
947
+ }) })] }) }), _jsxs(Show, { when: sectionBalance(), children: [_jsx(Show, { when: balanceUnsupported(), children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balUnsupported") })] }) }), _jsxs(Show, { when: !balanceUnsupported(), children: [_jsx(Show, { when: balanceState().status === "idle", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balNoKey", { p: providerName() }) })] }) }), _jsx(Show, { when: balanceState().status === "loading", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balLoading") })] }) }), _jsx(Show, { when: balanceState().status === "error", children: _jsxs("text", { fg: pal().error, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: (() => {
850
948
  const code = balanceState().error;
851
949
  if (code === "401")
852
950
  return t("balErr401");
@@ -857,7 +955,11 @@ function TokenCachePanel(props) {
857
955
  if (code === "TIMEOUT")
858
956
  return t("balErrTimeout");
859
957
  return t("balError") + (code ? ` (${code})` : "");
860
- })() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: _jsx("text", { fg: pal().text, children: justify(t("balTotal"), formatBalanceText(balanceState().data, balanceCurrency(), exchangeRate())) }) })] })] })] })] })] }));
958
+ })() })] }) }), _jsxs(Show, { when: balanceState().status === "ok" && balanceState().data, children: [_jsxs(Show, { when: balanceDetails().length > 0, children: [_jsxs("text", { fg: pal().text, onMouseUp: () => {
959
+ const next = !balanceOpen();
960
+ setBalanceOpen(next);
961
+ persistFold("balance.open", next);
962
+ }, children: [_jsx("span", { style: { fg: pal().muted }, children: balanceHeader().arrow }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: balanceHeader().title }) }), _jsx("span", { style: { fg: pal().muted }, children: balanceHeader().divider }), _jsx("span", { children: " " + balanceHeader().summary })] }), _jsx(Show, { when: balanceOpen(), children: balanceDetails().map((detail) => (_jsx("text", { fg: pal().muted, children: justify(formatBalanceDetailLabel(detail) + ":", formatBalanceDetailValue(detail)) }))) })] }), _jsxs(Show, { when: balanceDetails().length === 0, children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx("text", { fg: pal().text, children: justify(t("balTotal"), formatBalanceText(balanceState().data, balanceCurrency(), exchangeRate())) })] })] })] })] })] })] })] }));
861
963
  }
862
964
  // ---------------------------------------------------------------------------
863
965
  // Plugin entry