dsh-multi-provider-usage 0.7.1 → 0.7.3

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/lib/client.js CHANGED
@@ -51,11 +51,12 @@ window.__ModuleLoader__.load({
51
51
  ".dpu-provider:first-child{border-top:none;padding-top:0}",
52
52
  ".dpu-provider-head{display:flex;align-items:center;gap:2px;width:100%;min-width:0;border:none;background:transparent;border-radius:6px;padding:1px 1px;font:inherit;color:inherit;cursor:pointer;text-align:left}",
53
53
  ".dpu-provider-head:hover{background:var(--dsw-alias-interactive-bg-hover)}",
54
- ".dpu-chevron{flex:none;width:9px;font-size:9px;line-height:16px;color:var(--dsw-alias-label-tertiary)}",
55
54
  ".dpu-provider-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600}",
56
55
  ".dpu-provider-pct{flex:none;font-size:10px;font-weight:700;line-height:16px;color:var(--dsw-alias-label-primary)}",
57
56
  ".dpu-provider-balance{flex:none;max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 3px;border-radius:6px;font-size:10px;line-height:16px;font-weight:700;color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent)}",
58
57
  ".dpu-provider-balance.dpu-muted{color:var(--dsw-alias-label-tertiary)}",
58
+ ".dpu-provider-balance.dpu-low{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent)}",
59
+ ".dpu-provider-balance.dpu-critical{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent)}",
59
60
  ".dpu-provider-balance.dpu-hit{animation:dpu-balance-pop 1.4s ease}",
60
61
  ".dpu-balance-setup{flex:none;max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px;border-radius:6px;font-size:10px;line-height:16px;font-weight:600;color:var(--dsw-alias-label-secondary);background:transparent;border:1px dashed color-mix(in srgb, var(--dsw-alias-label-secondary) 55%, transparent);cursor:pointer}",
61
62
  ".dpu-balance-setup:hover{color:var(--dsw-alias-label-primary);border-color:currentColor}",
@@ -65,9 +66,9 @@ window.__ModuleLoader__.load({
65
66
  ".dpu-balance-float.dpu-up-text{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}",
66
67
  "@keyframes dpu-balance-pop{0%{transform:scale(.86);opacity:.35}35%{transform:scale(1.16);opacity:1}65%{transform:scale(1.02)}100%{transform:scale(1)}}",
67
68
  "@keyframes dpu-balance-fly{0%{opacity:0;transform:translateY(5px) scale(.9)}18%{opacity:1;transform:translateY(0) scale(1)}100%{opacity:0;transform:translateY(-12px) scale(.95)}}",
68
- ".dpu-provider-body{display:flex;flex-direction:column;gap:4px;min-width:0;padding:2px 0 3px 14px}",
69
+ ".dpu-provider-body{display:flex;flex-direction:column;gap:4px;min-width:0;padding:2px 0 3px 11px}",
69
70
  ".dpu-provider-error{font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow-wrap:anywhere}",
70
- ".dpu-dot{width:5px;height:5px;border-radius:50%;flex:none;background:var(--dsw-alias-label-tertiary)}",
71
+ ".dpu-dot{width:5px;height:5px;border-radius:50%;flex:none;margin-right:3px;background:var(--dsw-alias-label-tertiary)}",
71
72
  ".dpu-dot.dpu-ok{background:var(--dsw-alias-state-success-primary)}",
72
73
  ".dpu-dot.dpu-error{background:var(--dsw-alias-state-error-primary)}",
73
74
  ".dpu-dot.dpu-warn{background:var(--dsw-alias-state-warn-primary)}",
@@ -87,6 +88,8 @@ window.__ModuleLoader__.load({
87
88
  ".dpu-timeelapsed{height:100%;flex:none;background:var(--dsw-alias-border-l2);border-radius:3px;transition:width .4s ease}",
88
89
  ".dpu-timeremain{height:100%;flex:1 1 auto;min-width:0;background:var(--dsw-static-deepseek-450, #5686fe);border-radius:3px;transition:width .4s ease}",
89
90
  ".dpu-err{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow-wrap:anywhere}",
91
+ ".dpu-err.dpu-retry{cursor:pointer}",
92
+ ".dpu-err.dpu-retry:hover{color:var(--dsw-alias-label-primary)}",
90
93
  ".dpu-ok{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
91
94
  ".dpu-rail{box-sizing:border-box;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px;font-size:10px;font-weight:700;color:var(--dsw-alias-label-primary);cursor:default}",
92
95
  ".dpu-rail:hover{background:var(--dsw-alias-interactive-bg-hover)}",
@@ -103,7 +106,7 @@ window.__ModuleLoader__.load({
103
106
  "window.five-hour": "5 小时窗口",
104
107
  "window.weekly": "周窗口",
105
108
  "window.monthly": "月窗口",
106
- "window.mcp": "MCP 工具 (月)",
109
+ "window.mcp": "MCP 工具 (月)",
107
110
  "window.token": "Token 窗口",
108
111
  "window.other": "其他窗口",
109
112
  "window.five-hour.hint": "5h",
@@ -112,7 +115,7 @@ window.__ModuleLoader__.load({
112
115
  "time.minutes": "{n}分",
113
116
  "time.hours": "{n}小时{m}分",
114
117
  "time.days": "{n}天{m}小时",
115
- "time.elapsed.title": "本窗口时间进度:已流逝 {pct}%,剩余 {rest}%",
118
+ "time.elapsed.title": "本窗口时间进度:已流逝 {pct}%,剩余 {rest}%",
116
119
  "refresh": "刷新",
117
120
  "settings": "设置",
118
121
  "settings.title": "打开插件设置",
@@ -129,26 +132,26 @@ window.__ModuleLoader__.load({
129
132
  "status.disabled": "已禁用",
130
133
  "status.unknown": "未知",
131
134
  "rail.plain": "用量",
132
- "rail.all": "服务商用量:{summary}",
133
- "rail.error": "{provider}: 读取失败",
135
+ "rail.all": "服务商用量:{summary}",
136
+ "rail.error": "{provider}: 读取失败",
134
137
  "rail.title": "{provider} · {pct}%",
135
138
  "rail.title.account": "{provider} · {account} · {pct}%",
136
139
  "widget.brand": "服务商用量",
137
- "widget.account.title": "当前账号:{name}",
140
+ "widget.account.title": "当前账号:{name}",
138
141
  "widget.fallback": "内置",
139
- "widget.fallback.title": "实际来源:{source};请求来源:{requested};回退原因:{reason}{message}",
140
- "widget.partial.title": "部分接口失败:{error}",
142
+ "widget.fallback.title": "实际来源:{source};请求来源:{requested};回退原因:{reason}{message}",
143
+ "widget.partial.title": "部分接口失败:{error}",
141
144
  "balance.extra": "充值余额",
142
145
  "balance.zen": "充值余额",
143
146
  "balance.short": "余额",
144
147
  "balance.none": "余额 —",
145
- "balance.title": "订阅外充值余额 {amount}(来源:{source})",
146
- "balance.delta.title": "本轮余额变化:{from} → {to}",
147
- "balance.none.title": "充值余额不可用:{error}",
148
+ "balance.title": "订阅外充值余额 {amount}(来源:{source})",
149
+ "balance.delta.title": "本轮余额变化:{from} → {to}",
150
+ "balance.none.title": "充值余额不可用:{error}",
148
151
  "balance.setup": "配置余额",
149
- "balance.setup.title": "未配置 OpenCode 控制台令牌,无法读取充值余额。点击打开插件设置,或运行 opencode auth login。",
152
+ "balance.setup.title": "没有可用的 OpenCode 控制台 OAuth 登录,无法读取充值余额(API Key 登录不含余额,令牌过期也会如此)。点击打开设置页查看分步引导,或运行 opencode auth login 并选择 OpenCode Console 账号。",
150
153
  "balance.missing": "无充值额度",
151
- "balance.missing.title": "Command Code 的 credits 接口未返回购买或免费额度,因此没有充值余额可显示。",
154
+ "balance.missing.title": "Command Code 的 credits 接口未返回购买或免费额度,因此没有充值余额可显示。",
152
155
  "balance.details.extra": "购买 {purchased} + 免费 {free}",
153
156
  "autoUpdate.updated": "插件已更新到 v{v},重启 DSH 生效",
154
157
  "autoUpdate.available": "插件有新版本 v{v},自动更新未成功,请手动更新",
@@ -163,23 +166,48 @@ window.__ModuleLoader__.load({
163
166
  "reason.unknown": "未知原因",
164
167
  "update.available": "新版本 v{v}",
165
168
  "update.title": "点击查看升级说明",
169
+ "error.retry": "点击重试",
166
170
 
167
171
  // settings page (Plugins panel)
168
172
  "cfg.summary": "在一个侧边栏组件中查看 OpenCode GO、Command Code 与 GLM Code Plan 的套餐用量。",
169
173
  "cfg.title": "服务商用量设置",
170
- "cfg.lead": "选择要显示的服务商与凭据引用。保存后立即生效,无需重启。API Key 保存在 DSH 凭据域中,不会出现在这里。",
174
+ "cfg.lead": "选择要显示的服务商与凭据引用。保存后立即生效,无需重启。API Key 保存在 DSH 凭据域中,不会出现在这里。",
171
175
  "cfg.providers": "服务商",
172
176
  "cfg.console.status": "充值余额",
173
- "cfg.console.hint": "运行 opencode auth login 登录 OpenCode 控制台,或把控制台令牌保存为 OPENCODE_CONSOLE_TOKEN 凭据,即可显示充值余额。",
177
+ "cfg.console.hint": "运行 opencode auth login 登录 OpenCode 控制台,或把控制台令牌保存为 OPENCODE_CONSOLE_TOKEN 凭据,即可显示充值余额。",
178
+ "cfg.console.apiKey": "本机 OpenCode 当前是 API Key 登录,不含控制台余额;请运行 opencode auth login 并选择 OpenCode Console 账号。",
179
+ "cfg.console.expired": "控制台登录已过期,请重新运行 opencode auth login 后再刷新。",
180
+ "cfg.console.guide.title": "如何显示充值余额",
181
+ "cfg.console.guide.step1": "在终端运行以下命令,选择 OpenCode Console 账号登录:",
182
+ "cfg.console.guide.step2": "或从控制台网页复制令牌,保存为 DSH 凭据:",
183
+ "cfg.console.guide.step3": "完成后点击重新检测,插件会重读登录状态并立即刷新余额,无需重启 DSH。",
184
+ "cfg.console.guide.link": "打开 OpenCode 控制台 ↗",
185
+ "cfg.console.copy.command": "复制命令",
186
+ "cfg.console.copy.ref": "复制凭据名",
187
+ "cfg.console.recheck": "重新检测",
188
+ "cfg.console.rechecking": "检测中…",
189
+ "cfg.console.copied": "已复制",
190
+ "cfg.console.copyFailed": "复制失败,请手动复制。",
191
+ "cfg.usage": "实时用量",
192
+ "cfg.usage.refresh": "刷新用量",
193
+ "cfg.usage.refreshing": "刷新中…",
194
+ "cfg.usage.loading": "正在读取用量…",
195
+ "cfg.usage.loadFailed": "用量读取失败:{error}",
196
+ "cfg.usage.disabled": "已禁用,启用并保存后显示。",
197
+ "cfg.usage.noData": "暂无数据",
198
+ "cfg.usage.error": "读取失败:{error}",
199
+ "cfg.usage.balance": "充值余额",
200
+ "cfg.usage.noBalance": "暂无充值余额",
201
+ "cfg.usage.updatedAt": "更新于 {time}",
174
202
  "cfg.enabled": "启用",
175
203
  "cfg.order.hint": "拖动此处调整显示顺序",
176
204
  "cfg.move.up": "上移",
177
205
  "cfg.move.down": "下移",
178
- "cfg.credential": "凭据引用 (apiKeyEnv)",
206
+ "cfg.credential": "凭据引用 (apiKeyEnv)",
179
207
  "cfg.invite": "推广链接",
180
208
  "cfg.invite.copy": "复制",
181
209
  "cfg.invite.copied": "已复制",
182
- "cfg.invite.copyFailed": "复制失败,请手动选中链接复制。",
210
+ "cfg.invite.copyFailed": "复制失败,请手动选中链接复制。",
183
211
  "cfg.status.configured": "已配置",
184
212
  "cfg.status.notConfigured": "未配置",
185
213
  "cfg.status.enabled": "已启用",
@@ -192,13 +220,13 @@ window.__ModuleLoader__.load({
192
220
  "cfg.reloading": "加载中…",
193
221
  "cfg.reloaded": "已重新加载。",
194
222
  "cfg.retry": "重试",
195
- "cfg.saved": "已保存,设置已更新。",
223
+ "cfg.saved": "已保存,设置已更新。",
196
224
  "cfg.noChanges": "没有需要保存的更改。",
197
225
  "cfg.unsaved": "未保存",
198
- "cfg.conflict": "保存冲突:设置已被其他会话修改,请点击「重新加载」后再保存。",
199
- "cfg.saveFailed": "保存失败:{error}",
200
- "cfg.loadFailed": "加载设置失败:{error}",
201
- "cfg.unsupported": "宿主未提供设置描述符 (503)。请确认插件以 profile 条目安装,且 schemastery 版本 ≥ 3.18.4。",
226
+ "cfg.conflict": "保存冲突:设置已被其他会话修改,请点击「重新加载」后再保存。",
227
+ "cfg.saveFailed": "保存失败:{error}",
228
+ "cfg.loadFailed": "加载设置失败:{error}",
229
+ "cfg.unsupported": "宿主未提供设置描述符 (503)。请确认插件以 profile 条目安装,且 schemastery 版本 ≥ 3.18.4。",
202
230
  "cfg.revision": "revision {n}"
203
231
  };
204
232
  const en = {
@@ -249,7 +277,7 @@ window.__ModuleLoader__.load({
249
277
  "balance.delta.title": "Balance change this turn: {from} → {to}",
250
278
  "balance.none.title": "Top-up balance unavailable: {error}",
251
279
  "balance.setup": "Set up",
252
- "balance.setup.title": "No OpenCode console token, so the top-up balance cannot be read. Click to open the plugin settings, or run opencode auth login.",
280
+ "balance.setup.title": "No usable OpenCode console OAuth login, so the top-up balance cannot be read (API-key logins carry no balance, and expired sessions fail too). Click to open the plugin settings for the step-by-step guide, or run opencode auth login and choose the OpenCode Console account.",
253
281
  "balance.missing": "No top-up",
254
282
  "balance.missing.title": "Command Code's credits response carried no purchased or free credits, so there is no top-up balance to show.",
255
283
  "balance.details.extra": "purchased {purchased} + free {free}",
@@ -266,6 +294,7 @@ window.__ModuleLoader__.load({
266
294
  "reason.unknown": "unknown reason",
267
295
  "update.available": "v{v} available",
268
296
  "update.title": "Click for upgrade instructions",
297
+ "error.retry": "Click to retry",
269
298
 
270
299
  // settings page (Plugins panel)
271
300
  "cfg.summary": "OpenCode GO, Command Code and GLM Code Plan plan usage in one sidebar widget.",
@@ -274,6 +303,30 @@ window.__ModuleLoader__.load({
274
303
  "cfg.providers": "Providers",
275
304
  "cfg.console.status": "Top-up balance",
276
305
  "cfg.console.hint": "Run opencode auth login to sign in to the OpenCode console, or store the console token as the OPENCODE_CONSOLE_TOKEN credential, to show the top-up balance.",
306
+ "cfg.console.apiKey": "The local OpenCode login is an API key, which carries no console balance; run opencode auth login and choose the OpenCode Console account.",
307
+ "cfg.console.expired": "The console sign-in expired; run opencode auth login again and refresh.",
308
+ "cfg.console.guide.title": "How to show the top-up balance",
309
+ "cfg.console.guide.step1": "Run this command in a terminal and choose the OpenCode Console account:",
310
+ "cfg.console.guide.step2": "Or copy the token from the console website and store it as a DSH credential:",
311
+ "cfg.console.guide.step3": "When done, click Re-check — the plugin re-reads the sign-in state and refreshes the balance immediately; no DSH restart needed.",
312
+ "cfg.console.guide.link": "Open the OpenCode console ↗",
313
+ "cfg.console.copy.command": "Copy command",
314
+ "cfg.console.copy.ref": "Copy credential ref",
315
+ "cfg.console.recheck": "Re-check",
316
+ "cfg.console.rechecking": "Checking…",
317
+ "cfg.console.copied": "Copied",
318
+ "cfg.console.copyFailed": "Copy failed; copy it manually.",
319
+ "cfg.usage": "Live usage",
320
+ "cfg.usage.refresh": "Refresh usage",
321
+ "cfg.usage.refreshing": "Refreshing…",
322
+ "cfg.usage.loading": "Loading usage…",
323
+ "cfg.usage.loadFailed": "Failed to load usage: {error}",
324
+ "cfg.usage.disabled": "Disabled; enable it and save to show usage.",
325
+ "cfg.usage.noData": "No data",
326
+ "cfg.usage.error": "read failed: {error}",
327
+ "cfg.usage.balance": "Top-up balance",
328
+ "cfg.usage.noBalance": "no top-up balance",
329
+ "cfg.usage.updatedAt": "Updated {time}",
277
330
  "cfg.enabled": "Enabled",
278
331
  "cfg.order.hint": "Drag here to reorder",
279
332
  "cfg.move.up": "Move up",
@@ -329,6 +382,16 @@ window.__ModuleLoader__.load({
329
382
  return t("time.days", { n: Math.floor(hours / 24), m: hours % 24 });
330
383
  }
331
384
 
385
+ /** Compact local clock "HH:MM" for the 「更新于」 stamp; "" when invalid. */
386
+ function formatClock(ms) {
387
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return "";
388
+ try {
389
+ return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
390
+ } catch (_error) {
391
+ return "";
392
+ }
393
+ }
394
+
332
395
  /** Fill color by usage percent — themed alias tokens (light/dark safe). */
333
396
  function fillColor(percent) {
334
397
  if (percent >= 90) return "var(--dsw-alias-state-error-primary)";
@@ -364,6 +427,20 @@ window.__ModuleLoader__.load({
364
427
  return (change.amount < 0 ? "-" : "+") + text;
365
428
  }
366
429
 
430
+ /**
431
+ * Balance tone class (leading space included): at or below the critical
432
+ * threshold the chip turns red, at or below the low threshold amber,
433
+ * otherwise the usual green. Thresholds are in the currency unit and
434
+ * apply to USD and CNY plans alike — single-digit balances are exactly
435
+ * when "how much is left" starts to matter.
436
+ */
437
+ function balanceTone(balance) {
438
+ if (!balance || typeof balance.amount !== "number" || !Number.isFinite(balance.amount)) return "";
439
+ if (balance.amount <= 1) return " dpu-critical";
440
+ if (balance.amount <= 5) return " dpu-low";
441
+ return "";
442
+ }
443
+
367
444
  /** Animate a balance from its previous value to the refreshed value. */
368
445
  function AnimatedBalance({ value, currency, delta }) {
369
446
  const from = delta && typeof delta.from === "number" ? delta.from : value;
@@ -556,8 +633,10 @@ window.__ModuleLoader__.load({
556
633
  if (state.status === "updated" && state.restartRequired === true) {
557
634
  return t("autoUpdate.updated", { v: state.version || state.latestVersion || "?" });
558
635
  }
559
- if (state.status === "available") {
560
- return t("autoUpdate.available", { v: state.latestVersion || "?" });
636
+ // The host only reports "available" after a successful check, so the
637
+ // version is real; the guard keeps a stale "v?" off the widget anyway.
638
+ if (state.status === "available" && typeof state.latestVersion === "string" && state.latestVersion.length > 0) {
639
+ return t("autoUpdate.available", { v: state.latestVersion });
561
640
  }
562
641
  return "";
563
642
  }
@@ -643,9 +722,17 @@ window.__ModuleLoader__.load({
643
722
  }
644
723
  }
645
724
 
646
- /** True when the balance read failed only because no console token exists. */
647
- function isMissingConsoleCredential(message) {
648
- return typeof message === "string" && /no console credential/i.test(message);
725
+ /**
726
+ * True when the balance failed in a way the user can fix by signing in
727
+ * again (no console token, an API-key-only login, or an expired grant).
728
+ * The host marks these with `meta.balanceSetup`; the message pattern
729
+ * keeps older host payloads working.
730
+ */
731
+ function isBalanceSetupError(provider) {
732
+ const setup = provider?.meta && typeof provider.meta.balanceSetup === "string" ? provider.meta.balanceSetup : "";
733
+ if (setup === "no-credential" || setup === "api-key" || setup === "expired") return true;
734
+ const message = provider?.meta && typeof provider.meta.balanceError === "string" ? provider.meta.balanceError : "";
735
+ return /no console credential|opencode auth login|not an OpenCode Console session|expired or not a Console login/i.test(message);
649
736
  }
650
737
 
651
738
  /** Open the plugin settings from the balance chip (never toggles the card). */
@@ -777,6 +864,15 @@ window.__ModuleLoader__.load({
777
864
  });
778
865
  };
779
866
 
867
+ /** Click/keyboard retry: force one fresh upstream read for all cards. */
868
+ const forceRefresh = () => setStamp(stamp + 1);
869
+ const retryKeyDown = (event) => {
870
+ if (event && (event.key === "Enter" || event.key === " " || event.key === "Spacebar")) {
871
+ if (typeof event.preventDefault === "function") event.preventDefault();
872
+ forceRefresh();
873
+ }
874
+ };
875
+
780
876
  // Collapsed rail: a single compact badge summarising all providers.
781
877
  if (!props.wide) {
782
878
  const maxPct = providers.reduce((max, provider) => {
@@ -800,7 +896,7 @@ window.__ModuleLoader__.load({
800
896
  const balance = provider.balance && typeof provider.balance.amount === "number" ? provider.balance : null;
801
897
  const delta = balanceDeltas[provider.id] && typeof balanceDeltas[provider.id].amount === "number" ? balanceDeltas[provider.id] : null;
802
898
  const showDelta = delta !== null && Math.abs(delta.amount) >= 0.005;
803
- const balanceClass = "dpu-provider-balance" + (showDelta ? " dpu-hit" : "");
899
+ const balanceClass = "dpu-provider-balance" + (showDelta ? " dpu-hit" : "") + balanceTone(balance);
804
900
  // Command Code's balance is purchased + free credits; name the
805
901
  // composition in the tooltip so the single number stays honest.
806
902
  const balanceDetails = balance && balance.details && typeof balance.details === "object" && typeof balance.details.purchased === "number"
@@ -816,10 +912,13 @@ window.__ModuleLoader__.load({
816
912
  })
817
913
  : t("balance.title", { amount: balanceText, source: balance ? asText(balance.source) : "" }) + (balanceDetails === "" ? "" : " · " + balanceDetails);
818
914
  const rawBalanceError = provider.meta && typeof provider.meta.balanceError === "string" ? provider.meta.balanceError : "";
819
- // No console credential is actionable, not a failure: the header
820
- // shows a "set up balance" chip that opens the plugin settings.
821
- const missingConsoleCredential = isMissingConsoleCredential(rawBalanceError);
822
- const balanceError = rawBalanceError !== "" && !missingConsoleCredential ? rawBalanceError : "";
915
+ // No console credential (or an API-key-only / expired login) is
916
+ // actionable, not a failure: the header shows a "set up balance"
917
+ // chip that opens the plugin settings. Its tooltip carries the
918
+ // precise host reason when one exists.
919
+ const balanceSetup = isBalanceSetupError(provider);
920
+ const balanceSetupTitle = rawBalanceError === "" ? t("balance.setup.title") : t("balance.setup.title") + " · " + rawBalanceError;
921
+ const balanceError = rawBalanceError !== "" && !balanceSetup ? rawBalanceError : "";
823
922
  // Command Code: credits answered without either credit pool, so
824
923
  // there is no top-up balance to show (it is not a config problem).
825
924
  const balanceMissing = provider.meta && provider.meta.balanceMissing === true;
@@ -906,8 +1005,12 @@ window.__ModuleLoader__.load({
906
1005
  if (provider.status === "error") {
907
1006
  const message = asText(provider.error) || t("status.error");
908
1007
  body.push(react_jsx_runtime.jsx("div", {
909
- className: "dpu-provider-error",
910
- title: message,
1008
+ className: "dpu-provider-error dpu-retry",
1009
+ role: "button",
1010
+ tabIndex: 0,
1011
+ title: message + " · " + t("error.retry"),
1012
+ onClick: forceRefresh,
1013
+ onKeyDown: retryKeyDown,
911
1014
  children: message
912
1015
  }, "error"));
913
1016
  } else if (rows.length === 0) {
@@ -934,12 +1037,12 @@ window.__ModuleLoader__.load({
934
1037
  title: t("balance.none.title", { error: balanceError }),
935
1038
  children: t("balance.none")
936
1039
  }, "balance-error")
937
- : missingConsoleCredential
1040
+ : balanceSetup
938
1041
  ? react_jsx_runtime.jsx("span", {
939
1042
  className: "dpu-balance-setup",
940
1043
  role: "button",
941
1044
  tabIndex: 0,
942
- title: t("balance.setup.title"),
1045
+ title: balanceSetupTitle,
943
1046
  onClick: activateBalanceSetup,
944
1047
  onKeyDown: balanceSetupKeyDown,
945
1048
  children: t("balance.setup")
@@ -969,10 +1072,6 @@ window.__ModuleLoader__.load({
969
1072
  title: providerTitle(t, provider),
970
1073
  "aria-expanded": !isCollapsed,
971
1074
  children: [
972
- react_jsx_runtime.jsx("span", {
973
- className: "dpu-chevron",
974
- children: isCollapsed ? "▸" : "▾"
975
- }),
976
1075
  react_jsx_runtime.jsx("span", { className: providerStatusClass(provider) }),
977
1076
  react_jsx_runtime.jsx("span", {
978
1077
  className: "dpu-provider-name",
@@ -1035,7 +1134,15 @@ window.__ModuleLoader__.load({
1035
1134
  children: autoUpdateNotice(t, data && data.autoUpdate)
1036
1135
  }),
1037
1136
  sections.length > 0 ? react_jsx_runtime.jsx("div", { className: "dpu-providers", children: sections }) : null,
1038
- error ? react_jsx_runtime.jsx("div", { className: "dpu-err", children: error }) : null,
1137
+ error ? react_jsx_runtime.jsx("div", {
1138
+ className: "dpu-err dpu-retry",
1139
+ role: "button",
1140
+ tabIndex: 0,
1141
+ title: t("error.retry"),
1142
+ onClick: forceRefresh,
1143
+ onKeyDown: retryKeyDown,
1144
+ children: error
1145
+ }) : null,
1039
1146
  !error && data === null ? react_jsx_runtime.jsx("div", { className: "dpu-ok", children: t("loading") }) : null,
1040
1147
  data !== null && providers.length === 0
1041
1148
  ? react_jsx_runtime.jsx("div", { className: "dpu-err", children: t("noProviders") })
@@ -1057,6 +1164,8 @@ window.__ModuleLoader__.load({
1057
1164
  // auto-generated "Plugin configuration" form (they keep their defaults)
1058
1165
  // so this page never scrolls.
1059
1166
  const SETTINGS_PATH = "/provider-usage/settings";
1167
+ /** Same-origin usage snapshot the settings page renders live. */
1168
+ const USAGE_PATH = "/provider-usage/usage";
1060
1169
  const PROVIDER_FORMS = [
1061
1170
  {
1062
1171
  id: "opencode-go",
@@ -1223,7 +1332,38 @@ window.__ModuleLoader__.load({
1223
1332
  fontSize: 12,
1224
1333
  fontWeight: 500
1225
1334
  },
1226
- buttonDisabled: { opacity: 0.4, cursor: "not-allowed" }
1335
+ buttonDisabled: { opacity: 0.4, cursor: "not-allowed" },
1336
+ // live-usage inset of a provider card
1337
+ usageBox: {
1338
+ display: "flex",
1339
+ flexDirection: "column",
1340
+ gap: 6,
1341
+ padding: "8px 10px",
1342
+ borderRadius: 8,
1343
+ background: "var(--dsw-alias-bg-layer-2, #101216)"
1344
+ },
1345
+ usageHead: { display: "flex", alignItems: "center", gap: 8 },
1346
+ usageLabel: { fontSize: 11, fontWeight: 600, color: "var(--dsw-alias-label-tertiary, #8a8f98)" },
1347
+ usageRow: { display: "flex", flexDirection: "column", gap: 3 },
1348
+ usageRowLabel: { display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12, lineHeight: 1.5, minWidth: 0 },
1349
+ usageRowName: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #a8adb8)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
1350
+ usageRowValue: { flex: "none", fontSize: 12, fontWeight: 500, color: "var(--dsw-alias-label-primary, #e8e8e8)" },
1351
+ usageTrack: { height: 4, borderRadius: 2, background: "var(--dsw-alias-border-l2, #2a2d34)", overflow: "hidden" },
1352
+ usageFill: { height: "100%", borderRadius: 2, transition: "width .4s ease" },
1353
+ usageHint: { margin: 0, fontSize: 11, lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary, #8a8f98)", overflowWrap: "anywhere" },
1354
+ // balance setup guide (OpenCode GO card)
1355
+ guideTitle: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-label-secondary, #a8adb8)" },
1356
+ guideStep: { display: "flex", flexDirection: "column", gap: 4 },
1357
+ guideCode: {
1358
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
1359
+ fontSize: 11,
1360
+ color: "var(--dsw-alias-label-primary, #e8e8e8)",
1361
+ background: "var(--dsw-alias-bg-layer-3, #16181d)",
1362
+ border: "1px solid var(--dsw-alias-border-l2, #2a2d34)",
1363
+ borderRadius: 6,
1364
+ padding: "2px 6px",
1365
+ overflowWrap: "anywhere"
1366
+ }
1227
1367
  };
1228
1368
 
1229
1369
  function Field(props) {
@@ -1275,11 +1415,13 @@ window.__ModuleLoader__.load({
1275
1415
  }
1276
1416
 
1277
1417
  /**
1278
- * The GLM card's referral row: the invitation URL and a Copy button.
1279
- * Purely local — no host route reads it and it is never part of the
1280
- * saved settings value, so saving settings cannot rewrite it.
1418
+ * A small copy button with the transient "Copied / Copy failed" note.
1419
+ * Shared by the referral row and the balance setup guide. `value` is
1420
+ * what lands on the clipboard; `label`/`copiedKey`/`failedKey` let the
1421
+ * caller pick the wording. Returns a boolean path so the caller can
1422
+ * show a failure hint instead of a lie.
1281
1423
  */
1282
- function InviteLink(props) {
1424
+ function CopyButton(props) {
1283
1425
  const t = props.t;
1284
1426
  const [status, setStatus] = react.useState("idle");
1285
1427
  const timer = react.useRef(null);
@@ -1287,12 +1429,38 @@ window.__ModuleLoader__.load({
1287
1429
  if (timer.current !== null) clearTimeout(timer.current);
1288
1430
  }, []);
1289
1431
  const copy = async () => {
1290
- const ok = await copyText(props.url);
1432
+ const ok = await copyText(props.value);
1291
1433
  setStatus(ok ? "copied" : "failed");
1292
1434
  if (timer.current !== null) clearTimeout(timer.current);
1293
1435
  timer.current = setTimeout(() => setStatus("idle"), 1800);
1294
1436
  };
1295
- const note = status === "copied" ? t("cfg.invite.copied") : status === "failed" ? t("cfg.invite.copyFailed") : "";
1437
+ const note = status === "copied"
1438
+ ? t(props.copiedKey ?? "cfg.invite.copied")
1439
+ : status === "failed" ? t(props.failedKey ?? "cfg.invite.copyFailed") : "";
1440
+ return react_jsx_runtime.jsx("div", {
1441
+ style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
1442
+ children: [
1443
+ react_jsx_runtime.jsx("button", {
1444
+ type: "button",
1445
+ style: { ...PS.button, padding: "3px 10px" },
1446
+ onClick: copy,
1447
+ children: props.label ?? t("cfg.invite.copy")
1448
+ }, "copy"),
1449
+ note === "" ? null : react_jsx_runtime.jsx("span", {
1450
+ style: status === "failed" ? PS.warn : PS.ok,
1451
+ children: note
1452
+ }, "copy-note")
1453
+ ]
1454
+ });
1455
+ }
1456
+
1457
+ /**
1458
+ * The GLM card's referral row: the invitation URL and a Copy button.
1459
+ * Purely local — no host route reads it and it is never part of the
1460
+ * saved settings value, so saving settings cannot rewrite it.
1461
+ */
1462
+ function InviteLink(props) {
1463
+ const t = props.t;
1296
1464
  return react_jsx_runtime.jsx("div", {
1297
1465
  style: PS.inviteRow,
1298
1466
  children: [
@@ -1304,16 +1472,206 @@ window.__ModuleLoader__.load({
1304
1472
  rel: "noreferrer",
1305
1473
  children: props.url
1306
1474
  }),
1307
- react_jsx_runtime.jsx("button", {
1308
- type: "button",
1309
- style: { ...PS.button, padding: "3px 10px" },
1310
- onClick: copy,
1311
- children: t("cfg.invite.copy")
1312
- }),
1313
- note === "" ? null : react_jsx_runtime.jsx("span", {
1314
- style: status === "failed" ? PS.warn : PS.ok,
1315
- children: note
1316
- })
1475
+ react_jsx_runtime.jsx(CopyButton, { t: t, value: props.url }, "invite-copy")
1476
+ ]
1477
+ });
1478
+ }
1479
+
1480
+ /**
1481
+ * The balance setup guide on the OpenCode GO card, shown whenever the
1482
+ * console read is not configured or names a fixable reason (API-key
1483
+ * login, expired sign-in). Three steps, each with an actionable row:
1484
+ * the CLI sign-in command (copyable), the manual credential path with
1485
+ * a console link, and a re-check that re-reads the sign-in state and
1486
+ * refreshes the balance without restarting DSH. The lead-in names the
1487
+ * state-specific fix.
1488
+ */
1489
+ function ConsoleGuide(props) {
1490
+ const t = props.t;
1491
+ const [checking, setChecking] = react.useState(false);
1492
+ const recheck = async () => {
1493
+ if (checking) return;
1494
+ setChecking(true);
1495
+ try {
1496
+ await props.onRecheck();
1497
+ } finally {
1498
+ setChecking(false);
1499
+ }
1500
+ };
1501
+ const leadIn = props.reason === "api-key"
1502
+ ? t("cfg.console.apiKey")
1503
+ : props.reason === "expired" ? t("cfg.console.expired") : t("cfg.console.hint");
1504
+ const credentialRef = typeof props.credentialRef === "string" && props.credentialRef !== ""
1505
+ ? props.credentialRef
1506
+ : "OPENCODE_CONSOLE_TOKEN";
1507
+ const command = "opencode auth login";
1508
+ const copyKeys = { copiedKey: "cfg.console.copied", failedKey: "cfg.console.copyFailed" };
1509
+ const stepRow = (key, text) => react_jsx_runtime.jsx("p", { style: PS.hint, children: text }, key);
1510
+ return react_jsx_runtime.jsx("div", {
1511
+ style: PS.usageBox,
1512
+ children: [
1513
+ react_jsx_runtime.jsx("div", { style: PS.guideTitle, children: t("cfg.console.guide.title") }, "guide-title"),
1514
+ react_jsx_runtime.jsx("p", { style: PS.hint, children: leadIn }, "guide-lead"),
1515
+ react_jsx_runtime.jsx("div", { style: PS.guideStep, children: [
1516
+ stepRow("guide-step1-text", t("cfg.console.guide.step1")),
1517
+ react_jsx_runtime.jsx("div", {
1518
+ style: PS.inviteRow,
1519
+ children: [
1520
+ react_jsx_runtime.jsx("code", { style: PS.guideCode, children: command }, "guide-command"),
1521
+ react_jsx_runtime.jsx(CopyButton, {
1522
+ t: t,
1523
+ value: command,
1524
+ label: t("cfg.console.copy.command"),
1525
+ ...copyKeys
1526
+ }, "guide-step1-copy")
1527
+ ]
1528
+ }, "guide-step1-row")
1529
+ ] }, "guide-step1"),
1530
+ react_jsx_runtime.jsx("div", { style: PS.guideStep, children: [
1531
+ stepRow("guide-step2-text", t("cfg.console.guide.step2")),
1532
+ react_jsx_runtime.jsx("div", {
1533
+ style: PS.inviteRow,
1534
+ children: [
1535
+ react_jsx_runtime.jsx("code", { style: PS.guideCode, children: credentialRef }, "guide-ref"),
1536
+ react_jsx_runtime.jsx(CopyButton, {
1537
+ t: t,
1538
+ value: credentialRef,
1539
+ label: t("cfg.console.copy.ref"),
1540
+ ...copyKeys
1541
+ }, "guide-step2-copy")
1542
+ ]
1543
+ }, "guide-step2-row"),
1544
+ typeof props.consoleUrl === "string" && props.consoleUrl !== ""
1545
+ ? react_jsx_runtime.jsx("a", {
1546
+ style: PS.inviteLink,
1547
+ href: props.consoleUrl,
1548
+ target: "_blank",
1549
+ rel: "noreferrer",
1550
+ children: t("cfg.console.guide.link")
1551
+ }, "guide-link")
1552
+ : null
1553
+ ] }, "guide-step2"),
1554
+ react_jsx_runtime.jsx("div", { style: PS.guideStep, children: [
1555
+ stepRow("guide-step3-text", t("cfg.console.guide.step3")),
1556
+ react_jsx_runtime.jsx("button", {
1557
+ type: "button",
1558
+ style: checking || props.disabled === true
1559
+ ? { ...PS.button, ...PS.buttonDisabled }
1560
+ : PS.button,
1561
+ disabled: checking || props.disabled === true,
1562
+ onClick: recheck,
1563
+ children: checking ? t("cfg.console.rechecking") : t("cfg.console.recheck")
1564
+ }, "guide-recheck")
1565
+ ] }, "guide-step3")
1566
+ ]
1567
+ });
1568
+ }
1569
+
1570
+ /**
1571
+ * The live-usage inset of one provider card. `SettingsPage` fetches the
1572
+ * canonical usage snapshot (the same payload the widget renders) and
1573
+ * passes each provider's slice in; no hooks here, so it is safe to call
1574
+ * from any render path. `provider === null` covers not-loaded-yet,
1575
+ * a failed snapshot and a disabled-in-payload provider.
1576
+ */
1577
+ function UsageSection(props) {
1578
+ const t = props.t;
1579
+ if (props.enabled === false) {
1580
+ return react_jsx_runtime.jsx("p", { style: PS.hint, children: t("cfg.usage.disabled") });
1581
+ }
1582
+ const provider = props.provider === undefined ? null : props.provider;
1583
+ if (provider === null) {
1584
+ if (props.phase === "loading" || props.phase === "idle") {
1585
+ return react_jsx_runtime.jsx("p", { style: PS.hint, children: t("cfg.usage.loading") });
1586
+ }
1587
+ if (props.phase === "error") {
1588
+ return react_jsx_runtime.jsx("p", { style: PS.err, children: t("cfg.usage.loadFailed", { error: asText(props.error) || "?" }) });
1589
+ }
1590
+ return react_jsx_runtime.jsx("p", { style: PS.hint, children: t("cfg.usage.noData") });
1591
+ }
1592
+ if (provider.status === "error") {
1593
+ return react_jsx_runtime.jsx("p", { style: PS.err, children: t("cfg.usage.error", { error: asText(provider.error) || "?" }) });
1594
+ }
1595
+ const rows = [];
1596
+ for (const win of providerWindows(provider)) {
1597
+ const pct = typeof win.percent === "number" && Number.isFinite(win.percent) ? win.percent : null;
1598
+ const reset = win.resetsAt ? timeUntil(win.resetsAt, t) : "";
1599
+ rows.push(react_jsx_runtime.jsx("div", {
1600
+ style: PS.usageRow,
1601
+ children: [
1602
+ react_jsx_runtime.jsx("div", {
1603
+ style: PS.usageRowLabel,
1604
+ children: [
1605
+ react_jsx_runtime.jsx("span", { style: PS.usageRowName, title: windowLabel(t, win), children: windowLabel(t, win) }),
1606
+ react_jsx_runtime.jsx("span", {
1607
+ style: PS.usageRowValue,
1608
+ children: (pct === null ? "—" : formatPercent(pct) + "%") + (reset === "" ? "" : " · " + reset)
1609
+ })
1610
+ ]
1611
+ }),
1612
+ react_jsx_runtime.jsx("div", {
1613
+ style: PS.usageTrack,
1614
+ children: react_jsx_runtime.jsx("div", {
1615
+ style: { ...PS.usageFill, width: Math.max(0, Math.min(100, pct ?? 0)) + "%", background: fillColor(pct ?? 0) }
1616
+ })
1617
+ })
1618
+ ]
1619
+ }, win.key));
1620
+ }
1621
+ // The top-up balance row mirrors the widget's chip: value when one
1622
+ // exists, otherwise the reason it does not (setup needed, no pools,
1623
+ // or a failed read). A provider with no balance concept at all (GLM)
1624
+ // carries no flags, so the row is skipped entirely.
1625
+ const meta = provider.meta && typeof provider.meta === "object" ? provider.meta : {};
1626
+ const balance = provider.balance && typeof provider.balance.amount === "number" ? provider.balance : null;
1627
+ const balanceValue = formatMoney(balance);
1628
+ const balanceDetails = balance && balance.details && typeof balance.details === "object" && typeof balance.details.purchased === "number"
1629
+ ? t("balance.details.extra", {
1630
+ purchased: formatMoney({ amount: balance.details.purchased, currency: balance.currency }),
1631
+ free: formatMoney({ amount: typeof balance.details.free === "number" ? balance.details.free : 0, currency: balance.currency })
1632
+ })
1633
+ : "";
1634
+ const balanceMissing = meta.balanceMissing === true;
1635
+ const balanceSetup = meta.balanceSetup === "no-credential" || meta.balanceSetup === "api-key" || meta.balanceSetup === "expired";
1636
+ const balanceError = balanceValue === "" && !balanceMissing && !balanceSetup && typeof meta.balanceError === "string" && meta.balanceError !== ""
1637
+ ? meta.balanceError
1638
+ : "";
1639
+ if (balanceValue !== "" || balanceMissing || balanceSetup || balanceError !== "") {
1640
+ rows.push(react_jsx_runtime.jsx("div", {
1641
+ style: PS.usageRow,
1642
+ children: [
1643
+ react_jsx_runtime.jsx("div", {
1644
+ style: PS.usageRowLabel,
1645
+ children: [
1646
+ react_jsx_runtime.jsx("span", { style: PS.usageRowName, children: t("cfg.usage.balance") }),
1647
+ react_jsx_runtime.jsx("span", {
1648
+ style: { ...PS.usageRowValue, color: balanceValue === "" ? "var(--dsw-alias-label-tertiary, #8a8f98)" : PS.usageRowValue.color },
1649
+ children: balanceValue !== ""
1650
+ ? balanceValue
1651
+ : balanceMissing ? t("balance.missing") : t("cfg.usage.noBalance")
1652
+ })
1653
+ ]
1654
+ }),
1655
+ balanceValue !== "" && balanceDetails !== ""
1656
+ ? react_jsx_runtime.jsx("span", { style: PS.usageHint, children: balanceDetails })
1657
+ : null,
1658
+ balanceError !== ""
1659
+ ? react_jsx_runtime.jsx("span", { style: PS.usageHint, children: balanceError })
1660
+ : null
1661
+ ]
1662
+ }, "balance"));
1663
+ }
1664
+ if (rows.length === 0) {
1665
+ return react_jsx_runtime.jsx("p", { style: PS.hint, children: t("cfg.usage.noData") });
1666
+ }
1667
+ return react_jsx_runtime.jsx("div", {
1668
+ style: PS.usageBox,
1669
+ children: [
1670
+ react_jsx_runtime.jsx("div", {
1671
+ style: PS.usageHead,
1672
+ children: react_jsx_runtime.jsx("span", { style: PS.usageLabel, children: t("cfg.usage") })
1673
+ }, "usage-head"),
1674
+ ...rows
1317
1675
  ]
1318
1676
  });
1319
1677
  }
@@ -1333,6 +1691,11 @@ window.__ModuleLoader__.load({
1333
1691
  const [busy, setBusy] = react.useState({ save: false, reload: false });
1334
1692
  const [message, setMessage] = react.useState(null);
1335
1693
  const [draggingId, setDraggingId] = react.useState(null);
1694
+ // Live usage snapshot for the per-card 实时用量 sections. `phase`
1695
+ // gates the placeholders; on a failed refresh the previous data
1696
+ // stays visible and only the error line appears. `at` stamps when
1697
+ // the numbers were read, shown as 「更新于 HH:MM」.
1698
+ const [usage, setUsage] = react.useState({ phase: "idle", data: null, error: null, at: 0 });
1336
1699
  const tRef = react.useRef(t);
1337
1700
  tRef.current = t;
1338
1701
  // Id of the provider card currently being dragged (drag reordering).
@@ -1369,9 +1732,50 @@ window.__ModuleLoader__.load({
1369
1732
  }
1370
1733
  }, []);
1371
1734
 
1735
+ /**
1736
+ * Fetch the canonical usage snapshot; `force` appends `?refresh=1`
1737
+ * so the host re-reads upstream and the numbers reflect a just-
1738
+ * saved credential change immediately.
1739
+ */
1740
+ const loadUsage = react.useCallback(async (force) => {
1741
+ setUsage((prev) => ({ ...prev, phase: "loading" }));
1742
+ try {
1743
+ const res = await callApi("GET", USAGE_PATH + (force ? "?refresh=1" : ""));
1744
+ const payload = res.payload;
1745
+ if (res.status === 200 && payload !== null && Array.isArray(payload.providers)) {
1746
+ setUsage({ phase: "ready", data: payload, error: null, at: Date.now() });
1747
+ } else {
1748
+ const detail = (payload && payload.error) || "HTTP " + res.status;
1749
+ setUsage((prev) => ({ phase: "error", data: prev.data, error: detail }));
1750
+ }
1751
+ } catch (error) {
1752
+ setUsage((prev) => ({ phase: "error", data: prev.data, error: (error && error.message) || String(error) }));
1753
+ }
1754
+ }, []);
1755
+
1372
1756
  react.useEffect(() => {
1373
1757
  reload(false);
1374
- }, [reload]);
1758
+ void loadUsage(false);
1759
+ }, [reload, loadUsage]);
1760
+
1761
+ // While the settings page is open it tracks the widget: poll every
1762
+ // 30 s (cheap — the host cache collapses reads) and catch up the
1763
+ // moment the tab becomes visible again.
1764
+ react.useEffect(() => {
1765
+ if (typeof window === "undefined" || typeof window.setInterval !== "function") return undefined;
1766
+ const hidden = () => typeof document !== "undefined" && document.hidden === true;
1767
+ const timer = window.setInterval(() => { if (!hidden()) void loadUsage(false); }, 30_000);
1768
+ const onVisible = () => { if (typeof document !== "undefined" && !document.hidden) void loadUsage(false); };
1769
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
1770
+ document.addEventListener("visibilitychange", onVisible);
1771
+ }
1772
+ return () => {
1773
+ window.clearInterval(timer);
1774
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
1775
+ document.removeEventListener("visibilitychange", onVisible);
1776
+ }
1777
+ };
1778
+ }, [loadUsage]);
1375
1779
 
1376
1780
  const update = (key, value) => {
1377
1781
  setForm((prev) => ({ ...prev, [key]: value }));
@@ -1398,6 +1802,10 @@ window.__ModuleLoader__.load({
1398
1802
  if (res.status === 200 && payload !== null) {
1399
1803
  await reload(false);
1400
1804
  usageRefreshStore.publish();
1805
+ // A saved credential change (apiKeyEnv) must show its own
1806
+ // numbers right away: force one upstream re-read instead
1807
+ // of waiting for the next TTL.
1808
+ void loadUsage(true);
1401
1809
  setMessage({ kind: "ok", text: tt("cfg.saved") });
1402
1810
  } else if (res.status === 409) {
1403
1811
  setMessage({ kind: "warn", text: tt("cfg.conflict") });
@@ -1416,6 +1824,7 @@ window.__ModuleLoader__.load({
1416
1824
  setBusy((b) => ({ ...b, reload: true }));
1417
1825
  try {
1418
1826
  await reload(true);
1827
+ void loadUsage(false);
1419
1828
  } finally {
1420
1829
  setBusy((b) => ({ ...b, reload: false }));
1421
1830
  }
@@ -1448,6 +1857,28 @@ window.__ModuleLoader__.load({
1448
1857
  const server = formFromValue(state.value || {});
1449
1858
  const dirty = Object.keys(form).some((key) => !fieldEquals(form[key], server[key]));
1450
1859
 
1860
+ // The balance guide's manual path: the configured console credential
1861
+ // ref (default OPENCODE_CONSOLE_TOKEN) and the console website
1862
+ // derived from the configured console origin.
1863
+ const consoleValue = state.value && typeof state.value === "object" ? state.value : {};
1864
+ const consoleRef = typeof consoleValue.opencodeConsoleApiKeyEnv === "string" && consoleValue.opencodeConsoleApiKeyEnv !== ""
1865
+ ? consoleValue.opencodeConsoleApiKeyEnv
1866
+ : "OPENCODE_CONSOLE_TOKEN";
1867
+ const consoleLink = String(consoleValue.opencodeConsoleBaseUrl || "https://opencode.ai").replace(/\/+$/, "") + "/console";
1868
+
1869
+ /**
1870
+ * Re-check the console balance: re-read the detection report
1871
+ * (configured / reason) and force one upstream usage read, so a
1872
+ * sign-in the user just completed shows up without restarting DSH.
1873
+ * The settings reload is skipped while the form is dirty so
1874
+ * unsaved edits are not discarded — the forced usage read alone
1875
+ * still refreshes the balance row.
1876
+ */
1877
+ const recheckBalance = async () => {
1878
+ if (!dirty) await reload(false);
1879
+ await loadUsage(true);
1880
+ };
1881
+
1451
1882
  // Cards render in the saved order. Reordering works three ways, so a
1452
1883
  // shell without HTML5 drag (touch, Firefox proton, embedded webviews)
1453
1884
  // always has a path: drag a card header onto another card, drop it
@@ -1588,15 +2019,23 @@ window.__ModuleLoader__.load({
1588
2019
  // Reading the top-up balance is always on (the host default);
1589
2020
  // the page only reports whether a credential was found.
1590
2021
  if (consoleInfo !== null) {
2022
+ const consoleFixable = consoleInfo.reason === "api-key" || consoleInfo.reason === "expired";
1591
2023
  children.push(react_jsx_runtime.jsx(StatusLine, {
1592
2024
  error: typeof consoleInfo.error === "string" && consoleInfo.error !== "",
1593
2025
  children: t("cfg.console.status") + ": " + (consoleInfo.configured === true ? t("cfg.status.configured") : t("cfg.status.notConfigured")) + (consoleInfo.source ? " · " + consoleInfo.source : "")
1594
2026
  }, "console-status"));
1595
- if (consoleInfo.configured !== true) {
1596
- children.push(react_jsx_runtime.jsx("p", {
1597
- style: PS.hint,
1598
- children: t("cfg.console.hint")
1599
- }, "console-hint"));
2027
+ if (consoleInfo.configured !== true || consoleFixable) {
2028
+ children.push(react_jsx_runtime.jsx(ConsoleGuide, {
2029
+ t: t,
2030
+ // A specific reason (API-key login, expired
2031
+ // session) names the fix in the guide's lead-in
2032
+ // instead of the generic hint.
2033
+ reason: consoleFixable ? consoleInfo.reason : "",
2034
+ credentialRef: consoleRef,
2035
+ consoleUrl: consoleLink,
2036
+ disabled: disabled,
2037
+ onRecheck: recheckBalance
2038
+ }, "console-guide"));
1600
2039
  }
1601
2040
  }
1602
2041
  }
@@ -1606,6 +2045,19 @@ window.__ModuleLoader__.load({
1606
2045
  children: statusLines.join(" · ")
1607
2046
  }));
1608
2047
 
2048
+ // Live usage for this card, matched from the canonical snapshot
2049
+ // by provider id (the snapshot only carries enabled providers).
2050
+ const usageInfo = usage.data && Array.isArray(usage.data.providers)
2051
+ ? usage.data.providers.find((candidate) => candidate && candidate.id === definition.id) ?? null
2052
+ : null;
2053
+ children.push(react_jsx_runtime.jsx(UsageSection, {
2054
+ t: t,
2055
+ enabled: enabled,
2056
+ provider: usageInfo,
2057
+ phase: usage.phase,
2058
+ error: usage.error
2059
+ }, "usage"));
2060
+
1609
2061
  if (typeof definition.inviteUrl === "string" && definition.inviteUrl !== "") {
1610
2062
  children.push(react_jsx_runtime.jsx(InviteLink, { t: t, url: definition.inviteUrl }, "invite"));
1611
2063
  }
@@ -1633,7 +2085,27 @@ window.__ModuleLoader__.load({
1633
2085
  children: [
1634
2086
  react_jsx_runtime.jsx("div", { style: PS.title, children: t("cfg.title") }),
1635
2087
  react_jsx_runtime.jsx("p", { style: PS.hint, children: t("cfg.lead") }),
1636
- react_jsx_runtime.jsx("div", { style: PS.title, children: t("cfg.providers") }),
2088
+ react_jsx_runtime.jsx("div", {
2089
+ style: PS.row,
2090
+ children: [
2091
+ react_jsx_runtime.jsx("div", { style: PS.title, children: t("cfg.providers") }),
2092
+ typeof usage.at === "number" && usage.at > 0
2093
+ ? react_jsx_runtime.jsx("span", {
2094
+ style: { ...PS.hint, marginLeft: "auto" },
2095
+ children: t("cfg.usage.updatedAt", { time: formatClock(usage.at) })
2096
+ }, "usage-updated")
2097
+ : react_jsx_runtime.jsx("span", { style: { ...PS.hint, marginLeft: "auto" } }, "usage-updated"),
2098
+ react_jsx_runtime.jsx("button", {
2099
+ type: "button",
2100
+ style: usage.phase === "loading"
2101
+ ? { ...PS.button, padding: "3px 10px", ...PS.buttonDisabled }
2102
+ : { ...PS.button, padding: "3px 10px" },
2103
+ disabled: usage.phase === "loading",
2104
+ onClick: () => { void loadUsage(true); },
2105
+ children: usage.phase === "loading" ? t("cfg.usage.refreshing") : t("cfg.usage.refresh")
2106
+ }, "usage-refresh")
2107
+ ]
2108
+ }),
1637
2109
  react_jsx_runtime.jsx("div", {
1638
2110
  style: { display: "flex", flexDirection: "column", gap: 8 },
1639
2111
  children: providerCards
@@ -1673,7 +2145,7 @@ window.__ModuleLoader__.load({
1673
2145
  }
1674
2146
 
1675
2147
  /** One-line description the panel may render as the bundle's summary. */
1676
- const SUMMARY = "OpenCode GO / Command Code / GLM Code Plan 套餐用量:一个侧边栏组件、同源代理与 /provider-usage 命令。";
2148
+ const SUMMARY = "OpenCode GO / Command Code / GLM Code Plan 套餐用量:一个侧边栏组件、同源代理与 /provider-usage 命令。";
1677
2149
 
1678
2150
  /**
1679
2151
  * The `plugins.bundle.config` occupant. The `view` branch is a component
@@ -1743,7 +2215,10 @@ window.__ModuleLoader__.load({
1743
2215
  exports.Widget = Widget;
1744
2216
  exports.BundleConfig = BundleConfig;
1745
2217
  exports.SettingsPage = SettingsPage;
2218
+ exports.UsageSection = UsageSection;
1746
2219
  exports.InviteLink = InviteLink;
2220
+ exports.CopyButton = CopyButton;
2221
+ exports.ConsoleGuide = ConsoleGuide;
1747
2222
  exports.GLM_INVITE_URL = GLM_INVITE_URL;
1748
2223
  exports.balanceDeltasBetween = balanceDeltasBetween;
1749
2224
  exports.formatSignedMoney = formatSignedMoney;