dsh-multi-provider-usage 0.0.0-stage → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1753 @@
1
+ // dsh-multi-provider-usage — browser half.
2
+ //
3
+ // Hand-authored client bundle in the DSH module-loader factory format:
4
+ // `window.__ModuleLoader__.load({ id, factory })`. The factory receives the
5
+ // module-table `require`, so only shell-externalized modules may be imported
6
+ // (react / react/jsx-runtime are in the static table). It registers a widget
7
+ // into the sidebar's `sidebar.footer.action` list slot and polls the
8
+ // same-origin `/provider-usage/usage` proxy owned by the host half — API keys
9
+ // never enter the browser. UI strings are bilingual via the DSH locale
10
+ // service (dictionaries under the `dsh-multi-provider-usage` namespace).
11
+ //
12
+ // Every declaration that must not collide with another plugin's classic-script
13
+ // globals is kept INSIDE the factory: the shell evaluates all client bundles
14
+ // in one shared global lexical scope, so a top-level `const PACKAGE_NAME`
15
+ // would make the whole web boot fail with "Identifier has already been
16
+ // declared".
17
+ window.__ModuleLoader__.load({
18
+ id: "dsh-multi-provider-usage",
19
+ factory: (require) => {
20
+ const PACKAGE_NAME = "dsh-multi-provider-usage";
21
+ const CONFIG_KEY = PACKAGE_NAME;
22
+ // GLM Coding Plan invitation link (Z.ai "Invite Friends, Get Credits").
23
+ // It is public by nature and is not a credential, so the settings page
24
+ // renders it directly and it never enters the saved settings value.
25
+ const GLM_INVITE_URL = "https://zcode.z.ai/invite/SMBZKGEF";
26
+ var module = { exports: {} };
27
+ var exports = module.exports;
28
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
29
+
30
+ let react_jsx_runtime = require("react/jsx-runtime");
31
+ let react = require("react");
32
+
33
+ // ── styles (injected once, same pattern as built-in client plugins) ──
34
+ const CSS_ID = "dsh-multi-provider-usage/widget.css";
35
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + CSS_ID + "\"]") === null) {
36
+ const tag = document.createElement("style");
37
+ tag.dataset.plugin = "dsh-multi-provider-usage";
38
+ tag.dataset.pluginCss = CSS_ID;
39
+ tag.textContent = [
40
+ ".dpu-widget{box-sizing:border-box;width:100%;min-width:0;padding:6px 6px 4px;display:flex;flex-direction:column;gap:5px;border-top:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}",
41
+ ".dpu-widget:hover{background:var(--dsw-alias-interactive-bg-hover)}",
42
+ ".dpu-head{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;font-weight:600;line-height:18px}",
43
+ ".dpu-brand{white-space:nowrap;flex:none}",
44
+ ".dpu-headright{display:flex;align-items:center;gap:6px;min-width:0}",
45
+ ".dpu-refresh{cursor:pointer;border:none;background:none;padding:0;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:18px}",
46
+ ".dpu-refresh:hover{color:var(--dsw-alias-label-primary)}",
47
+ ".dpu-settings{cursor:pointer;border:none;background:none;padding:0;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:18px}",
48
+ ".dpu-settings:hover{color:var(--dsw-alias-label-primary)}",
49
+ ".dpu-providers{display:flex;flex-direction:column;gap:9px;min-width:0}",
50
+ ".dpu-provider{position:relative;box-sizing:border-box;min-width:0;padding:6px 0 2px;border-top:1px solid var(--dsw-alias-border-l2)}",
51
+ ".dpu-provider:first-child{border-top:none;padding-top:0}",
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
+ ".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
+ ".dpu-provider-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600}",
56
+ ".dpu-provider-pct{flex:none;font-size:10px;font-weight:700;line-height:16px;color:var(--dsw-alias-label-primary)}",
57
+ ".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
+ ".dpu-provider-balance.dpu-muted{color:var(--dsw-alias-label-tertiary)}",
59
+ ".dpu-provider-balance.dpu-hit{animation:dpu-balance-pop 1.4s ease}",
60
+ ".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
+ ".dpu-balance-setup:hover{color:var(--dsw-alias-label-primary);border-color:currentColor}",
62
+ ".dpu-balance-setup:focus-visible{outline:1px solid var(--dsw-alias-label-primary);outline-offset:1px}",
63
+ ".dpu-balance-float{position:absolute;right:4px;top:-3px;z-index:4;pointer-events:none;padding:0 3px;border-radius:6px;font-size:9px;line-height:14px;font-weight:700;white-space:nowrap;animation:dpu-balance-fly 1.6s ease-out forwards}",
64
+ ".dpu-balance-float.dpu-down-text{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 18%, transparent)}",
65
+ ".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
+ "@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
+ "@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-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.dpu-ok{background:var(--dsw-alias-state-success-primary)}",
72
+ ".dpu-dot.dpu-error{background:var(--dsw-alias-state-error-primary)}",
73
+ ".dpu-dot.dpu-warn{background:var(--dsw-alias-state-warn-primary)}",
74
+ ".dpu-sub{display:flex;align-items:center;gap:5px;min-width:0;font-size:10px;line-height:15px;color:var(--dsw-alias-label-secondary)}",
75
+ ".dpu-account{flex:none;max-width:56px;overflow:hidden;text-overflow:ellipsis;padding:0 2px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover);font-size:8px;line-height:14px;font-weight:600;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
76
+ ".dpu-plan{flex:none;max-width:64px;overflow:hidden;text-overflow:ellipsis;padding:0 2px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover);font-size:8px;line-height:14px;font-weight:600;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
77
+ ".dpu-badge{flex:none;padding:0 4px;border-radius:6px;font-size:8px;line-height:14px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);white-space:nowrap;cursor:default}",
78
+ ".dpu-row{display:flex;flex-direction:column;gap:3px;min-width:0}",
79
+ ".dpu-row-label{display:flex;justify-content:space-between;gap:8px;font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary)}",
80
+ ".dpu-row-label b{font-weight:500;color:var(--dsw-alias-label-primary)}",
81
+ ".dpu-meta{display:flex;align-items:center;gap:6px;min-width:0}",
82
+ ".dpu-meta .dpu-bars{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}",
83
+ ".dpu-meta>span{box-sizing:border-box;flex:0 0 64px;font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}",
84
+ ".dpu-track{box-sizing:border-box;flex:none;height:5px;border-radius:3px;background:var(--dsw-alias-border-l2);overflow:hidden}",
85
+ ".dpu-fill{height:100%;border-radius:3px;transition:width .4s ease}",
86
+ ".dpu-timetrack{box-sizing:border-box;flex:none;display:flex;width:100%;height:5px;border-radius:3px;overflow:hidden;background:var(--dsw-alias-border-l2)}",
87
+ ".dpu-timeelapsed{height:100%;flex:none;background:var(--dsw-alias-border-l2);border-radius:3px;transition:width .4s ease}",
88
+ ".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
+ ".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}",
90
+ ".dpu-ok{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
91
+ ".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
+ ".dpu-rail:hover{background:var(--dsw-alias-interactive-bg-hover)}",
93
+ ".dpu-update{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;line-height:16px;font-weight:600;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);text-decoration:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px}",
94
+ ".dpu-autoupdate{font-size:10px;line-height:15px;color:var(--dsw-alias-state-success-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
95
+ ].join("");
96
+ document.head.appendChild(tag);
97
+ }
98
+
99
+ // ── locale dictionaries ──
100
+ const NS = "dsh-multi-provider-usage";
101
+ const zh = {
102
+ "window.rolling": "5 小时窗口",
103
+ "window.five-hour": "5 小时窗口",
104
+ "window.weekly": "周窗口",
105
+ "window.monthly": "月窗口",
106
+ "window.mcp": "MCP 工具 (月)",
107
+ "window.token": "Token 窗口",
108
+ "window.other": "其他窗口",
109
+ "window.five-hour.hint": "5h",
110
+ "window.mcp.hint": "月",
111
+ "time.soon": "即将重置",
112
+ "time.minutes": "{n}分",
113
+ "time.hours": "{n}小时{m}分",
114
+ "time.days": "{n}天{m}小时",
115
+ "time.elapsed.title": "本窗口时间进度:已流逝 {pct}%,剩余 {rest}%",
116
+ "refresh": "刷新",
117
+ "settings": "设置",
118
+ "settings.title": "打开插件设置",
119
+ "loading": "加载中…",
120
+ "error": "错误",
121
+ "noProviders": "未启用任何服务商。请在插件设置中启用。",
122
+ "provider.opencode-go": "OpenCode GO",
123
+ "provider.command-code": "Command Code",
124
+ "provider.glm": "GLM Code Plan",
125
+ "status.ok": "正常",
126
+ "status.exceeded": "已超限",
127
+ "status.rate-limited": "限流中",
128
+ "status.error": "失败",
129
+ "status.disabled": "已禁用",
130
+ "status.unknown": "未知",
131
+ "rail.plain": "用量",
132
+ "rail.all": "服务商用量:{summary}",
133
+ "rail.error": "{provider}: 读取失败",
134
+ "rail.title": "{provider} · {pct}%",
135
+ "rail.title.account": "{provider} · {account} · {pct}%",
136
+ "widget.brand": "服务商用量",
137
+ "widget.account.title": "当前账号:{name}",
138
+ "widget.fallback": "内置",
139
+ "widget.fallback.title": "实际来源:{source};请求来源:{requested};回退原因:{reason}{message}",
140
+ "widget.partial.title": "部分接口失败:{error}",
141
+ "balance.extra": "充值余额",
142
+ "balance.zen": "充值余额",
143
+ "balance.short": "余额",
144
+ "balance.none": "余额 —",
145
+ "balance.title": "订阅外充值余额 {amount}(来源:{source})",
146
+ "balance.delta.title": "本轮余额变化:{from} → {to}",
147
+ "balance.none.title": "充值余额不可用:{error}",
148
+ "balance.setup": "配置余额",
149
+ "balance.setup.title": "未配置 OpenCode 控制台令牌,无法读取充值余额。点击打开插件设置,或运行 opencode auth login。",
150
+ "balance.missing": "无充值额度",
151
+ "balance.missing.title": "Command Code 的 credits 接口未返回购买或免费额度,因此没有充值余额可显示。",
152
+ "balance.details.extra": "购买 {purchased} + 免费 {free}",
153
+ "autoUpdate.updated": "插件已更新到 v{v},重启 DSH 生效",
154
+ "autoUpdate.available": "插件有新版本 v{v},自动更新未成功,请手动更新",
155
+ "widget.noData": "暂无数据",
156
+ "source.opencode-go": "dsh-opencode-go 当前账号",
157
+ "source.builtin": "DSH 内置提供商",
158
+ "reason.not-installed": "未安装 dsh-opencode-go",
159
+ "reason.entry-disabled": "dsh-opencode-go 已禁用",
160
+ "reason.service-unavailable": "宿主服务不可用",
161
+ "reason.read-failed": "读取额度失败",
162
+ "reason.invalid-response": "额度数据不合法",
163
+ "reason.unknown": "未知原因",
164
+ "update.available": "新版本 v{v}",
165
+ "update.title": "点击查看升级说明",
166
+
167
+ // settings page (Plugins panel)
168
+ "cfg.summary": "在一个侧边栏组件中查看 OpenCode GO、Command Code 与 GLM Code Plan 的套餐用量。",
169
+ "cfg.title": "服务商用量设置",
170
+ "cfg.lead": "选择要显示的服务商与凭据引用。保存后立即生效,无需重启。API Key 保存在 DSH 凭据域中,不会出现在这里。",
171
+ "cfg.providers": "服务商",
172
+ "cfg.console.status": "充值余额",
173
+ "cfg.console.hint": "运行 opencode auth login 登录 OpenCode 控制台,或把控制台令牌保存为 OPENCODE_CONSOLE_TOKEN 凭据,即可显示充值余额。",
174
+ "cfg.enabled": "启用",
175
+ "cfg.order.hint": "拖动此处调整显示顺序",
176
+ "cfg.move.up": "上移",
177
+ "cfg.move.down": "下移",
178
+ "cfg.credential": "凭据引用 (apiKeyEnv)",
179
+ "cfg.invite": "推广链接",
180
+ "cfg.invite.copy": "复制",
181
+ "cfg.invite.copied": "已复制",
182
+ "cfg.invite.copyFailed": "复制失败,请手动选中链接复制。",
183
+ "cfg.status.configured": "已配置",
184
+ "cfg.status.notConfigured": "未配置",
185
+ "cfg.status.enabled": "已启用",
186
+ "cfg.status.disabled": "已禁用",
187
+ "cfg.status.account": "账号",
188
+ "cfg.loading": "正在加载设置…",
189
+ "cfg.save": "保存",
190
+ "cfg.saving": "保存中…",
191
+ "cfg.reload": "重新加载",
192
+ "cfg.reloading": "加载中…",
193
+ "cfg.reloaded": "已重新加载。",
194
+ "cfg.retry": "重试",
195
+ "cfg.saved": "已保存,设置已更新。",
196
+ "cfg.noChanges": "没有需要保存的更改。",
197
+ "cfg.unsaved": "未保存",
198
+ "cfg.conflict": "保存冲突:设置已被其他会话修改,请点击「重新加载」后再保存。",
199
+ "cfg.saveFailed": "保存失败:{error}",
200
+ "cfg.loadFailed": "加载设置失败:{error}",
201
+ "cfg.unsupported": "宿主未提供设置描述符 (503)。请确认插件以 profile 条目安装,且 schemastery 版本 ≥ 3.18.4。",
202
+ "cfg.revision": "revision {n}"
203
+ };
204
+ const en = {
205
+ "window.rolling": "5-hour window",
206
+ "window.five-hour": "5-hour window",
207
+ "window.weekly": "Weekly",
208
+ "window.monthly": "Monthly",
209
+ "window.mcp": "MCP tools (monthly)",
210
+ "window.token": "Token window",
211
+ "window.other": "Other window",
212
+ "window.five-hour.hint": "5h",
213
+ "window.mcp.hint": "mo",
214
+ "time.soon": "resets soon",
215
+ "time.minutes": "{n}m",
216
+ "time.hours": "{n}h{m}m",
217
+ "time.days": "{n}d{m}h",
218
+ "time.elapsed.title": "Window time: {pct}% elapsed, {rest}% left",
219
+ "refresh": "Refresh",
220
+ "settings": "Settings",
221
+ "settings.title": "Open plugin settings",
222
+ "loading": "Loading…",
223
+ "error": "Error",
224
+ "noProviders": "No providers are enabled. Enable one in the plugin settings.",
225
+ "provider.opencode-go": "OpenCode GO",
226
+ "provider.command-code": "Command Code",
227
+ "provider.glm": "GLM Code Plan",
228
+ "status.ok": "ok",
229
+ "status.exceeded": "exceeded",
230
+ "status.rate-limited": "rate-limited",
231
+ "status.error": "failed",
232
+ "status.disabled": "disabled",
233
+ "status.unknown": "unknown",
234
+ "rail.plain": "Usage",
235
+ "rail.all": "Provider usage: {summary}",
236
+ "rail.error": "{provider}: read failed",
237
+ "rail.title": "{provider} · {pct}%",
238
+ "rail.title.account": "{provider} · {account} · {pct}%",
239
+ "widget.brand": "Provider usage",
240
+ "widget.account.title": "Active account: {name}",
241
+ "widget.fallback": "Built-in",
242
+ "widget.fallback.title": "Effective source: {source}; requested: {requested}; fallback reason: {reason}{message}",
243
+ "widget.partial.title": "Some endpoints failed: {error}",
244
+ "balance.extra": "Top-up",
245
+ "balance.zen": "Top-up",
246
+ "balance.short": "Bal.",
247
+ "balance.none": "Balance —",
248
+ "balance.title": "Top-up balance {amount} (source: {source})",
249
+ "balance.delta.title": "Balance change this turn: {from} → {to}",
250
+ "balance.none.title": "Top-up balance unavailable: {error}",
251
+ "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.",
253
+ "balance.missing": "No top-up",
254
+ "balance.missing.title": "Command Code's credits response carried no purchased or free credits, so there is no top-up balance to show.",
255
+ "balance.details.extra": "purchased {purchased} + free {free}",
256
+ "autoUpdate.updated": "Plugin updated to v{v}; restart DSH to apply",
257
+ "autoUpdate.available": "Plugin v{v} is available; automatic update failed — update it manually",
258
+ "widget.noData": "No data",
259
+ "source.opencode-go": "dsh-opencode-go current account",
260
+ "source.builtin": "DSH built-in provider",
261
+ "reason.not-installed": "dsh-opencode-go not installed",
262
+ "reason.entry-disabled": "dsh-opencode-go disabled",
263
+ "reason.service-unavailable": "host service unavailable",
264
+ "reason.read-failed": "quota read failed",
265
+ "reason.invalid-response": "invalid quota response",
266
+ "reason.unknown": "unknown reason",
267
+ "update.available": "v{v} available",
268
+ "update.title": "Click for upgrade instructions",
269
+
270
+ // settings page (Plugins panel)
271
+ "cfg.summary": "OpenCode GO, Command Code and GLM Code Plan plan usage in one sidebar widget.",
272
+ "cfg.title": "Provider usage settings",
273
+ "cfg.lead": "Choose which providers to show and their credential refs. Changes apply immediately; no restart needed. API keys stay in the DSH credentials domain and are never shown here.",
274
+ "cfg.providers": "Providers",
275
+ "cfg.console.status": "Top-up balance",
276
+ "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.",
277
+ "cfg.enabled": "Enabled",
278
+ "cfg.order.hint": "Drag here to reorder",
279
+ "cfg.move.up": "Move up",
280
+ "cfg.move.down": "Move down",
281
+ "cfg.credential": "Credential ref (apiKeyEnv)",
282
+ "cfg.invite": "Referral link",
283
+ "cfg.invite.copy": "Copy",
284
+ "cfg.invite.copied": "Copied",
285
+ "cfg.invite.copyFailed": "Copy failed; select the link and copy it manually.",
286
+ "cfg.status.configured": "configured",
287
+ "cfg.status.notConfigured": "not configured",
288
+ "cfg.status.enabled": "enabled",
289
+ "cfg.status.disabled": "disabled",
290
+ "cfg.status.account": "Account",
291
+ "cfg.loading": "Loading settings…",
292
+ "cfg.save": "Save",
293
+ "cfg.saving": "Saving…",
294
+ "cfg.reload": "Reload",
295
+ "cfg.reloading": "Loading…",
296
+ "cfg.reloaded": "Reloaded.",
297
+ "cfg.retry": "Retry",
298
+ "cfg.saved": "Saved — settings updated.",
299
+ "cfg.noChanges": "Nothing to save.",
300
+ "cfg.unsaved": "unsaved",
301
+ "cfg.conflict": "Save conflict: the settings were changed by another session. Click Reload, then save again.",
302
+ "cfg.saveFailed": "Save failed: {error}",
303
+ "cfg.loadFailed": "Failed to load settings: {error}",
304
+ "cfg.unsupported": "The host exposes no settings descriptor (503). Make sure the plugin is installed as a profile entry and schemastery is ≥ 3.18.4.",
305
+ "cfg.revision": "revision {n}"
306
+ };
307
+
308
+ // ── data helpers ──────────────────────────────────────────────────
309
+
310
+ /** Fraction of the window ALREADY elapsed, derived from its reset time. */
311
+ function timeElapsedFraction(resetsAt, windowMs) {
312
+ if (!resetsAt || !windowMs) return null;
313
+ const reset = new Date(resetsAt).getTime();
314
+ if (!Number.isFinite(reset)) return null;
315
+ const elapsed = (windowMs - (reset - Date.now())) / windowMs;
316
+ return Math.max(0, Math.min(1, elapsed));
317
+ }
318
+
319
+ /** Short human relative time until `iso`, localized. */
320
+ function timeUntil(iso, t) {
321
+ const target = new Date(iso).getTime();
322
+ if (!Number.isFinite(target)) return "";
323
+ const diff = target - Date.now();
324
+ if (diff <= 0) return t("time.soon");
325
+ const mins = Math.floor(diff / 60000);
326
+ if (mins < 60) return t("time.minutes", { n: mins });
327
+ const hours = Math.floor(mins / 60);
328
+ if (hours < 24) return t("time.hours", { n: hours, m: mins % 60 });
329
+ return t("time.days", { n: Math.floor(hours / 24), m: hours % 24 });
330
+ }
331
+
332
+ /** Fill color by usage percent — themed alias tokens (light/dark safe). */
333
+ function fillColor(percent) {
334
+ if (percent >= 90) return "var(--dsw-alias-state-error-primary)";
335
+ if (percent >= 70) return "var(--dsw-alias-state-warn-primary)";
336
+ return "var(--dsw-alias-state-success-primary)";
337
+ }
338
+
339
+ // ── defensive display helpers ─────────────────────────────────────
340
+ function asText(value) {
341
+ if (typeof value === "string") return value;
342
+ if (value === null || value === undefined) return "";
343
+ return String(value);
344
+ }
345
+
346
+ /** One-decimal display percentage; null/NaN renders as an em dash. */
347
+ function formatPercent(value) {
348
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
349
+ const rounded = Math.round(value * 10) / 10;
350
+ return String(rounded);
351
+ }
352
+
353
+ /** Format a provider balance for the badge. */
354
+ function formatMoney(balance) {
355
+ if (!balance || typeof balance.amount !== "number" || !Number.isFinite(balance.amount)) return "";
356
+ const amount = balance.amount.toFixed(2);
357
+ return balance.currency === "USD" || !balance.currency ? "$" + amount : amount + " " + balance.currency;
358
+ }
359
+
360
+ /** Compact signed delta without the currency symbol, e.g. -0.1. */
361
+ function formatDeltaShort(change) {
362
+ if (!change || typeof change.amount !== "number" || !Number.isFinite(change.amount)) return "";
363
+ const text = Math.abs(change.amount).toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
364
+ return (change.amount < 0 ? "-" : "+") + text;
365
+ }
366
+
367
+ /** Animate a balance from its previous value to the refreshed value. */
368
+ function AnimatedBalance({ value, currency, delta }) {
369
+ const from = delta && typeof delta.from === "number" ? delta.from : value;
370
+ const [shown, setShown] = react.useState(from);
371
+ react.useEffect(() => {
372
+ const target = typeof value === "number" ? value : from;
373
+ if (!delta || typeof delta.from !== "number" || from === target) {
374
+ setShown(target);
375
+ return;
376
+ }
377
+ if (typeof window.requestAnimationFrame !== "function") {
378
+ setShown(target);
379
+ return;
380
+ }
381
+ let raf = null;
382
+ const started = typeof window.performance?.now === "function" ? window.performance.now() : Date.now();
383
+ const duration = 650;
384
+ const step = (now) => {
385
+ const progress = Math.min(1, Math.max(0, (now - started) / duration));
386
+ const eased = 1 - Math.pow(1 - progress, 3);
387
+ setShown(from + (target - from) * eased);
388
+ if (progress < 1) raf = window.requestAnimationFrame(step);
389
+ else setShown(target);
390
+ };
391
+ raf = window.requestAnimationFrame(step);
392
+ return () => { if (raf !== null) window.cancelAnimationFrame(raf); };
393
+ }, [delta?.seq, value, currency, from]);
394
+ return formatMoney({ amount: shown, currency });
395
+ }
396
+
397
+ /** Snapshot the current balances keyed by provider id. */
398
+ function balancesFromProviders(providers) {
399
+ const out = {};
400
+ for (const provider of providers ?? []) {
401
+ const balance = provider?.balance;
402
+ if (!balance || typeof balance.amount !== "number" || !Number.isFinite(balance.amount)) continue;
403
+ out[provider.id] = {
404
+ amount: balance.amount,
405
+ currency: typeof balance.currency === "string" && balance.currency.length > 0 ? balance.currency : "USD"
406
+ };
407
+ }
408
+ return out;
409
+ }
410
+
411
+ /** Compare two balance snapshots and return only providers whose balance changed. */
412
+ function balanceDeltasBetween(previous, providers) {
413
+ const out = {};
414
+ for (const provider of providers ?? []) {
415
+ const balance = provider?.balance;
416
+ const before = previous?.[provider?.id];
417
+ if (!balance || typeof balance.amount !== "number" || !Number.isFinite(balance.amount)) continue;
418
+ if (!before || typeof before.amount !== "number" || !Number.isFinite(before.amount)) continue;
419
+ const currency = typeof balance.currency === "string" && balance.currency.length > 0 ? balance.currency : "USD";
420
+ const beforeCurrency = typeof before.currency === "string" && before.currency.length > 0 ? before.currency : "USD";
421
+ if (currency !== beforeCurrency) continue;
422
+ const raw = balance.amount - before.amount;
423
+ if (!Number.isFinite(raw)) continue;
424
+ // Money is displayed in cents; rounding here keeps 5.14 - 4.88 from
425
+ // surfacing as -0.2600000000000006.
426
+ const amount = Math.round(raw * 100) / 100;
427
+ if (Math.abs(amount) < 0.005) continue;
428
+ out[provider.id] = { amount, currency, from: before.amount, to: balance.amount };
429
+ }
430
+ return out;
431
+ }
432
+
433
+ /** Signed money for the per-turn balance delta. */
434
+ function formatSignedMoney(change) {
435
+ if (!change || typeof change.amount !== "number" || !Number.isFinite(change.amount)) return "";
436
+ const amount = Math.abs(change.amount) < 0.005 ? 0 : change.amount;
437
+ const absolute = Math.abs(amount).toFixed(2);
438
+ const sign = amount < 0 ? "-" : amount > 0 ? "+" : "";
439
+ return change.currency === "USD" || !change.currency ? sign + "$" + absolute : sign + absolute + " " + change.currency;
440
+ }
441
+
442
+ /** The window whose percentage is shown on the collapsed rail. */
443
+ function primaryWindow(provider) {
444
+ if (!provider || !Array.isArray(provider.windows)) return null;
445
+ return provider.windows.find((window) => window.key === "monthly")
446
+ ?? provider.windows.find((window) => window.key === "rolling" || window.key === "five-hour")
447
+ ?? provider.windows[0]
448
+ ?? null;
449
+ }
450
+
451
+ function primaryPercent(provider) {
452
+ const window = primaryWindow(provider);
453
+ return window && typeof window.percent === "number" ? window.percent : null;
454
+ }
455
+
456
+ function statusText(t, status) {
457
+ const key = "status." + (typeof status === "string" && status !== "" ? status : "unknown");
458
+ const label = t(key);
459
+ if (label !== key) return label;
460
+ return typeof status === "string" && status !== "" ? status : t("status.unknown");
461
+ }
462
+
463
+ function sourceLabel(t, source) {
464
+ if (source === "opencode-go") return t("source.opencode-go");
465
+ if (source === "builtin") return t("source.builtin");
466
+ return asText(source);
467
+ }
468
+
469
+ function reasonLabel(t, reason) {
470
+ const key = "reason." + (typeof reason === "string" && reason !== "" ? reason : "unknown");
471
+ const label = t(key);
472
+ return label === key ? t("reason.unknown") : label;
473
+ }
474
+
475
+ function fallbackTitle(t, fallback, source) {
476
+ const requested = fallback && (fallback.from === "builtin" || fallback.from === "opencode-go") ? fallback.from : source;
477
+ const raw = asText(fallback && fallback.message);
478
+ return t("widget.fallback.title", {
479
+ source: sourceLabel(t, source),
480
+ requested: sourceLabel(t, requested),
481
+ reason: reasonLabel(t, fallback && fallback.reason),
482
+ message: raw === "" ? "" : " · " + raw
483
+ });
484
+ }
485
+
486
+ /** Detect zh vs en for the tests and for hosts without a locale seat. */
487
+ function detectLang() {
488
+ try {
489
+ const lang = (typeof document !== "undefined" && document.documentElement && document.documentElement.lang)
490
+ || (typeof navigator !== "undefined" && navigator.language)
491
+ || "";
492
+ return String(lang).toLowerCase();
493
+ } catch (_error) {
494
+ return "";
495
+ }
496
+ }
497
+
498
+ function fallbackT(key, params) {
499
+ const table = detectLang().indexOf("zh") === 0 ? zh : en;
500
+ let raw = table[key] !== undefined ? table[key] : zh[key] !== undefined ? zh[key] : key;
501
+ if (params === null || params === undefined) return raw;
502
+ return String(raw).replace(/\{(\w+)\}/g, (match, name) => (params[name] === null || params[name] === undefined ? "" : String(params[name])));
503
+ }
504
+
505
+ function translateFor(props) {
506
+ return props && typeof props.t === "function" ? props.t : fallbackT;
507
+ }
508
+
509
+ /** Applicable windows for a provider, skipping malformed rows. */
510
+ function providerWindows(provider) {
511
+ return Array.isArray(provider?.windows)
512
+ ? provider.windows.filter((window) => window && typeof window.percent === "number")
513
+ : [];
514
+ }
515
+
516
+ function windowLabel(t, window) {
517
+ const key = typeof window.labelKey === "string" && window.labelKey.length > 0 ? window.labelKey : "window." + window.key;
518
+ const label = t(key);
519
+ const base = label === key ? asText(window.key) : label;
520
+ return typeof window.note === "string" && window.note.length > 0 ? base + " · " + window.note : base;
521
+ }
522
+
523
+ function providerStatusClass(provider) {
524
+ if (provider?.status === "error") return "dpu-dot dpu-error";
525
+ if (provider?.status === "ok") {
526
+ const partial = provider?.meta && Array.isArray(provider.meta.errors) ? provider.meta.errors.length : 0;
527
+ return partial > 0 ? "dpu-dot dpu-warn" : "dpu-dot dpu-ok";
528
+ }
529
+ return "dpu-dot dpu-warn";
530
+ }
531
+
532
+ function providerPctText(provider) {
533
+ const pct = primaryPercent(provider);
534
+ return pct === null ? "—" : formatPercent(pct) + "%";
535
+ }
536
+
537
+ function providerTitle(t, provider) {
538
+ const parts = [provider.name || t("provider." + provider.id), providerPctText(provider)];
539
+ const account = provider.account && typeof provider.account.name === "string" ? provider.account.name.trim() : "";
540
+ if (account !== "") parts.push(account);
541
+ const plan = provider.plan && typeof provider.plan.name === "string" ? provider.plan.name.trim() : "";
542
+ if (plan !== "") parts.push(plan);
543
+ const money = formatMoney(provider.balance);
544
+ if (money !== "") parts.push(money);
545
+ const partial = provider?.meta && Array.isArray(provider.meta.errors)
546
+ ? provider.meta.errors.filter((message) => typeof message === "string" && message !== "")
547
+ : [];
548
+ if (provider.status !== "error" && partial.length > 0) parts.push(t("widget.partial.title", { error: partial.join("; ") }));
549
+ if (provider.status === "error" && typeof provider.error === "string") parts.push(provider.error);
550
+ return parts.join(" · ");
551
+ }
552
+
553
+ /** Short widget notice for an auto-update state (empty when nothing to say). */
554
+ function autoUpdateNotice(t, state) {
555
+ if (!state || typeof state !== "object") return "";
556
+ if (state.status === "updated" && state.restartRequired === true) {
557
+ return t("autoUpdate.updated", { v: state.version || state.latestVersion || "?" });
558
+ }
559
+ if (state.status === "available") {
560
+ return t("autoUpdate.available", { v: state.latestVersion || "?" });
561
+ }
562
+ return "";
563
+ }
564
+
565
+ /** Hover text for the auto-update notice: the reason a self-update failed. */
566
+ function autoUpdateTitle(state) {
567
+ if (!state || typeof state !== "object") return "";
568
+ return state.status === "available" && typeof state.message === "string" ? state.message : "";
569
+ }
570
+
571
+ /** Compact balance label for a provider header (empty when unavailable). */
572
+ function formatBalanceShort(_t, provider) {
573
+ const balance = provider?.balance;
574
+ if (!balance || typeof balance.amount !== "number" || !Number.isFinite(balance.amount)) return "";
575
+ // The header is tight; the amount alone is enough, the tooltip carries
576
+ // the "balance" label and source.
577
+ return formatMoney(balance);
578
+ }
579
+
580
+ function collapseStorageKey() {
581
+ return PACKAGE_NAME + ":collapsed";
582
+ }
583
+
584
+ function readCollapsedProviders() {
585
+ try {
586
+ const raw = window.localStorage.getItem(collapseStorageKey());
587
+ const parsed = raw === null ? [] : JSON.parse(raw);
588
+ return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : []);
589
+ } catch (_error) {
590
+ return new Set();
591
+ }
592
+ }
593
+
594
+ function storeCollapsedProviders(value) {
595
+ try {
596
+ window.localStorage.setItem(collapseStorageKey(), JSON.stringify([...value]));
597
+ } catch (_error) {
598
+ // private mode / storage disabled: collapse state is session-only
599
+ }
600
+ }
601
+
602
+ /** Apply the saved provider order to the displayed sections. */
603
+ function orderedProviders(data) {
604
+ const providers = data && Array.isArray(data.providers) ? data.providers : [];
605
+ const order = data && Array.isArray(data.providerOrder) ? data.providerOrder : [];
606
+ if (order.length === 0) return providers;
607
+ const rank = new Map();
608
+ order.forEach((id, index) => {
609
+ if (typeof id === "string" && !rank.has(id)) rank.set(id, index);
610
+ });
611
+ // Stable sort: providers missing from the order keep the host order.
612
+ return providers
613
+ .map((provider, index) => ({ provider, index }))
614
+ .sort((a, b) => ((rank.get(a.provider.id) ?? Infinity) - (rank.get(b.provider.id) ?? Infinity)) || (a.index - b.index))
615
+ .map((entry) => entry.provider);
616
+ }
617
+
618
+ function railSummary(t, providers) {
619
+ return providers.map((provider) => {
620
+ const name = provider.name || t("provider." + provider.id);
621
+ const pct = primaryPercent(provider);
622
+ const money = formatMoney(provider.balance);
623
+ return name + " " + (pct === null ? "—" : formatPercent(pct) + "%") + (money === "" ? "" : " · " + money);
624
+ }).join(" · ");
625
+ }
626
+
627
+ let pluginContext = null;
628
+
629
+ /** Open this bundle's page in the Plugins panel (its configuration page). */
630
+ function openPluginSettings() {
631
+ const ctx = pluginContext;
632
+ if (!ctx || typeof ctx.get !== "function") return;
633
+ try {
634
+ const navigation = ctx.get("pluginNavigation");
635
+ if (navigation && typeof navigation.openBundle === "function") {
636
+ navigation.openBundle(PACKAGE_NAME);
637
+ return;
638
+ }
639
+ const layout = ctx.get("layout");
640
+ if (layout && typeof layout.selectPanel === "function") layout.selectPanel("plugins");
641
+ } catch (_error) {
642
+ // a host without the Plugins panel simply has no settings shortcut
643
+ }
644
+ }
645
+
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);
649
+ }
650
+
651
+ /** Open the plugin settings from the balance chip (never toggles the card). */
652
+ function activateBalanceSetup(event) {
653
+ if (event) {
654
+ if (typeof event.preventDefault === "function") event.preventDefault();
655
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
656
+ }
657
+ openPluginSettings();
658
+ }
659
+
660
+ /** Enter/Space activation for the keyboard-reachable balance chip. */
661
+ function balanceSetupKeyDown(event) {
662
+ if (event && (event.key === "Enter" || event.key === " " || event.key === "Spacebar")) activateBalanceSetup(event);
663
+ }
664
+
665
+ /** Cross-component ping: a settings save asks the widget to reload now. */
666
+ const usageRefreshStore = {
667
+ listeners: new Set(),
668
+ publish() {
669
+ for (const listener of this.listeners) listener();
670
+ },
671
+ subscribe(listener) {
672
+ this.listeners.add(listener);
673
+ return () => this.listeners.delete(listener);
674
+ }
675
+ };
676
+
677
+ // ── widget component ──────────────────────────────────────────────
678
+ /**
679
+ * All enabled providers are stacked in one column. Each provider header
680
+ * collapses/expands its own body; collapse state is persisted locally.
681
+ * @param {{ wide?: boolean, t?: Function }} props - owner share from the
682
+ * sidebar (`sidebar.footer.action` is rendered with `{ wide }`) plus the
683
+ * locale translate seat declared by this registration.
684
+ */
685
+ function Widget(props) {
686
+ const t = props.t || ((key) => key);
687
+ const [data, setData] = react.useState(null);
688
+ const [error, setError] = react.useState(null);
689
+ const [stamp, setStamp] = react.useState(0);
690
+ const [collapsed, setCollapsed] = react.useState(readCollapsedProviders);
691
+ const [balanceDeltas, setBalanceDeltas] = react.useState({});
692
+ const previousBalancesRef = react.useRef(null);
693
+ const deltaTimersRef = react.useRef(new Map());
694
+
695
+ react.useEffect(() => {
696
+ let alive = true;
697
+ /** True when the tab is hidden (guarded for hosts without DOM). */
698
+ const isHidden = () => typeof document !== "undefined" && document.hidden === true;
699
+ const load = async (force) => {
700
+ // A hidden tab has nobody looking at the numbers: skip the poll
701
+ // and catch up the moment it becomes visible again.
702
+ if (isHidden() && !force) return;
703
+ try {
704
+ const url = "/provider-usage/usage" + (force ? "?refresh=1" : "");
705
+ const res = await fetch(url, { cache: "no-store" });
706
+ const json = await res.json().catch(() => null);
707
+ if (!alive) return;
708
+ if (json === null || (typeof json.error === "string" && (!Array.isArray(json.providers) || json.providers.length === 0))) {
709
+ setError(asText(json?.error) || "HTTP " + res.status);
710
+ setData(null);
711
+ } else {
712
+ const providers = Array.isArray(json.providers) ? json.providers : [];
713
+ const previous = previousBalancesRef.current;
714
+ previousBalancesRef.current = balancesFromProviders(providers);
715
+ if (previous !== null) {
716
+ const changes = balanceDeltasBetween(previous, providers);
717
+ const ids = Object.keys(changes);
718
+ if (ids.length > 0) {
719
+ const stamped = {};
720
+ for (const id of ids) stamped[id] = { ...changes[id], seq: Date.now() + Math.random() };
721
+ setBalanceDeltas((current) => ({ ...current, ...stamped }));
722
+ for (const id of ids) {
723
+ const existing = deltaTimersRef.current.get(id);
724
+ if (existing !== undefined) window.clearTimeout(existing);
725
+ const seq = stamped[id].seq;
726
+ const timer = window.setTimeout(() => {
727
+ setBalanceDeltas((current) => {
728
+ if (current[id]?.seq !== seq) return current;
729
+ const next = { ...current };
730
+ delete next[id];
731
+ return next;
732
+ });
733
+ deltaTimersRef.current.delete(id);
734
+ }, 6000);
735
+ deltaTimersRef.current.set(id, timer);
736
+ }
737
+ }
738
+ }
739
+ setData(json);
740
+ setError(null);
741
+ }
742
+ } catch (e) {
743
+ if (!alive) return;
744
+ setError(asText(e && e.message) || String(e));
745
+ setData(null);
746
+ }
747
+ };
748
+ // The manual refresh forces one immediate upstream read; the 30 s
749
+ // poll afterwards must use the host cache again.
750
+ load(stamp > 0);
751
+ const timer = window.setInterval(() => load(false), 30000);
752
+ const onVisibility = () => { if (!document.hidden) void load(false); };
753
+ document.addEventListener("visibilitychange", onVisibility);
754
+ return () => {
755
+ alive = false;
756
+ window.clearInterval(timer);
757
+ document.removeEventListener("visibilitychange", onVisibility);
758
+ };
759
+ }, [stamp]);
760
+
761
+ react.useEffect(() => () => {
762
+ for (const timer of deltaTimersRef.current.values()) window.clearTimeout(timer);
763
+ deltaTimersRef.current.clear();
764
+ }, []);
765
+
766
+ react.useEffect(() => usageRefreshStore.subscribe(() => setStamp((n) => n + 1)), []);
767
+
768
+ const providers = orderedProviders(data);
769
+
770
+ const toggleProvider = (id) => {
771
+ setCollapsed((prev) => {
772
+ const next = new Set(prev);
773
+ if (next.has(id)) next.delete(id);
774
+ else next.add(id);
775
+ storeCollapsedProviders(next);
776
+ return next;
777
+ });
778
+ };
779
+
780
+ // Collapsed rail: a single compact badge summarising all providers.
781
+ if (!props.wide) {
782
+ const maxPct = providers.reduce((max, provider) => {
783
+ const pct = primaryPercent(provider);
784
+ return pct !== null && (max === null || pct > max) ? pct : max;
785
+ }, null);
786
+ const title = error !== null
787
+ ? error
788
+ : providers.length === 0 ? t("rail.plain") : t("rail.all", { summary: railSummary(t, providers) });
789
+ return react_jsx_runtime.jsx("div", {
790
+ className: "dpu-rail",
791
+ title: title,
792
+ children: maxPct === null ? (providers.length === 0 ? "—" : t("rail.plain")) : formatPercent(maxPct) + "%"
793
+ });
794
+ }
795
+
796
+ const sections = providers.map((provider) => {
797
+ const isCollapsed = collapsed.has(provider.id);
798
+ const pctText = providerPctText(provider);
799
+ const balanceText = formatBalanceShort(t, provider);
800
+ const balance = provider.balance && typeof provider.balance.amount === "number" ? provider.balance : null;
801
+ const delta = balanceDeltas[provider.id] && typeof balanceDeltas[provider.id].amount === "number" ? balanceDeltas[provider.id] : null;
802
+ const showDelta = delta !== null && Math.abs(delta.amount) >= 0.005;
803
+ const balanceClass = "dpu-provider-balance" + (showDelta ? " dpu-hit" : "");
804
+ // Command Code's balance is purchased + free credits; name the
805
+ // composition in the tooltip so the single number stays honest.
806
+ const balanceDetails = balance && balance.details && typeof balance.details === "object" && typeof balance.details.purchased === "number"
807
+ ? t("balance.details.extra", {
808
+ purchased: formatMoney({ amount: balance.details.purchased, currency: balance.currency }),
809
+ free: formatMoney({ amount: typeof balance.details.free === "number" ? balance.details.free : 0, currency: balance.currency })
810
+ })
811
+ : "";
812
+ const balanceTitle = showDelta
813
+ ? t("balance.delta.title", {
814
+ from: formatMoney({ amount: delta.from, currency: delta.currency }),
815
+ to: formatMoney({ amount: delta.to, currency: delta.currency })
816
+ })
817
+ : t("balance.title", { amount: balanceText, source: balance ? asText(balance.source) : "" }) + (balanceDetails === "" ? "" : " · " + balanceDetails);
818
+ 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 : "";
823
+ // Command Code: credits answered without either credit pool, so
824
+ // there is no top-up balance to show (it is not a config problem).
825
+ const balanceMissing = provider.meta && provider.meta.balanceMissing === true;
826
+
827
+ const accountName = provider.account && typeof provider.account.name === "string" ? provider.account.name.trim() : "";
828
+ const planName = provider.plan && typeof provider.plan.name === "string" ? provider.plan.name.trim() : "";
829
+ const fallback = provider.fallback && typeof provider.fallback === "object" ? provider.fallback : null;
830
+
831
+ const accountBadge = accountName === "" ? null : react_jsx_runtime.jsx("span", {
832
+ className: "dpu-account",
833
+ title: t("widget.account.title", { name: accountName }),
834
+ children: accountName
835
+ }, "account");
836
+ const planBadge = planName === "" || planName.length > 12 ? null : react_jsx_runtime.jsx("span", {
837
+ className: "dpu-plan",
838
+ title: planName,
839
+ children: planName
840
+ }, "plan");
841
+ const fallbackBadge = fallback === null ? null : react_jsx_runtime.jsx("span", {
842
+ className: "dpu-badge",
843
+ title: fallbackTitle(t, fallback, provider.source),
844
+ children: t("widget.fallback")
845
+ }, "fallback");
846
+
847
+ const rows = [];
848
+ if (provider.status === "ok") {
849
+ for (const win of providerWindows(provider)) {
850
+ const elapsed = timeElapsedFraction(win.resetsAt, win.windowMs);
851
+ rows.push(react_jsx_runtime.jsx("div", {
852
+ className: "dpu-row",
853
+ children: [
854
+ react_jsx_runtime.jsx("div", {
855
+ className: "dpu-row-label",
856
+ children: [
857
+ react_jsx_runtime.jsx("b", { children: windowLabel(t, win) }),
858
+ react_jsx_runtime.jsx("b", { children: formatPercent(win.percent) + "%" })
859
+ ]
860
+ }),
861
+ react_jsx_runtime.jsx("div", {
862
+ className: "dpu-meta",
863
+ children: [
864
+ react_jsx_runtime.jsx("div", {
865
+ className: "dpu-bars",
866
+ children: [
867
+ react_jsx_runtime.jsx("div", {
868
+ className: "dpu-track",
869
+ children: react_jsx_runtime.jsx("div", {
870
+ className: "dpu-fill",
871
+ style: {
872
+ width: Math.max(0, Math.min(100, win.percent)) + "%",
873
+ background: fillColor(win.percent)
874
+ }
875
+ })
876
+ }),
877
+ elapsed === null ? null : react_jsx_runtime.jsx("div", {
878
+ className: "dpu-timetrack",
879
+ title: t("time.elapsed.title", { pct: Math.round(elapsed * 100), rest: Math.round((1 - elapsed) * 100) }),
880
+ children: [
881
+ react_jsx_runtime.jsx("div", {
882
+ className: "dpu-timeelapsed",
883
+ style: { width: (elapsed * 100) + "%" }
884
+ }),
885
+ react_jsx_runtime.jsx("div", {
886
+ className: "dpu-timeremain",
887
+ style: { width: ((1 - elapsed) * 100) + "%" }
888
+ })
889
+ ]
890
+ })
891
+ ]
892
+ }),
893
+ react_jsx_runtime.jsx("span", {
894
+ children: win.status === "ok"
895
+ ? (win.resetsAt ? timeUntil(win.resetsAt, t) : (win.hintKey ? t(win.hintKey) : ""))
896
+ : statusText(t, win.status)
897
+ })
898
+ ]
899
+ })
900
+ ]
901
+ }, win.key));
902
+ }
903
+ }
904
+
905
+ const body = [];
906
+ if (provider.status === "error") {
907
+ const message = asText(provider.error) || t("status.error");
908
+ body.push(react_jsx_runtime.jsx("div", {
909
+ className: "dpu-provider-error",
910
+ title: message,
911
+ children: message
912
+ }, "error"));
913
+ } else if (rows.length === 0) {
914
+ body.push(react_jsx_runtime.jsx("div", { className: "dpu-provider-error", children: t("widget.noData") }, "empty"));
915
+ } else {
916
+ body.push(react_jsx_runtime.jsx(react.Fragment, { children: rows }, "rows"));
917
+ }
918
+
919
+ const balanceBadge = balance !== null || showDelta
920
+ ? react_jsx_runtime.jsx("span", {
921
+ className: balanceClass,
922
+ title: balanceTitle,
923
+ children: showDelta && balance !== null
924
+ ? react_jsx_runtime.jsx(AnimatedBalance, {
925
+ value: balance.amount,
926
+ currency: balance.currency,
927
+ delta
928
+ })
929
+ : showDelta ? formatSignedMoney(delta) : balanceText
930
+ }, "balance")
931
+ : balanceError !== ""
932
+ ? react_jsx_runtime.jsx("span", {
933
+ className: "dpu-provider-balance dpu-muted",
934
+ title: t("balance.none.title", { error: balanceError }),
935
+ children: t("balance.none")
936
+ }, "balance-error")
937
+ : missingConsoleCredential
938
+ ? react_jsx_runtime.jsx("span", {
939
+ className: "dpu-balance-setup",
940
+ role: "button",
941
+ tabIndex: 0,
942
+ title: t("balance.setup.title"),
943
+ onClick: activateBalanceSetup,
944
+ onKeyDown: balanceSetupKeyDown,
945
+ children: t("balance.setup")
946
+ }, "balance-setup")
947
+ : balanceMissing
948
+ ? react_jsx_runtime.jsx("span", {
949
+ className: "dpu-provider-balance dpu-muted",
950
+ title: t("balance.missing.title"),
951
+ children: t("balance.missing")
952
+ }, "balance-missing")
953
+ : null;
954
+ const deltaChip = showDelta
955
+ ? react_jsx_runtime.jsx("span", {
956
+ className: "dpu-balance-float " + (delta.amount < 0 ? "dpu-down-text" : "dpu-up-text"),
957
+ title: balanceTitle,
958
+ children: formatDeltaShort(delta)
959
+ }, "delta")
960
+ : null;
961
+
962
+ return react_jsx_runtime.jsx("div", {
963
+ className: "dpu-provider",
964
+ children: [
965
+ react_jsx_runtime.jsx("button", {
966
+ type: "button",
967
+ className: "dpu-provider-head",
968
+ onClick: () => toggleProvider(provider.id),
969
+ title: providerTitle(t, provider),
970
+ "aria-expanded": !isCollapsed,
971
+ children: [
972
+ react_jsx_runtime.jsx("span", {
973
+ className: "dpu-chevron",
974
+ children: isCollapsed ? "▸" : "▾"
975
+ }),
976
+ react_jsx_runtime.jsx("span", { className: providerStatusClass(provider) }),
977
+ react_jsx_runtime.jsx("span", {
978
+ className: "dpu-provider-name",
979
+ children: provider.name || t("provider." + provider.id)
980
+ }),
981
+ accountBadge,
982
+ planBadge,
983
+ fallbackBadge,
984
+ react_jsx_runtime.jsx("span", {
985
+ className: "dpu-provider-pct",
986
+ children: pctText
987
+ }),
988
+ balanceBadge,
989
+ deltaChip
990
+ ]
991
+ }, "head"),
992
+ isCollapsed ? null : react_jsx_runtime.jsx("div", { className: "dpu-provider-body", children: body }, "body")
993
+ ]
994
+ }, provider.id);
995
+ });
996
+
997
+ return react_jsx_runtime.jsx("div", {
998
+ className: "dpu-widget",
999
+ children: [
1000
+ react_jsx_runtime.jsx("div", {
1001
+ className: "dpu-head",
1002
+ children: [
1003
+ react_jsx_runtime.jsx("span", { className: "dpu-brand", children: t("widget.brand") }),
1004
+ react_jsx_runtime.jsx("div", {
1005
+ className: "dpu-headright",
1006
+ children: [
1007
+ (data && data.update && data.update.available && data.update.latest
1008
+ ? react_jsx_runtime.jsx("a", {
1009
+ className: "dpu-update",
1010
+ href: "https://www.npmjs.com/package/" + PACKAGE_NAME,
1011
+ target: "_blank",
1012
+ rel: "noreferrer",
1013
+ title: t("update.title"),
1014
+ children: t("update.available", { v: data.update.latest })
1015
+ })
1016
+ : null),
1017
+ react_jsx_runtime.jsx("button", {
1018
+ className: "dpu-refresh",
1019
+ onClick: () => setStamp(stamp + 1),
1020
+ children: t("refresh")
1021
+ }),
1022
+ react_jsx_runtime.jsx("button", {
1023
+ className: "dpu-settings",
1024
+ onClick: openPluginSettings,
1025
+ title: t("settings.title"),
1026
+ children: t("settings")
1027
+ })
1028
+ ]
1029
+ })
1030
+ ]
1031
+ }),
1032
+ autoUpdateNotice(t, data && data.autoUpdate) === "" ? null : react_jsx_runtime.jsx("div", {
1033
+ className: "dpu-autoupdate",
1034
+ title: autoUpdateTitle(data && data.autoUpdate),
1035
+ children: autoUpdateNotice(t, data && data.autoUpdate)
1036
+ }),
1037
+ 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,
1039
+ !error && data === null ? react_jsx_runtime.jsx("div", { className: "dpu-ok", children: t("loading") }) : null,
1040
+ data !== null && providers.length === 0
1041
+ ? react_jsx_runtime.jsx("div", { className: "dpu-err", children: t("noProviders") })
1042
+ : null
1043
+ ]
1044
+ });
1045
+ }
1046
+
1047
+ // ── settings page (Plugins panel → this bundle) ───────────────────
1048
+ //
1049
+ // `plugins.bundle.config` is a KEYED slot: the plugin manager asks a
1050
+ // bundle for its configuration page by PACKAGE NAME and renders it above
1051
+ // the bundle's rows. The slot component branches on `props.view` before
1052
+ // any hook: `summary` is a plain string and `page` mounts SettingsPage.
1053
+ //
1054
+ // The form exposes the knobs touched most often only: one enable flag
1055
+ // and one credential ref per provider, plus the general options. End-
1056
+ // points, regions, CLI auth files and the console credential stay in the
1057
+ // auto-generated "Plugin configuration" form (they keep their defaults)
1058
+ // so this page never scrolls.
1059
+ const SETTINGS_PATH = "/provider-usage/settings";
1060
+ const PROVIDER_FORMS = [
1061
+ {
1062
+ id: "opencode-go",
1063
+ nameKey: "provider.opencode-go",
1064
+ credentialKey: "opencodeGoApiKeyEnv",
1065
+ credentialDefault: "OPENCODE_GO_API_KEY",
1066
+ enabledKey: "opencodeGoEnabled",
1067
+ console: true
1068
+ },
1069
+ {
1070
+ id: "command-code",
1071
+ nameKey: "provider.command-code",
1072
+ credentialKey: "commandCodeApiKeyEnv",
1073
+ credentialDefault: "COMMANDCODE_API_KEY",
1074
+ enabledKey: "commandCodeEnabled"
1075
+ },
1076
+ {
1077
+ id: "glm",
1078
+ nameKey: "provider.glm",
1079
+ credentialKey: "glmApiKeyEnv",
1080
+ credentialDefault: "ZAI_API_KEY",
1081
+ enabledKey: "glmEnabled",
1082
+ inviteUrl: GLM_INVITE_URL
1083
+ }
1084
+ ];
1085
+
1086
+ function emptyForm() {
1087
+ return {
1088
+ opencodeGoEnabled: true,
1089
+ opencodeGoApiKeyEnv: "OPENCODE_GO_API_KEY",
1090
+ commandCodeEnabled: true,
1091
+ commandCodeApiKeyEnv: "COMMANDCODE_API_KEY",
1092
+ glmEnabled: true,
1093
+ glmApiKeyEnv: "ZAI_API_KEY",
1094
+ providerOrder: PROVIDER_FORMS.map((definition) => definition.id)
1095
+ };
1096
+ }
1097
+
1098
+ /** The known provider ids, canonically ordered (the form default). */
1099
+ function canonicalProviderOrder() {
1100
+ return PROVIDER_FORMS.map((definition) => definition.id);
1101
+ }
1102
+
1103
+ /** Fix up a saved order into a full permutation of the known ids. */
1104
+ function providerOrderFromForm(value) {
1105
+ const order = [];
1106
+ if (Array.isArray(value)) {
1107
+ for (const id of value) {
1108
+ if (typeof id === "string" && !order.includes(id) && canonicalProviderOrder().includes(id)) order.push(id);
1109
+ }
1110
+ }
1111
+ for (const id of canonicalProviderOrder()) {
1112
+ if (!order.includes(id)) order.push(id);
1113
+ }
1114
+ return order;
1115
+ }
1116
+
1117
+ /** Field-level dirty check: reference equality, or per-member for the order array. */
1118
+ function fieldEquals(a, b) {
1119
+ if (a === b) return true;
1120
+ if (Array.isArray(a) && Array.isArray(b)) {
1121
+ return a.length === b.length && a.every((item, index) => item === b[index]);
1122
+ }
1123
+ return false;
1124
+ }
1125
+
1126
+ function formFromValue(value) {
1127
+ const form = emptyForm();
1128
+ if (value !== null && typeof value === "object") {
1129
+ for (const key of Object.keys(form)) {
1130
+ if (value[key] !== undefined) form[key] = value[key];
1131
+ }
1132
+ }
1133
+ return form;
1134
+ }
1135
+
1136
+ /** Call one of this plugin's routes. */
1137
+ async function callApi(method, path, body) {
1138
+ const init = { method: method, cache: "no-store", credentials: "same-origin" };
1139
+ if (body !== undefined) {
1140
+ init.headers = { "content-type": "application/json" };
1141
+ init.body = JSON.stringify(body);
1142
+ }
1143
+ const res = await fetch(path, init);
1144
+ let payload = null;
1145
+ try {
1146
+ payload = await res.json();
1147
+ } catch (_error) {
1148
+ payload = null;
1149
+ }
1150
+ return { status: res.status, payload: payload };
1151
+ }
1152
+
1153
+ // Inline styles on the DSH design tokens. Keeping them inline means the
1154
+ // page needs no extra CSS rule to be picked up.
1155
+ const PS = {
1156
+ wrap: { display: "flex", flexDirection: "column", gap: 12, padding: "4px 0 8px" },
1157
+ card: {
1158
+ background: "var(--dsw-alias-bg-layer-3, #16181d)",
1159
+ border: "1px solid var(--dsw-alias-border-l2, #2a2d34)",
1160
+ borderRadius: 10,
1161
+ padding: "14px 16px",
1162
+ display: "flex",
1163
+ flexDirection: "column",
1164
+ gap: 10
1165
+ },
1166
+ title: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary, #e8e8e8)" },
1167
+ hint: { margin: 0, fontSize: 12, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary, #8a8f98)" },
1168
+ providerRow: {
1169
+ display: "flex",
1170
+ flexDirection: "column",
1171
+ gap: 8,
1172
+ padding: "10px 12px",
1173
+ borderRadius: 8,
1174
+ border: "1px solid var(--dsw-alias-border-l2, #2a2d34)"
1175
+ },
1176
+ providerHead: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 },
1177
+ providerName: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary, #e8e8e8)" },
1178
+ status: { fontSize: 11, lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary, #8a8f98)", overflowWrap: "anywhere" },
1179
+ field: { display: "flex", flexDirection: "column", gap: 4, minWidth: 0 },
1180
+ fieldLabel: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #8a8f98)" },
1181
+ input: {
1182
+ font: "inherit",
1183
+ fontSize: 12,
1184
+ color: "var(--dsw-alias-label-primary, #e8e8e8)",
1185
+ background: "var(--dsw-alias-bg-layer-2, #101216)",
1186
+ border: "1px solid var(--dsw-alias-border-l2, #2a2d34)",
1187
+ borderRadius: 7,
1188
+ padding: "6px 8px",
1189
+ minWidth: 0
1190
+ },
1191
+ row: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" },
1192
+ inviteRow: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", minWidth: 0 },
1193
+ inviteLink: {
1194
+ fontSize: 12,
1195
+ lineHeight: 1.5,
1196
+ color: "var(--dsw-alias-state-info-primary, #6cb6ff)",
1197
+ textDecoration: "none",
1198
+ overflowWrap: "anywhere"
1199
+ },
1200
+ checkbox: { display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary, #a8adb8)" },
1201
+ ok: { margin: 0, fontSize: 12, color: "var(--dsw-alias-state-success-primary, #5dbb76)" },
1202
+ warn: { margin: 0, fontSize: 12, color: "var(--dsw-alias-state-warn-primary, #d6a64b)" },
1203
+ err: { margin: 0, fontSize: 12, color: "var(--dsw-alias-state-error-primary, #e5484d)" },
1204
+ info: { margin: 0, fontSize: 12, color: "var(--dsw-alias-label-tertiary, #8a8f98)" },
1205
+ button: {
1206
+ font: "inherit",
1207
+ cursor: "pointer",
1208
+ background: "transparent",
1209
+ color: "var(--dsw-alias-label-secondary, #a8adb8)",
1210
+ border: "1px solid var(--dsw-alias-border-l2, #2a2d34)",
1211
+ borderRadius: 8,
1212
+ padding: "6px 14px",
1213
+ fontSize: 12
1214
+ },
1215
+ buttonPrimary: {
1216
+ font: "inherit",
1217
+ cursor: "pointer",
1218
+ background: "var(--dsw-alias-label-primary, #0f1115)",
1219
+ color: "var(--dsw-alias-bg-layer-3, #ffffff)",
1220
+ border: "1px solid transparent",
1221
+ borderRadius: 8,
1222
+ padding: "7px 16px",
1223
+ fontSize: 12,
1224
+ fontWeight: 500
1225
+ },
1226
+ buttonDisabled: { opacity: 0.4, cursor: "not-allowed" }
1227
+ };
1228
+
1229
+ function Field(props) {
1230
+ return react_jsx_runtime.jsx("label", {
1231
+ style: PS.field,
1232
+ children: [
1233
+ react_jsx_runtime.jsx("span", { style: PS.fieldLabel, children: props.label }),
1234
+ props.children
1235
+ ]
1236
+ });
1237
+ }
1238
+
1239
+ function StatusLine(props) {
1240
+ return react_jsx_runtime.jsx("div", {
1241
+ style: props.error ? PS.err : PS.status,
1242
+ children: props.children
1243
+ });
1244
+ }
1245
+
1246
+ /**
1247
+ * Copy a short text, preferring the async clipboard and falling back to
1248
+ * a hidden selection for shells that only ship `execCommand`. Returns a
1249
+ * boolean so the caller can show a failure hint instead of a lie.
1250
+ */
1251
+ async function copyText(text) {
1252
+ try {
1253
+ if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
1254
+ await navigator.clipboard.writeText(text);
1255
+ return true;
1256
+ }
1257
+ } catch (_error) {
1258
+ // fall through to the selection fallback
1259
+ }
1260
+ try {
1261
+ const area = document.createElement("textarea");
1262
+ area.value = text;
1263
+ area.setAttribute("readonly", "");
1264
+ area.style.position = "fixed";
1265
+ area.style.top = "-1000px";
1266
+ area.style.opacity = "0";
1267
+ document.body.appendChild(area);
1268
+ area.select();
1269
+ const ok = document.execCommand("copy");
1270
+ document.body.removeChild(area);
1271
+ return ok === true;
1272
+ } catch (_error) {
1273
+ return false;
1274
+ }
1275
+ }
1276
+
1277
+ /**
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.
1281
+ */
1282
+ function InviteLink(props) {
1283
+ const t = props.t;
1284
+ const [status, setStatus] = react.useState("idle");
1285
+ const timer = react.useRef(null);
1286
+ react.useEffect(() => () => {
1287
+ if (timer.current !== null) clearTimeout(timer.current);
1288
+ }, []);
1289
+ const copy = async () => {
1290
+ const ok = await copyText(props.url);
1291
+ setStatus(ok ? "copied" : "failed");
1292
+ if (timer.current !== null) clearTimeout(timer.current);
1293
+ timer.current = setTimeout(() => setStatus("idle"), 1800);
1294
+ };
1295
+ const note = status === "copied" ? t("cfg.invite.copied") : status === "failed" ? t("cfg.invite.copyFailed") : "";
1296
+ return react_jsx_runtime.jsx("div", {
1297
+ style: PS.inviteRow,
1298
+ children: [
1299
+ react_jsx_runtime.jsx("span", { style: PS.fieldLabel, children: t("cfg.invite") }),
1300
+ react_jsx_runtime.jsx("a", {
1301
+ style: PS.inviteLink,
1302
+ href: props.url,
1303
+ target: "_blank",
1304
+ rel: "noreferrer",
1305
+ children: props.url
1306
+ }),
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
+ })
1317
+ ]
1318
+ });
1319
+ }
1320
+
1321
+ /**
1322
+ * The configuration page itself. Mounted only for `view: "page"`.
1323
+ *
1324
+ * Mount → GET /provider-usage/settings → `phase: "ready"` with the
1325
+ * redacted `value` and the non-network `detection` report. Save POSTs
1326
+ * only the changed fields; 409 shows a reload hint, 503 explains the
1327
+ * missing settings descriptor.
1328
+ */
1329
+ function SettingsPage(props) {
1330
+ const t = translateFor(props);
1331
+ const [state, setState] = react.useState({ phase: "loading", revision: 0, value: {}, detection: null, error: null });
1332
+ const [form, setForm] = react.useState(emptyForm);
1333
+ const [busy, setBusy] = react.useState({ save: false, reload: false });
1334
+ const [message, setMessage] = react.useState(null);
1335
+ const [draggingId, setDraggingId] = react.useState(null);
1336
+ const tRef = react.useRef(t);
1337
+ tRef.current = t;
1338
+ // Id of the provider card currently being dragged (drag reordering).
1339
+ const dragProviderRef = react.useRef(null);
1340
+
1341
+ const reload = react.useCallback(async (announce) => {
1342
+ const tt = tRef.current;
1343
+ try {
1344
+ const res = await callApi("GET", SETTINGS_PATH);
1345
+ const payload = res.payload;
1346
+ if (res.status === 200 && payload !== null) {
1347
+ const value = payload.value && typeof payload.value === "object" ? payload.value : {};
1348
+ setState({
1349
+ phase: "ready",
1350
+ revision: typeof payload.revision === "number" ? payload.revision : 0,
1351
+ value: value,
1352
+ detection: payload.detection && typeof payload.detection === "object" ? payload.detection : null,
1353
+ error: null
1354
+ });
1355
+ setForm(formFromValue(value));
1356
+ setMessage(announce === true ? { kind: "ok", text: tt("cfg.reloaded") } : null);
1357
+ return true;
1358
+ }
1359
+ const detail = (payload && (payload.error || payload.hint)) || "";
1360
+ setState((prev) => ({
1361
+ ...prev,
1362
+ phase: "error",
1363
+ error: (res.status === 503 ? tt("cfg.unsupported") : tt("cfg.loadFailed", { error: "HTTP " + res.status })) + (detail === "" ? "" : " [" + detail + "]")
1364
+ }));
1365
+ return false;
1366
+ } catch (error) {
1367
+ setState((prev) => ({ ...prev, phase: "error", error: tt("cfg.loadFailed", { error: (error && error.message) || String(error) }) }));
1368
+ return false;
1369
+ }
1370
+ }, []);
1371
+
1372
+ react.useEffect(() => {
1373
+ reload(false);
1374
+ }, [reload]);
1375
+
1376
+ const update = (key, value) => {
1377
+ setForm((prev) => ({ ...prev, [key]: value }));
1378
+ setMessage(null);
1379
+ };
1380
+
1381
+ const save = async () => {
1382
+ if (busy.save) return;
1383
+ const tt = tRef.current;
1384
+ const server = formFromValue(state.value || {});
1385
+ const patch = {};
1386
+ for (const key of Object.keys(form)) {
1387
+ if (!fieldEquals(form[key], server[key])) patch[key] = form[key];
1388
+ }
1389
+ if (Object.keys(patch).length === 0) {
1390
+ setMessage({ kind: "info", text: tt("cfg.noChanges") });
1391
+ return;
1392
+ }
1393
+ setBusy((b) => ({ ...b, save: true }));
1394
+ setMessage(null);
1395
+ try {
1396
+ const res = await callApi("POST", SETTINGS_PATH, { patch: patch, expectedRevision: state.revision });
1397
+ const payload = res.payload;
1398
+ if (res.status === 200 && payload !== null) {
1399
+ await reload(false);
1400
+ usageRefreshStore.publish();
1401
+ setMessage({ kind: "ok", text: tt("cfg.saved") });
1402
+ } else if (res.status === 409) {
1403
+ setMessage({ kind: "warn", text: tt("cfg.conflict") });
1404
+ } else {
1405
+ setMessage({ kind: "err", text: tt("cfg.saveFailed", { error: (payload && payload.error) || ("HTTP " + res.status) }) });
1406
+ }
1407
+ } catch (error) {
1408
+ setMessage({ kind: "err", text: tt("cfg.saveFailed", { error: (error && error.message) || String(error) }) });
1409
+ } finally {
1410
+ setBusy((b) => ({ ...b, save: false }));
1411
+ }
1412
+ };
1413
+
1414
+ const onReload = async () => {
1415
+ if (busy.reload) return;
1416
+ setBusy((b) => ({ ...b, reload: true }));
1417
+ try {
1418
+ await reload(true);
1419
+ } finally {
1420
+ setBusy((b) => ({ ...b, reload: false }));
1421
+ }
1422
+ };
1423
+
1424
+ if (state.phase === "loading") {
1425
+ return react_jsx_runtime.jsx("div", { style: PS.wrap, children: react_jsx_runtime.jsx("p", { style: PS.hint, children: t("cfg.loading") }) });
1426
+ }
1427
+ if (state.phase === "error") {
1428
+ return react_jsx_runtime.jsx("div", {
1429
+ style: PS.wrap,
1430
+ children: [
1431
+ react_jsx_runtime.jsx("p", { style: PS.err, children: state.error }),
1432
+ react_jsx_runtime.jsx("div", {
1433
+ style: PS.row,
1434
+ children: react_jsx_runtime.jsx("button", {
1435
+ style: busy.reload ? { ...PS.button, ...PS.buttonDisabled } : PS.button,
1436
+ disabled: busy.reload,
1437
+ onClick: onReload,
1438
+ children: t("cfg.retry")
1439
+ })
1440
+ })
1441
+ ]
1442
+ });
1443
+ }
1444
+
1445
+ const detection = state.detection && typeof state.detection === "object" ? state.detection : {};
1446
+ const detected = detection.providers && typeof detection.providers === "object" ? detection.providers : {};
1447
+ const disabled = busy.save || busy.reload;
1448
+ const server = formFromValue(state.value || {});
1449
+ const dirty = Object.keys(form).some((key) => !fieldEquals(form[key], server[key]));
1450
+
1451
+ // Cards render in the saved order. Reordering works three ways, so a
1452
+ // shell without HTML5 drag (touch, Firefox proton, embedded webviews)
1453
+ // always has a path: drag a card header onto another card, drop it
1454
+ // anywhere over a card, or tap the header's ▲/▼ buttons. The order is
1455
+ // written into `providerOrder` and saved with the next settings save.
1456
+ const orderedDefinitions = providerOrderFromForm(form.providerOrder)
1457
+ .map((id) => PROVIDER_FORMS.find((definition) => definition.id === id))
1458
+ .filter(Boolean);
1459
+ const currentOrder = orderedDefinitions.map((definition) => definition.id);
1460
+ const moveProvider = (fromId, toId) => {
1461
+ if (fromId === null || fromId === toId) return;
1462
+ const order = providerOrderFromForm(form.providerOrder);
1463
+ const from = order.indexOf(fromId);
1464
+ const to = order.indexOf(toId);
1465
+ if (from === -1 || to === -1) return;
1466
+ order.splice(to, 0, order.splice(from, 1)[0]);
1467
+ update("providerOrder", order);
1468
+ };
1469
+ const shiftProvider = (id, delta) => {
1470
+ const order = providerOrderFromForm(form.providerOrder);
1471
+ const from = order.indexOf(id);
1472
+ if (from === -1) return;
1473
+ const to = Math.max(0, Math.min(order.length - 1, from + delta));
1474
+ if (to === from) return;
1475
+ order.splice(to, 0, order.splice(from, 1)[0]);
1476
+ update("providerOrder", order);
1477
+ };
1478
+ /** Card-level drop zone: accepts a drop anywhere over the card. */
1479
+ const dropProps = (definition) => ({
1480
+ onDragOver: (event) => {
1481
+ try { event.preventDefault(); } catch (_error) {
1482
+ // dragging is best-effort on older shells
1483
+ }
1484
+ },
1485
+ onDrop: (event) => {
1486
+ try {
1487
+ event.preventDefault();
1488
+ // `text/plain` carries the id for engines that need payload.
1489
+ const fromId = event.dataTransfer && typeof event.dataTransfer.getData === "function"
1490
+ ? event.dataTransfer.getData("text/plain")
1491
+ : "";
1492
+ moveProvider(fromId || dragProviderRef.current, definition.id);
1493
+ } catch (_error) {
1494
+ moveProvider(dragProviderRef.current, definition.id);
1495
+ }
1496
+ dragProviderRef.current = null;
1497
+ setDraggingId(null);
1498
+ }
1499
+ });
1500
+ /** Header drag handle: initiates the reorder drag. */
1501
+ const dragHandleProps = (definition) => ({
1502
+ draggable: !disabled,
1503
+ onDragStart: (event) => {
1504
+ dragProviderRef.current = definition.id;
1505
+ setDraggingId(definition.id);
1506
+ try {
1507
+ // Firefox refuses to start a drag without payload data.
1508
+ event.dataTransfer.effectAllowed = "move";
1509
+ event.dataTransfer.setData("text/plain", definition.id);
1510
+ } catch (_error) {
1511
+ // engines without dataTransfer still reorder via the drop target
1512
+ }
1513
+ },
1514
+ onDragEnd: () => {
1515
+ dragProviderRef.current = null;
1516
+ setDraggingId(null);
1517
+ }
1518
+ });
1519
+ /** Small ghost button that shifts one provider up/down. */
1520
+ const shiftButton = (definition, delta, glyph, titleKey, blocked) => react_jsx_runtime.jsx("button", {
1521
+ type: "button",
1522
+ "aria-label": t(titleKey),
1523
+ title: t(titleKey),
1524
+ disabled: disabled || blocked,
1525
+ onClick: () => shiftProvider(definition.id, delta),
1526
+ style: {
1527
+ border: "none",
1528
+ background: "transparent",
1529
+ padding: "0 3px",
1530
+ fontSize: "12px",
1531
+ lineHeight: "16px",
1532
+ cursor: blocked || disabled ? "default" : "pointer",
1533
+ color: blocked || disabled ? "var(--dsw-alias-label-tertiary, #8a8f98)" : "var(--dsw-alias-label-secondary, #a8adb8)"
1534
+ },
1535
+ children: glyph
1536
+ }, titleKey);
1537
+ const providerCards = orderedDefinitions.map((definition) => {
1538
+ const info = detected[definition.id] && typeof detected[definition.id] === "object" ? detected[definition.id] : {};
1539
+ const enabled = form[definition.enabledKey] !== false;
1540
+ const statusLines = [];
1541
+ statusLines.push(info.configured === true ? t("cfg.status.configured") : t("cfg.status.notConfigured"));
1542
+ statusLines.push(enabled ? t("cfg.status.enabled") : t("cfg.status.disabled"));
1543
+ if (typeof info.credentialRef === "string" && info.credentialRef !== "") statusLines.push(info.credentialRef);
1544
+ if (info.account && typeof info.account.name === "string" && info.account.name !== "") {
1545
+ statusLines.push(t("cfg.status.account") + ": " + info.account.name);
1546
+ }
1547
+ if (typeof info.error === "string" && info.error !== "") statusLines.push(info.error);
1548
+
1549
+ const orderIndex = currentOrder.indexOf(definition.id);
1550
+ const children = [
1551
+ react_jsx_runtime.jsx("div", {
1552
+ style: { ...PS.providerHead, cursor: disabled ? undefined : "grab" },
1553
+ title: disabled ? undefined : t("cfg.order.hint"),
1554
+ ...dragHandleProps(definition),
1555
+ children: [
1556
+ react_jsx_runtime.jsx("span", { style: PS.providerName, children: t(definition.nameKey) }),
1557
+ shiftButton(definition, -1, "▲", "cfg.move.up", orderIndex <= 0),
1558
+ shiftButton(definition, 1, "▼", "cfg.move.down", orderIndex >= currentOrder.length - 1),
1559
+ react_jsx_runtime.jsx("label", {
1560
+ style: { ...PS.checkbox, marginLeft: "auto" },
1561
+ children: [
1562
+ react_jsx_runtime.jsx("input", {
1563
+ type: "checkbox",
1564
+ checked: enabled,
1565
+ disabled: disabled,
1566
+ onChange: (event) => update(definition.enabledKey, event.target.checked)
1567
+ }),
1568
+ t("cfg.enabled")
1569
+ ]
1570
+ })
1571
+ ]
1572
+ }),
1573
+ react_jsx_runtime.jsx(Field, {
1574
+ label: t("cfg.credential"),
1575
+ children: react_jsx_runtime.jsx("input", {
1576
+ style: PS.input,
1577
+ type: "text",
1578
+ value: asText(form[definition.credentialKey]),
1579
+ disabled: disabled || !enabled,
1580
+ placeholder: definition.credentialDefault,
1581
+ onChange: (event) => update(definition.credentialKey, event.target.value)
1582
+ })
1583
+ })
1584
+ ];
1585
+
1586
+ if (definition.console === true) {
1587
+ const consoleInfo = info.console && typeof info.console === "object" ? info.console : null;
1588
+ // Reading the top-up balance is always on (the host default);
1589
+ // the page only reports whether a credential was found.
1590
+ if (consoleInfo !== null) {
1591
+ children.push(react_jsx_runtime.jsx(StatusLine, {
1592
+ error: typeof consoleInfo.error === "string" && consoleInfo.error !== "",
1593
+ children: t("cfg.console.status") + ": " + (consoleInfo.configured === true ? t("cfg.status.configured") : t("cfg.status.notConfigured")) + (consoleInfo.source ? " · " + consoleInfo.source : "")
1594
+ }, "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"));
1600
+ }
1601
+ }
1602
+ }
1603
+
1604
+ children.push(react_jsx_runtime.jsx(StatusLine, {
1605
+ error: typeof info.error === "string" && info.error !== "",
1606
+ children: statusLines.join(" · ")
1607
+ }));
1608
+
1609
+ if (typeof definition.inviteUrl === "string" && definition.inviteUrl !== "") {
1610
+ children.push(react_jsx_runtime.jsx(InviteLink, { t: t, url: definition.inviteUrl }, "invite"));
1611
+ }
1612
+
1613
+ return react_jsx_runtime.jsx("div", {
1614
+ style: {
1615
+ ...PS.providerRow,
1616
+ opacity: draggingId === definition.id ? 0.45 : undefined,
1617
+ transition: "opacity .15s ease"
1618
+ },
1619
+ ...dropProps(definition),
1620
+ children: children
1621
+ }, definition.id);
1622
+ });
1623
+
1624
+ const messageStyle = message === null
1625
+ ? PS.info
1626
+ : message.kind === "ok" ? PS.ok : message.kind === "warn" ? PS.warn : message.kind === "err" ? PS.err : PS.info;
1627
+
1628
+ return react_jsx_runtime.jsx("div", {
1629
+ style: PS.wrap,
1630
+ children: [
1631
+ react_jsx_runtime.jsx("section", {
1632
+ style: PS.card,
1633
+ children: [
1634
+ react_jsx_runtime.jsx("div", { style: PS.title, children: t("cfg.title") }),
1635
+ 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") }),
1637
+ react_jsx_runtime.jsx("div", {
1638
+ style: { display: "flex", flexDirection: "column", gap: 8 },
1639
+ children: providerCards
1640
+ })
1641
+ ]
1642
+ }),
1643
+ react_jsx_runtime.jsx("section", {
1644
+ style: PS.card,
1645
+ children: [
1646
+ react_jsx_runtime.jsx("div", {
1647
+ style: PS.row,
1648
+ children: [
1649
+ react_jsx_runtime.jsx("button", {
1650
+ style: disabled ? { ...PS.buttonPrimary, ...PS.buttonDisabled } : PS.buttonPrimary,
1651
+ disabled: disabled,
1652
+ onClick: save,
1653
+ children: busy.save ? t("cfg.saving") : t("cfg.save")
1654
+ }),
1655
+ react_jsx_runtime.jsx("button", {
1656
+ style: disabled ? { ...PS.button, ...PS.buttonDisabled } : PS.button,
1657
+ disabled: disabled,
1658
+ onClick: onReload,
1659
+ children: busy.reload ? t("cfg.reloading") : t("cfg.reload")
1660
+ }),
1661
+ dirty ? react_jsx_runtime.jsx("span", { style: PS.warn, children: t("cfg.unsaved") }) : null,
1662
+ react_jsx_runtime.jsx("span", {
1663
+ style: { ...PS.hint, marginLeft: "auto" },
1664
+ children: t("cfg.revision", { n: state.revision })
1665
+ })
1666
+ ]
1667
+ }),
1668
+ message === null ? null : react_jsx_runtime.jsx("p", { style: messageStyle, children: message.text })
1669
+ ]
1670
+ })
1671
+ ]
1672
+ });
1673
+ }
1674
+
1675
+ /** One-line description the panel may render as the bundle's summary. */
1676
+ const SUMMARY = "OpenCode GO / Command Code / GLM Code Plan 套餐用量:一个侧边栏组件、同源代理与 /provider-usage 命令。";
1677
+
1678
+ /**
1679
+ * The `plugins.bundle.config` occupant. The `view` branch is a component
1680
+ * boundary on purpose: SettingsPage opens with hooks, so the summary —
1681
+ * rendered as a plain string by the panel — must not pass through a
1682
+ * hook-bearing parent.
1683
+ */
1684
+ function BundleConfig(props) {
1685
+ if (props !== null && props !== undefined && props.view === "summary") {
1686
+ try {
1687
+ return translateFor(props)("cfg.summary");
1688
+ } catch (_error) {
1689
+ return SUMMARY;
1690
+ }
1691
+ }
1692
+ return react_jsx_runtime.jsx(SettingsPage, props && typeof props.t === "function" ? { t: props.t } : {});
1693
+ }
1694
+
1695
+ // ── cordis plugin entry ──
1696
+ const inject = ["slots", "locale"];
1697
+
1698
+ function apply(ctx) {
1699
+ pluginContext = ctx;
1700
+ ctx.effect(() => () => {
1701
+ if (pluginContext === ctx) pluginContext = null;
1702
+ }, "dsh-multi-provider-usage: context");
1703
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-multi-provider-usage: dictionaries");
1704
+ const registerWidget = () => ctx.slots.register({
1705
+ name: "sidebar.footer.action",
1706
+ id: PACKAGE_NAME,
1707
+ order: 0,
1708
+ label: "Provider usage",
1709
+ locale: NS
1710
+ }, Widget);
1711
+ const registerConfig = () => ctx.slots.register({
1712
+ name: "plugins.bundle.config",
1713
+ key: CONFIG_KEY,
1714
+ locale: NS
1715
+ }, BundleConfig);
1716
+ // The settings page is additive: a host that predates the Plugins
1717
+ // panel (or a slots service without `inject`) must lose only this
1718
+ // page, never the widget.
1719
+ const registerConfigSafely = () => {
1720
+ try {
1721
+ registerConfig();
1722
+ } catch (_error) {
1723
+ // no plugins.bundle.config slot in this composition
1724
+ }
1725
+ };
1726
+ if (typeof ctx.slots.inject === "function") {
1727
+ ctx.slots.inject("sidebar.footer.action", registerWidget);
1728
+ try {
1729
+ ctx.slots.inject("plugins.bundle.config", registerConfigSafely);
1730
+ } catch (_error) {
1731
+ // older slots service: the widget alone still works
1732
+ }
1733
+ } else {
1734
+ ctx.effect(registerWidget, "dsh-multi-provider-usage: widget registration");
1735
+ ctx.effect(registerConfigSafely, "dsh-multi-provider-usage: settings page registration");
1736
+ }
1737
+ }
1738
+
1739
+ exports.apply = apply;
1740
+ exports.inject = inject;
1741
+ exports.CONFIG_KEY = CONFIG_KEY;
1742
+ exports.SUMMARY = SUMMARY;
1743
+ exports.Widget = Widget;
1744
+ exports.BundleConfig = BundleConfig;
1745
+ exports.SettingsPage = SettingsPage;
1746
+ exports.InviteLink = InviteLink;
1747
+ exports.GLM_INVITE_URL = GLM_INVITE_URL;
1748
+ exports.balanceDeltasBetween = balanceDeltasBetween;
1749
+ exports.formatSignedMoney = formatSignedMoney;
1750
+ exports.formatDeltaShort = formatDeltaShort;
1751
+ return module.exports;
1752
+ }
1753
+ });