dsh-cline-pass 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -6,10 +6,10 @@
6
6
  * exports as an ordinary plugin (`apply` + `inject`). Plain CJS with no build
7
7
  * step, so `require` reaches only the shell's platform seed table — `react`.
8
8
  *
9
- * It adds a Cline Pass page in Settings (the same card on the Plugins page) and
10
- * a compact key card on the Models page. Every read and write goes to the
11
- * authenticated `/api/cline-pass` route published by `lib/panel.js`, so the
12
- * panel and the `cline_pass_*` tools share one source of truth.
9
+ * It adds a folded Cline Pass card to the Plugins page and a compact key card
10
+ * on the Models page. Every read and write goes to the authenticated
11
+ * `/api/cline-pass` route published by `lib/panel.js`, so the panel and the
12
+ * `cline_pass_*` tools share one source of truth.
13
13
  *
14
14
  * @module dsh-cline-pass/client
15
15
  */
@@ -23,20 +23,52 @@ window.__ModuleLoader__.load({
23
23
 
24
24
  const React = require('react')
25
25
  const h = React.createElement
26
+ // The platform seed table publishes the icon set, so this bundle draws its
27
+ // disclosure chevrons with the very component the host's own cards use.
28
+ //
29
+ // That export was renamed between host releases: the 0.1.2–0.1.5 line ships
30
+ // `IconChevronDownOutline14`, while 0.1.6+ ships the artwork / regular /
31
+ // medium triple and the unsuffixed name. Destructuring a single name yields
32
+ // `undefined` on the other host, and React throws on an undefined element
33
+ // type — which takes the whole panel down, not just the chevron. Resolve
34
+ // whichever this host publishes, newest first, and let `Chevron` fall back
35
+ // to an inline glyph so an unknown host still draws the disclosure.
36
+ const PRIMITIVES_SPECIFIER = '@deepseek-ai/dsh-client-ui-primitives'
37
+ const primitives = require(PRIMITIVES_SPECIFIER)
38
+ const HostChevron = primitives.IconChevronDownOutline
39
+ ?? primitives.IconChevronDownOutlineRegular
40
+ ?? primitives.IconChevronDownOutlineArtwork
41
+ ?? primitives.IconChevronDownOutlineMedium
42
+ ?? primitives.IconChevronDownOutline14
43
+ ?? null
26
44
 
27
45
  /** Locale namespace this card owns. */
28
46
  const NS = 'cline-pass'
29
47
  /** Exact `/api` Fetch route the host half publishes. */
30
48
  const PANEL_PATH = '/api/cline-pass'
31
49
  /** Human label for the upstream verdict vocabulary. */
32
- const VERDICT = { ok: 'available', limited: 'rate-limited', bad: 'not-pinnable', auth: 'auth-failed', unknown: 'unknown' }
50
+ const VERDICT = { ok: 'available', limited: 'rate-limited', bad: 'not-pinnable', auth: 'auth-failed', 'not-adopted': 'not-adopted', unknown: 'unknown' }
51
+ /**
52
+ * The quota windows this bundle can name, in display order.
53
+ *
54
+ * A window the gateway reports that is missing here still renders — under the
55
+ * gateway's own type string — so an added window needs no release.
56
+ */
57
+ const USAGE_WINDOWS = [['five_hour', 'usageFiveHour'], ['weekly', 'usageWeekly'], ['monthly', 'usageMonthly']]
58
+ /**
59
+ * How long a token reading stays fresh.
60
+ *
61
+ * Counting walks the account's usage history, so an automatic re-read on
62
+ * every panel open would spend requests for a number that moves slowly.
63
+ */
64
+ const TOKEN_TTL_MS = 5 * 60_000
33
65
 
34
66
  //#region styles
35
67
 
36
68
  const CSS = [
37
69
  '.cp-panel{max-width:820px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:14px;font-size:13px}',
38
70
  '.cp-title{margin:0;font-size:18px;font-weight:600}',
39
- '.cp-card{border:0.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}',
71
+ '.cp-card{border:0.5px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}',
40
72
  '.cp-card-title{font-weight:600;font-size:13px;display:flex;align-items:center;gap:8px;justify-content:space-between}',
41
73
  '.cp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
42
74
  '.cp-grow{flex:1;min-width:120px}',
@@ -51,7 +83,29 @@ window.__ModuleLoader__.load({
51
83
  '.cp-btn-danger{color:var(--dsw-alias-state-error-primary)}',
52
84
  '.cp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}',
53
85
  '.cp-table{display:flex;flex-direction:column;gap:6px}',
54
- '.cp-account,.cp-model{border:0.5px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:8px}',
86
+ '.cp-model{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:8px}',
87
+ // The account table is a real table for the same reason the history one
88
+ // is: per-row grids size their `auto` tracks from their own content, so a
89
+ // header cell never lines up with the data under it.
90
+ '.cp-acct-table{width:100%;border-collapse:collapse;table-layout:fixed}',
91
+ '.cp-acct-table th{text-align:center;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);padding:0 0 6px;border-bottom:0.5px solid var(--dsw-alias-border-l3)}',
92
+ '.cp-acct-table td{padding:6px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);font-size:12px;vertical-align:middle;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
93
+ '.cp-acct-table tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
94
+ '.cp-acct-col-dot{width:52px}',
95
+ '.cp-acct-state{display:flex;align-items:center;gap:8px}',
96
+ '.cp-acct-row-off .cp-acct-id,.cp-acct-row-off .cp-acct-hint{opacity:.55}',
97
+ '.cp-acct-col-hint{width:104px}',
98
+ '.cp-acct-col-actions{width:190px}',
99
+ '.cp-acct-id{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:0}',
100
+ '.cp-acct-name{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
101
+ '.cp-acct-ref,.cp-acct-hint{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
102
+ '.cp-acct-ref{color:var(--dsw-alias-label-tertiary)}',
103
+ '.cp-acct-hint{color:var(--dsw-alias-label-secondary);font-size:12px}',
104
+ // Wrapping is the fallback for a panel narrower than the three buttons:
105
+ // a taller row beats a clipped button.
106
+ '.cp-acct-actions{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap}',
107
+ '.cp-acct-newname{flex:0 0 150px}',
108
+ '.cp-acct-add,.cp-acct-key{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px;margin-top:2px}',
55
109
  '.cp-chips{display:flex;flex-wrap:wrap;gap:6px}',
56
110
  '.cp-chip{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:2px 8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}',
57
111
  '.cp-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:600}',
@@ -60,10 +114,86 @@ window.__ModuleLoader__.load({
60
114
  '.cp-msg-ok{background:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-bg-base)}',
61
115
  '.cp-msg-err{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base)}',
62
116
  '.cp-msg-warn{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base)}',
63
- '.cp-history{display:flex;flex-direction:column;gap:4px;max-height:240px;overflow:auto}',
64
- '.cp-history-row{display:flex;gap:8px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;border-bottom:0.5px solid var(--dsw-alias-border-l1);padding-bottom:3px}',
117
+ '.cp-history{max-height:280px;overflow:auto;padding:0 4px 2px}',
118
+ // A real table, not a grid per row: with one grid per row every `auto`
119
+ // track is resolved from that row's own content, so a row whose upstream
120
+ // is a long pill and a row whose upstream is "(auto)" end up with
121
+ // different column widths — and nothing lines up with the header.
122
+ '.cp-history-table{width:100%;border-collapse:collapse;table-layout:fixed}',
123
+ // Header and data share one alignment: a table whose labels sit left of
124
+ // their values is the thing this panel keeps getting asked to fix.
125
+ '.cp-history-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-1);text-align:center;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);padding:2px 0 6px;border-bottom:0.5px solid var(--dsw-alias-border-l3)}',
126
+ '.cp-history-table td{padding:6px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);font-size:12px;vertical-align:middle;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
127
+ '.cp-history-table tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
128
+ '.cp-history-col-dot{width:18px}',
129
+ '.cp-history-col-when{width:62px}',
130
+ '.cp-history-col-tokens{width:120px}',
131
+ '.cp-history-col-load{width:108px}',
132
+ '.cp-history-when{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
133
+ '.cp-history-model-cell{min-width:0}',
134
+ // The flex stack lives INSIDE the cell, never on the `<td>` itself: a td
135
+ // with `display:flex` stops being a table-cell, drops out of the row's
136
+ // height equalisation, and the row's two bottom borders land at different
137
+ // y positions — which is what makes the rule look broken.
138
+ '.cp-history-model-stack{display:flex;flex-direction:column;gap:2px;min-width:0;align-items:center}',
139
+ '.cp-history-model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
140
+ // Selector must out-specify `.cp-history-table td` (0,1,1), whose nowrap +
141
+ // ellipsis would otherwise clip the counts with no way to read them.
142
+ '.cp-history-table td.cp-history-tokens{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip;line-height:1.5}',
143
+ // Three stacked figures read like a request log: label left, value right.
144
+ '.cp-history-table td.cp-history-load{font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip}',
145
+ '.cp-history-load-row{display:flex;justify-content:space-between;gap:6px;font-size:11.5px;line-height:1.5}',
146
+ '.cp-history-meta{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden}',
147
+ // A failure gets its own row under the entry rather than widening it: the
148
+ // gateway's messages are long enough to wreck a one-line layout.
149
+ '.cp-history-error{color:var(--dsw-alias-state-error-primary);font-size:11.5px}',
150
+ '.cp-tag{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
151
+ '.cp-tag-quiet{background:0 0;border-color:transparent;color:var(--dsw-alias-label-tertiary);flex:none}',
65
152
  '.cp-trace{font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary);display:flex;flex-direction:column;gap:2px}',
66
153
  '.cp-inline{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
154
+ '.cp-grow-end{margin-left:auto}',
155
+ // Plugins-page card chrome, mirrored token for token from the host's own
156
+ // `PluginCard`: that tab hands a plugin the whole card, so matching it —
157
+ // rather than inventing a second look — is this bundle's job.
158
+ '.cp-set-card{border:0.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}',
159
+ '.cp-set-card:hover{border-color:var(--dsw-alias-label-dimmed)}',
160
+ '.cp-set-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
161
+ '.cp-set-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}',
162
+ '.cp-set-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
163
+ '.cp-set-head-text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
164
+ '.cp-set-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
165
+ '.cp-set-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
166
+ '.cp-set-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
167
+ '.cp-set-chevron-open{transform:rotate(180deg)}',
168
+ '.cp-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;display:inline-flex}',
169
+ 'details[open]>summary .cp-chevron{transform:rotate(180deg)}',
170
+ // A summary laid out as a flex row keeps no native affordance, so the
171
+ // pointer and the hover tone are what say "this opens".
172
+ '.cp-fold{cursor:pointer}',
173
+ '.cp-fold:hover .cp-chevron{color:var(--dsw-alias-label-primary)}',
174
+ '.cp-set-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px}',
175
+ '.cp-usage-groups{display:flex;flex-direction:column;gap:12px}',
176
+ '.cp-usage-group+.cp-usage-group{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px}',
177
+ '.cp-usage-account{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
178
+ '.cp-usage{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}',
179
+ '.cp-usage-item{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
180
+ '.cp-usage-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}',
181
+ '.cp-usage-label{font-weight:600}',
182
+ '.cp-usage-value{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}',
183
+ '.cp-usage-track{height:6px;border-radius:999px;background:var(--dsw-alias-border-l2);overflow:hidden}',
184
+ '.cp-usage-fill{height:100%;border-radius:999px;transition:width .3s}',
185
+ '.cp-usage-note{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
186
+ '.cp-usage-tokens{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}',
187
+ '.cp-usage-models{margin-top:2px}',
188
+ '.cp-usage-models-sum{cursor:pointer;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
189
+ '.cp-usage-models-sum:hover{color:var(--dsw-alias-label-primary)}',
190
+ '.cp-usage-model-rows{display:flex;flex-direction:column;gap:2px;margin-top:6px}',
191
+ '.cp-usage-model-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:10px;align-items:baseline;font-size:11.5px}',
192
+ '.cp-usage-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
193
+ '.cp-usage-model-cost{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}',
194
+ '.cp-usage-model-tokens{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);min-width:52px;text-align:right}',
195
+ '.cp-check{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}',
196
+ '.cp-model-hidden .cp-mono,.cp-model-hidden .cp-check{opacity:.55}',
67
197
  ].join('')
68
198
 
69
199
  const CSS_TAG = '@deepseek-ai/dsh-cline-pass/client.css'
@@ -81,6 +211,10 @@ window.__ModuleLoader__.load({
81
211
 
82
212
  const ZH = {
83
213
  title: 'Cline Pass',
214
+ description: '订阅模型的渠道钉住与账号池配置',
215
+ panelUnavailable: '浏览器通道不可用。',
216
+ expand: '展开设置',
217
+ collapse: '收起设置',
84
218
  keyOk: 'API Key 已配置',
85
219
  keyMissing: '尚未配置 API Key',
86
220
  keyValue: 'API Key',
@@ -90,6 +224,18 @@ window.__ModuleLoader__.load({
90
224
  test: '测试',
91
225
  remove: '删除',
92
226
  accounts: '账号',
227
+ accountsCount: '{n} 个账号',
228
+ accountsActive: '当前',
229
+ accountsSetActive: '设为当前',
230
+ accountsKey: '密钥',
231
+ accountsActions: '操作',
232
+ accountsEnabled: '启用',
233
+ accountsOff: '已停用',
234
+ accountsPoolHint: '单账号:优先用「当前」账号;轮询:每次请求依次使用已启用账号。任一账号额度耗尽或被拒时,会自动改用池内其他账号,失败账号冷却一段时间。',
235
+ accountsFirstRun: '配置 API Key 后,才会显示官方额度、订阅模型与请求记录。',
236
+ accountsFor: '为「{name}」保存密钥',
237
+ accountsFirst: '(首个可用)',
238
+ keyNone: '未配置',
93
239
  accountMode: '账号模式',
94
240
  modeSingle: '单账号',
95
241
  modeRoundrobin: '轮询',
@@ -98,11 +244,38 @@ window.__ModuleLoader__.load({
98
244
  accountName: '账号名',
99
245
  models: '订阅模型',
100
246
  modelsCount: '{n} 个模型',
247
+ hiddenModels: '{n} 个已从选择列表隐藏',
248
+ visible: '在模型选择列表中显示',
249
+ hiddenTag: '已隐藏',
250
+ selectAll: '全选',
251
+ invertSelection: '反选',
252
+ usageTitle: '官方额度',
253
+ usageFiveHour: '5 小时',
254
+ usageWeekly: '本周',
255
+ usageMonthly: '本月',
256
+ usageResetsIn: '{t}后重置',
257
+ usageDays: '{n} 天',
258
+ usageHours: '{n} 小时',
259
+ usageMinutes: '{n} 分钟',
260
+ usageRefresh: '刷新',
261
+ usageLoading: '正在读取官方额度…',
262
+ usageUnavailable: '暂时读不到官方额度。',
263
+ usageEmpty: '官方未返回额度窗口。',
264
+ usageAccount: '账号 {name}',
265
+ historyWhen: '时间',
266
+ historyModel: '模型',
267
+ historyUpstream: '上游',
268
+ historyLoad: '延迟',
269
+ historyTokens: 'TOKEN',
270
+ historyFirstChunk: '首字',
271
+ historyTotal: '总耗时',
272
+ historyRate: '输出速率',
273
+ historyTokensHint: '↓输入 ↑输出 ⚡缓存 🧠推理;— 表示网关本次未返回用量',
274
+ historyEffort: '推理强度',
275
+ historyAuto: '(自动)',
101
276
  noChannels: '未探测渠道',
102
277
  pinned: '已钉住',
103
278
  auto: '自动路由',
104
- autoSetup: '一键配置',
105
- autoSetupHint: '探测 → 校验全部渠道 → 钉住可用渠道 → 真实请求验证',
106
279
  probe: '探测',
107
280
  validate: '校验',
108
281
  testPin: '测试',
@@ -112,15 +285,15 @@ window.__ModuleLoader__.load({
112
285
  history: '最近请求',
113
286
  noHistory: '暂无记录。',
114
287
  busy: '处理中…',
115
- notReady: '先配置 API Key。',
116
288
  settingsUnavailable: '设置服务不可用,改动无法保存。',
117
- panelUnavailable: '浏览器通道不可用。',
118
289
  retry: '重试',
119
- autoDone: '已钉住 {n} 个渠道。',
120
- autoKept: '无可用渠道,保持自动路由。',
121
290
  }
122
291
  const EN = {
123
292
  title: 'Cline Pass',
293
+ description: 'Channel pins and the account pool for the subscription models',
294
+ panelUnavailable: 'The browser channel is unavailable.',
295
+ expand: 'Show settings',
296
+ collapse: 'Hide settings',
124
297
  keyOk: 'API key configured',
125
298
  keyMissing: 'No API key',
126
299
  keyValue: 'API key',
@@ -130,6 +303,18 @@ window.__ModuleLoader__.load({
130
303
  test: 'Test',
131
304
  remove: 'Remove',
132
305
  accounts: 'Accounts',
306
+ accountsCount: '{n} account(s)',
307
+ accountsActive: 'active',
308
+ accountsSetActive: 'Use',
309
+ accountsKey: 'Key',
310
+ accountsActions: 'Actions',
311
+ accountsEnabled: 'Enabled',
312
+ accountsOff: 'Disabled',
313
+ accountsPoolHint: 'Single: prefers the "current" account; round-robin: each request uses the next enabled account. When one is out of quota or refused, the pool switches to another and stands the failed one down for a while.',
314
+ accountsFirstRun: 'Quota, subscription models and request history appear once a key is stored.',
315
+ accountsFor: 'Store a key for "{name}"',
316
+ accountsFirst: '(first enabled)',
317
+ keyNone: 'not set',
133
318
  accountMode: 'Mode',
134
319
  modeSingle: 'Single',
135
320
  modeRoundrobin: 'Round robin',
@@ -138,11 +323,38 @@ window.__ModuleLoader__.load({
138
323
  accountName: 'Name',
139
324
  models: 'Models',
140
325
  modelsCount: '{n} model(s)',
326
+ hiddenModels: '{n} hidden from the pickers',
327
+ visible: 'Show in the model pickers',
328
+ hiddenTag: 'Hidden',
329
+ selectAll: 'Check all',
330
+ invertSelection: 'Invert',
331
+ usageTitle: 'Official usage limits',
332
+ usageFiveHour: '5-hour',
333
+ usageWeekly: 'Weekly',
334
+ usageMonthly: 'Monthly',
335
+ usageResetsIn: 'resets in {t}',
336
+ usageDays: '{n}d',
337
+ usageHours: '{n}h',
338
+ usageMinutes: '{n}m',
339
+ usageRefresh: 'Refresh',
340
+ usageLoading: 'Reading the official quota…',
341
+ usageUnavailable: 'The official quota is unavailable right now.',
342
+ usageEmpty: 'The gateway reported no quota windows.',
343
+ usageAccount: 'account {name}',
344
+ historyWhen: 'Time',
345
+ historyModel: 'Model',
346
+ historyUpstream: 'Upstream',
347
+ historyLoad: 'Latency',
348
+ historyTokens: 'Tokens',
349
+ historyFirstChunk: 'first',
350
+ historyTotal: 'total',
351
+ historyRate: 'rate',
352
+ historyTokensHint: '↓in ↑out ⚡cache 🧠reasoning; a dash means the gateway reported no usage',
353
+ historyEffort: 'effort',
354
+ historyAuto: '(auto)',
141
355
  noChannels: 'No channels probed',
142
356
  pinned: 'Pinned',
143
357
  auto: 'Auto',
144
- autoSetup: 'Auto-configure',
145
- autoSetupHint: 'probe → validate every channel → pin the healthy ones → verify with a real request',
146
358
  probe: 'Probe',
147
359
  validate: 'Validate',
148
360
  testPin: 'Test',
@@ -152,12 +364,8 @@ window.__ModuleLoader__.load({
152
364
  history: 'Recent requests',
153
365
  noHistory: 'Nothing recorded.',
154
366
  busy: 'Working…',
155
- notReady: 'Configure the API key first.',
156
367
  settingsUnavailable: 'Settings are read-only; changes will not persist.',
157
- panelUnavailable: 'The browser channel is unavailable.',
158
368
  retry: 'Retry',
159
- autoDone: 'Pinned {n} channel(s).',
160
- autoKept: 'No usable channel; kept automatic routing.',
161
369
  }
162
370
 
163
371
  //#endregion
@@ -197,11 +405,18 @@ window.__ModuleLoader__.load({
197
405
  return n >= 1000 ? (n / 1000).toFixed(1) + 's' : Math.round(n) + 'ms'
198
406
  }
199
407
 
200
- /** Format a timestamp as local time, or '-' when absent. */
201
- function time(value) {
202
- const n = Number(value ?? 0)
203
- if (!Number.isFinite(n) || n <= 0) return '-'
204
- try { return new Date(n).toLocaleTimeString() } catch { return '-' }
408
+ /**
409
+ * A timestamp as a history row reads it: clock time for today, and the date
410
+ * in front of it once an entry is no longer from today — a long-lived host
411
+ * process would otherwise show times that look like they were all this hour.
412
+ */
413
+ function stamp(value) {
414
+ const at = new Date(Number(value ?? 0))
415
+ if (!Number.isFinite(at.getTime())) return '-'
416
+ const pad = (part) => String(part).padStart(2, '0')
417
+ const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
418
+ if (at.toDateString() === new Date().toDateString()) return clock
419
+ return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${clock.slice(0, 5)}`
205
420
  }
206
421
 
207
422
  /** Colour a channel verdict. */
@@ -217,6 +432,145 @@ window.__ModuleLoader__.load({
217
432
  return String(template).replace(/\{(\w+)\}/g, (whole, key) => (params?.[key] === undefined ? whole : String(params[key])))
218
433
  }
219
434
 
435
+ /**
436
+ * The three-step tone ramp a quota bar uses.
437
+ *
438
+ * A percentage is only alarming near the cap, so the ramp keeps the ordinary
439
+ * reading green and reserves colour for the two states worth reacting to.
440
+ */
441
+ function usageColor(percent) {
442
+ if (percent >= 90) return 'var(--dsw-alias-state-error-primary)'
443
+ if (percent >= 70) return 'var(--dsw-alias-state-warn-primary)'
444
+ return 'var(--dsw-alias-state-success-primary)'
445
+ }
446
+
447
+
448
+ /**
449
+ * One token count, bounded in width for the request table.
450
+ *
451
+ * Exact with separators while that still fits a narrow column, then one
452
+ * decimal of k/M — the convention the reference request log uses too
453
+ * ("4,239" beside "205.6K"). Every field is therefore at most six characters,
454
+ * so four of them plus their markers cannot push a row taller than the three
455
+ * latency lines already do. The exact digits stay in the tooltip.
456
+ */
457
+ function formatTokenCount(value) {
458
+ const n = Math.max(0, Number(value ?? 0))
459
+ if (n < 100_000) return n.toLocaleString('en-US')
460
+ if (n < 1_000_000) {
461
+ const k = (n / 1000).toFixed(1)
462
+ // 999,999 rounds to "1000.0k" — a value the next unit already names.
463
+ return Number(k) >= 1000 ? `${(n / 1_000_000).toFixed(1)}M` : `${k}k`
464
+ }
465
+ return `${(n / 1_000_000).toFixed(1)}M`
466
+ }
467
+
468
+ /**
469
+ * The gateway states money in MICRO-USD, not dollars.
470
+ *
471
+ * Discovered by cross-check: the five-hour reading summed to 83,653,988 while
472
+ * the plan's cap for the same window is 1,000,000,000 and the official
473
+ * percentage was 8% — and 83.6M/1e9 = 8.4%. The weekly (288.8M / 2.5e9 =
474
+ * 11.6% vs 11%) and monthly (288.8M / 5e9 = 5.8% vs 5%) readings agree, so
475
+ * both the per-row `costUsd` and the caps are 1e-6 USD: $83.65 against a
476
+ * $1000 window. The division happens here, at the one place money becomes
477
+ * text, which leaves the transport a faithful copy of what was sent.
478
+ */
479
+ const MICRO_USD = 1_000_000
480
+
481
+
482
+ /**
483
+ * A USD figure, already in dollars.
484
+ *
485
+ * A nonzero amount under a cent is shown as `<$0.01` rather than rounded to
486
+ * zero: "no usage" and "a sliver of usage" are different answers.
487
+ */
488
+ function formatUsd(value) {
489
+ const n = Number(value ?? 0)
490
+ if (!Number.isFinite(n) || n <= 0) return '$0.00'
491
+ if (n < 0.01) return '<$0.01'
492
+ if (n < 1000) return `$${n.toFixed(2)}`
493
+ // A cap is read as a figure, so thousands get separators rather than a "k"
494
+ // suffix that hides the digits making it recognizable.
495
+ return `$${Math.round(n).toLocaleString('en-US')}`
496
+ }
497
+
498
+ /**
499
+ * A request's three latency figures, the way a request log reads them.
500
+ *
501
+ * - `first`: what the caller waited before anything appeared.
502
+ * - `total`: what the whole call cost.
503
+ * - `rate`: output tokens per second AFTER the first chunk — the speed of the
504
+ * answer itself. Dividing the total instead would fold the model's thinking
505
+ * into a "speed" and report a slow answer for a fast one that thought long.
506
+ *
507
+ * A moment that never came is a dash, not a zero: zero would read as "instant".
508
+ */
509
+ function latency(entry) {
510
+ const stamp = (value) => (Number(value ?? 0) > 0 ? ms(value) : '—')
511
+ const first = Number(entry.ttft ?? 0)
512
+ const total = Number(entry.ms ?? 0)
513
+ const out = entry.usageReported === true ? Number(entry.usage?.outputTokens ?? 0) : 0
514
+ const streaming = total - first
515
+ const rate = first > 0 && out > 0 && streaming > 0
516
+ ? `${(out / (streaming / 1000)).toFixed(1)} t/s`
517
+ : '—'
518
+ return { first: stamp(first), total: stamp(total), rate }
519
+ }
520
+
521
+ /**
522
+ * The four token counts of a request, or `null` when the gateway sent no
523
+ * usage frame — which is not the same as a call that cost nothing.
524
+ *
525
+ * Returned as parts rather than a string so the same numbers can be rendered
526
+ * two ways: bounded for the narrow cell, exact for its tooltip.
527
+ */
528
+ function tokenParts(entry) {
529
+ if (entry.usageReported !== true) return null
530
+ const usage = entry.usage
531
+ const parts = [
532
+ { key: 'in', icon: '↓', value: Number(usage.inputTokens ?? 0) },
533
+ { key: 'out', icon: '↑', value: Number(usage.outputTokens ?? 0) },
534
+ ]
535
+ if (Number(usage.cacheReadTokens ?? 0) > 0) parts.push({ key: 'cache', icon: '⚡', value: Number(usage.cacheReadTokens) })
536
+ if (Number(usage.reasoningTokens ?? 0) > 0) parts.push({ key: 'think', icon: '🧠', value: Number(usage.reasoningTokens) })
537
+ return parts
538
+ }
539
+
540
+ /** The cell's rendering: width-bounded, so no count can reshape the row. */
541
+ function tokenSummary(entry) {
542
+ const parts = tokenParts(entry)
543
+ if (parts === null) return '—'
544
+ return parts.map((part) => `${part.icon}${formatTokenCount(part.value)}`).join(' ')
545
+ }
546
+
547
+ /** The tooltip's rendering: every digit, so nothing is only approximately known. */
548
+ function tokenSummaryExact(entry) {
549
+ const parts = tokenParts(entry)
550
+ if (parts === null) return ''
551
+ return parts.map((part) => `${part.icon}${part.value.toLocaleString('en-US')}`).join(' ')
552
+ }
553
+
554
+ /**
555
+ * A coarse "3 天 4 小时" from an ISO instant.
556
+ *
557
+ * Coarse on purpose: a quota reset is read at a glance, and seconds of
558
+ * precision would only make the panel re-render for nothing.
559
+ * @returns the duration, or '' when the instant is absent or already past.
560
+ */
561
+ function countdown(t, iso) {
562
+ const at = Date.parse(String(iso ?? ''))
563
+ if (!Number.isFinite(at)) return ''
564
+ const minutes = Math.round((at - Date.now()) / 60000)
565
+ if (minutes <= 0) return ''
566
+ const days = Math.floor(minutes / 1440)
567
+ const hours = Math.floor((minutes % 1440) / 60)
568
+ const mins = minutes % 60
569
+ if (days > 0) return `${fill(t('usageDays'), { n: days })} ${fill(t('usageHours'), { n: hours })}`
570
+ if (hours > 0) return `${fill(t('usageHours'), { n: hours })} ${fill(t('usageMinutes'), { n: mins })}`
571
+ return fill(t('usageMinutes'), { n: Math.max(1, mins) })
572
+ }
573
+
220
574
  //#endregion
221
575
 
222
576
  //#region controller
@@ -237,6 +591,13 @@ window.__ModuleLoader__.load({
237
591
  this.t = this.makeTranslate()
238
592
  }
239
593
 
594
+ /**
595
+ * Load the in-process request history.
596
+ *
597
+ * The payload is returned rather than advertised through `action`: `run`
598
+ * publishes its own result there, so a second publish only wrote a shape
599
+ * nothing read. The caller stores what it gets back.
600
+ */
240
601
  /**
241
602
  * Bind this panel's copy to the active locale.
242
603
  *
@@ -280,13 +641,16 @@ window.__ModuleLoader__.load({
280
641
  addAccount: (payload) => controller.addAccount(payload),
281
642
  removeAccount: (name) => controller.removeAccount(name),
282
643
  setAccountMode: (patch) => controller.setAccountMode(patch),
644
+ setAccountEnabled: (name, enabled) => controller.setAccountEnabled(name, enabled),
283
645
  pinModel: (payload) => controller.pinModel(payload),
284
- setupModel: (model) => controller.setupModel(model),
646
+ setModelVisible: (model, visible) => controller.setModelVisible(model, visible),
647
+ setModelsVisibility: (mode) => controller.setModelsVisibility(mode),
285
648
  probeModel: (model) => controller.probeModel(model),
286
649
  validateModel: (model) => controller.validateModel(model),
287
650
  testModel: (model) => controller.testModel(model),
288
651
  resetModel: (model) => controller.resetModel(model),
289
652
  refreshModels: () => controller.refreshModels(),
653
+ loadUsage: (explicit) => controller.loadUsage(explicit),
290
654
  loadHistory: (limit) => controller.loadHistory(limit),
291
655
  clearNotice: () => controller.clearNotice(),
292
656
  }
@@ -337,6 +701,30 @@ window.__ModuleLoader__.load({
337
701
  }
338
702
  }
339
703
 
704
+ /**
705
+ * Run one automatic read: the busy flag, and nothing else.
706
+ *
707
+ * These run on mount, unprompted, and they fail for reasons the user
708
+ * cannot act on (a stale host, a flaky gateway, a missing endpoint). The
709
+ * card's own "no reading yet" state already says what is known, and their
710
+ * retry buttons are right there, so a panel-level warning on every open
711
+ * would be noise the user cannot clear. A user-triggered read goes through
712
+ * {@link run} instead, where the banner IS the answer they asked for.
713
+ */
714
+ async readQuietly(label, action) {
715
+ this.publish({ busy: label, notice: null })
716
+ try {
717
+ const value = await action()
718
+ this.publish({ busy: null, error: null })
719
+ return value
720
+ } catch (error) {
721
+ // Visible only to whoever is debugging the panel itself.
722
+ console.warn('[cline-pass] automatic read failed:', String(error?.message ?? error))
723
+ this.publish({ busy: null })
724
+ return undefined
725
+ }
726
+ }
727
+
340
728
  /** Merge a fresh host state into the snapshot. */
341
729
  applyState(value) {
342
730
  const data = value?.accounts === undefined && value?.provider === undefined ? this.store.getSnapshot().data : value
@@ -399,6 +787,13 @@ window.__ModuleLoader__.load({
399
787
  if (result !== undefined) this.applyState(result)
400
788
  }
401
789
 
790
+ /** Take one account in or out of the pool. */
791
+ async setAccountEnabled(name, enabled) {
792
+ const result = await this.run('account', async () => await this.call('account.enable', { name, enabled }))
793
+ if (result !== undefined) this.applyState(result)
794
+ return result
795
+ }
796
+
402
797
  /** Switch account mode or the active account. */
403
798
  async setAccountMode(patch) {
404
799
  const result = await this.run('account', async () => await this.call('account.mode', patch))
@@ -412,6 +807,29 @@ window.__ModuleLoader__.load({
412
807
  return result
413
808
  }
414
809
 
810
+ /**
811
+ * Show or hide one model in every picker this route feeds.
812
+ *
813
+ * Hiding never touches the subscription list, so the checkbox in the panel
814
+ * is the whole undo.
815
+ */
816
+ async setModelVisible(model, visible) {
817
+ const result = await this.run('visibility', async () => await this.call('model.visibility', { model, visible }))
818
+ if (result !== undefined) this.applyState(result)
819
+ return result
820
+ }
821
+
822
+ /**
823
+ * Show, hide or swap every subscription model at once.
824
+ *
825
+ * One write for the whole set, so a bulk change cannot half-apply.
826
+ */
827
+ async setModelsVisibility(mode) {
828
+ const result = await this.run('visibility', async () => await this.call('models.visibility', { mode }))
829
+ if (result !== undefined) this.applyState(result)
830
+ return result
831
+ }
832
+
415
833
  /** Probe one model and surface the discovered channels. */
416
834
  async probeModel(model) {
417
835
  const result = await this.run('probe', async () => await this.call('model.probe', { model }))
@@ -459,32 +877,15 @@ window.__ModuleLoader__.load({
459
877
  return result
460
878
  }
461
879
 
462
- /** The one-click path for one model. */
463
- async setupModel(model) {
464
- this.publish({ busy: 'setup:' + model, notice: null, error: null })
465
- try {
466
- const result = await this.call('setup.auto', { model })
467
- const pinned = (result?.pinned ?? []).length
468
- this.publish({
469
- busy: null,
470
- action: { kind: 'setup', model, data: result },
471
- notice: result?.ok === true
472
- ? { kind: 'ok', text: fill(this.t('autoDone'), { n: pinned }) + (result.actual ? ` (${result.actual})` : '') }
473
- : { kind: 'warn', text: `${model} · ${result?.error || this.t('autoKept')}` },
474
- })
475
- return result
476
- } catch (error) {
477
- this.publish({ busy: null, error: String(error?.message ?? error) })
478
- return undefined
479
- } finally {
480
- await this.refresh()
481
- }
482
- }
483
-
484
- /** Return one model to automatic routing. */
485
- async resetModel(model) {
486
- const result = await this.pinModel({ model, upstreams: [], exclude: [], sort: 'none' })
487
- if (result !== undefined) this.publish({ notice: { kind: 'ok', text: `${model} · ${this.t('auto')}` } })
880
+ /**
881
+ * Read the official quota. Quiet by default: the card asks on mount, and a
882
+ * gateway that cannot answer leaves the last good reading in place rather
883
+ * than turning the section into an error.
884
+ */
885
+ async loadUsage(explicit = false) {
886
+ const call = async () => await this.call('usage', {})
887
+ const result = explicit === true ? await this.run('usage', call) : await this.readQuietly('usage', call)
888
+ if (result !== undefined) this.applyState(result)
488
889
  return result
489
890
  }
490
891
 
@@ -498,13 +899,15 @@ window.__ModuleLoader__.load({
498
899
  return result
499
900
  }
500
901
 
501
- /** Load the in-process request history. */
902
+ /** Drop every pin for one model, returning it to automatic routing. */
903
+ async resetModel(model) {
904
+ const result = await this.pinModel({ model, upstreams: [], exclude: [], sort: 'none' })
905
+ if (result !== undefined) this.publish({ notice: { kind: 'ok', text: `${model} · ${this.t('auto')}` } })
906
+ return result
907
+ }
908
+
502
909
  async loadHistory(limit) {
503
- return await this.run('history', async () => {
504
- const result = await this.call('history', { limit: limit ?? 25 })
505
- this.publish({ action: { kind: 'history', data: result } })
506
- return result
507
- })
910
+ return await this.run('history', async () => await this.call('history', { limit: limit ?? 25 }))
508
911
  }
509
912
  }
510
913
 
@@ -524,44 +927,77 @@ window.__ModuleLoader__.load({
524
927
  return h('span', { className: 'cp-dot', style: { background: props.color }, title: props.title })
525
928
  }
526
929
 
527
- /** The API-key field, shared by the panel and the Models card. */
930
+ /**
931
+ * The disclosure chevron, drawn with whichever host icon this release
932
+ * publishes.
933
+ *
934
+ * The host 0.1.2–0.1.5 line exports `IconChevronDownOutline14`; 0.1.6+
935
+ * exports the artwork / regular / medium triple. When neither is present —
936
+ * an unrecognised host, or a seed table that omits the module — an inline
937
+ * SVG keeps the affordance instead of letting `h(undefined)` throw.
938
+ */
939
+ function Chevron(props) {
940
+ if (HostChevron !== null) return h(HostChevron, props)
941
+ return h('svg', {
942
+ className: props.className,
943
+ width: 14,
944
+ height: 14,
945
+ viewBox: '0 0 16 16',
946
+ fill: 'none',
947
+ 'aria-hidden': 'true',
948
+ focusable: 'false',
949
+ }, h('path', {
950
+ d: 'M4 6.5 8 10.5 12 6.5',
951
+ stroke: 'currentColor',
952
+ strokeWidth: 1.4,
953
+ strokeLinecap: 'round',
954
+ strokeLinejoin: 'round',
955
+ }))
956
+ }
957
+
958
+ /**
959
+ * The API-key field, shared by the panel and the Models card.
960
+ *
961
+ * `inline` drops the card wrapper for callers that already sit inside one —
962
+ * the accounts card draws the field as the last row of its own table.
963
+ */
528
964
  function KeyField(props) {
529
- const { t, account, disabled, onSave, onTest, onSaveTest, busy } = props
965
+ const { t, account, disabled, onSave, onTest, onSaveTest, busy, inline } = props
530
966
  const [value, setValue] = React.useState('')
531
967
  const entered = value.trim().length > 0
532
- return h('div', { className: 'cp-card' }, [
533
- h('div', { key: 'row', className: 'cp-row' }, [
534
- h('span', { key: 'l', className: 'cp-muted' }, t('keyValue')),
535
- h('input', {
536
- key: 'input',
537
- className: 'cp-input cp-grow',
538
- type: 'password',
539
- autoComplete: 'off',
540
- spellCheck: false,
541
- value,
542
- placeholder: t('keyPlaceholder'),
543
- 'aria-label': t('keyValue'),
544
- disabled,
545
- onChange: (event) => setValue(event.target.value),
546
- }),
547
- h('button', {
548
- key: 'save', type: 'button', className: 'cp-btn cp-btn-primary',
549
- disabled: disabled || !entered,
550
- onClick: () => { onSaveTest(value.trim()); setValue('') },
551
- }, t('saveTest')),
552
- h('button', {
553
- key: 'test', type: 'button', className: 'cp-btn',
554
- disabled: disabled || !entered,
555
- onClick: () => onTest(value.trim()),
556
- }, t('test')),
557
- h('button', {
558
- key: 'saveOnly', type: 'button', className: 'cp-btn',
559
- disabled: disabled || !entered,
560
- onClick: () => { onSave(value.trim()); setValue('') },
561
- }, t('save')),
562
- busy ? h('span', { key: 'b', className: 'cp-muted' }, t('busy')) : null,
563
- ]),
968
+ const row = h('div', { key: 'row', className: 'cp-row' }, [
969
+ h('input', {
970
+ key: 'input',
971
+ className: 'cp-input cp-grow',
972
+ type: 'password',
973
+ autoComplete: 'off',
974
+ spellCheck: false,
975
+ value,
976
+ placeholder: t('keyPlaceholder'),
977
+ 'aria-label': t('keyValue'),
978
+ disabled,
979
+ onChange: (event) => setValue(event.target.value),
980
+ }),
981
+ // Least to most committal: check the value, store it as-is, or store it
982
+ // only once a real request has proved it works.
983
+ h('button', {
984
+ key: 'test', type: 'button', className: 'cp-btn',
985
+ disabled: disabled || !entered,
986
+ onClick: () => onTest(value.trim()),
987
+ }, t('test')),
988
+ h('button', {
989
+ key: 'saveOnly', type: 'button', className: 'cp-btn',
990
+ disabled: disabled || !entered,
991
+ onClick: () => { onSave(value.trim()); setValue('') },
992
+ }, t('save')),
993
+ h('button', {
994
+ key: 'save', type: 'button', className: 'cp-btn cp-btn-primary',
995
+ disabled: disabled || !entered,
996
+ onClick: () => { onSaveTest(value.trim()); setValue('') },
997
+ }, t('saveTest')),
998
+ busy ? h('span', { key: 'b', className: 'cp-muted' }, t('busy')) : null,
564
999
  ])
1000
+ return inline === true ? row : h('div', { className: 'cp-card' }, [row])
565
1001
  }
566
1002
 
567
1003
  /** The channel chooser for one model. */
@@ -612,26 +1048,40 @@ window.__ModuleLoader__.load({
612
1048
  /**
613
1049
  * One model row.
614
1050
  *
615
- * The default view is deliberately just identity plus the one action that
616
- * matters, so a page of models stays readable; the finer pin actions and the
617
- * channel list live behind the expander.
1051
+ * The default view is deliberately just identity, the checkbox that decides
1052
+ * whether any picker offers this model, and the one action that matters. The
1053
+ * finer pin actions and the channel list live behind the expander.
1054
+ *
1055
+ * The expander holds the three steps that are not the one-click path:
1056
+ * discovering channels (probe), verifying what actually serves the stored pin
1057
+ * (test), and dropping it (reset). Validating every channel is what
1058
+ * auto-configure already does before it pins, so it is not a separate button
1059
+ * here — running it alone costs one real request per channel to re-learn
1060
+ * verdicts the same click would have produced.
618
1061
  */
619
1062
  function ModelRow(props) {
620
- const { t, model, disabled, busy, actions } = props
1063
+ const { t, model, disabled, actions } = props
621
1064
  const [open, setOpen] = React.useState(false)
622
- const working = busy === 'setup:' + model.id
1065
+ const hidden = model.hidden === true
623
1066
  const pinnedLabel = model.pinned.length === 0 ? t('auto') : model.pinned.join(' › ')
624
1067
  const action = (key, label, run) => h('button', {
625
1068
  key, type: 'button', className: 'cp-btn', disabled, onClick: () => run(model.id),
626
1069
  }, label)
627
- return h('div', { className: 'cp-model' }, [
1070
+ return h('div', { className: `cp-model${hidden ? ' cp-model-hidden' : ''}` }, [
628
1071
  h('div', { key: 'head', className: 'cp-row' }, [
1072
+ h('input', {
1073
+ key: 'vis',
1074
+ type: 'checkbox',
1075
+ className: 'cp-check',
1076
+ checked: !hidden,
1077
+ disabled,
1078
+ title: t('visible'),
1079
+ 'aria-label': `${t('visible')}: ${model.id}`,
1080
+ onChange: (event) => actions.setModelVisible(model.id, event.target.checked),
1081
+ }),
629
1082
  h('span', { key: 'name', className: 'cp-mono cp-grow' }, model.id),
1083
+ hidden ? h('span', { key: 'off', className: 'cp-muted' }, t('hiddenTag')) : null,
630
1084
  h('span', { key: 'pin', className: 'cp-muted cp-mono' }, `${t('pinned')}: ${pinnedLabel}`),
631
- h('button', {
632
- key: 'auto', type: 'button', className: 'cp-btn cp-btn-primary',
633
- disabled, title: t('autoSetupHint'), onClick: () => actions.setupModel(model.id),
634
- }, working ? t('busy') : t('autoSetup')),
635
1085
  h('button', {
636
1086
  key: 'more', type: 'button', className: 'cp-btn', title: t('channels'),
637
1087
  onClick: () => setOpen(!open),
@@ -640,7 +1090,6 @@ window.__ModuleLoader__.load({
640
1090
  open ? h('div', { key: 'detail', className: 'cp-panel' }, [
641
1091
  h('div', { key: 'actions', className: 'cp-row' }, [
642
1092
  action('probe', t('probe'), actions.probeModel),
643
- action('validate', t('validate'), actions.validateModel),
644
1093
  action('test', t('testPin'), actions.testModel),
645
1094
  action('reset', t('reset'), actions.resetModel),
646
1095
  ]),
@@ -651,63 +1100,166 @@ window.__ModuleLoader__.load({
651
1100
  ])
652
1101
  }
653
1102
 
654
- /** The accounts card. */
1103
+ /**
1104
+ * One account row.
1105
+ *
1106
+ * Identity stacks name over credential reference: the reference is what a
1107
+ * user needs when the key is missing ("store it at …") and what tells two
1108
+ * accounts apart, but it is never the first thing being read.
1109
+ */
1110
+ function AccountRow(props) {
1111
+ const { t, account, disabled, active, pooled, removable, actions } = props
1112
+ const off = account.enabled === false
1113
+ return h('tr', { className: `cp-acct-row${off ? ' cp-acct-row-off' : ''}` }, [
1114
+ h('td', { key: 'state' }, h('span', { className: 'cp-acct-state' }, [
1115
+ // The checkbox is what decides whether the router may use this account;
1116
+ // the dot beside it still says whether its key is stored.
1117
+ h('input', {
1118
+ key: 'on',
1119
+ type: 'checkbox',
1120
+ className: 'cp-check',
1121
+ checked: !off,
1122
+ disabled,
1123
+ title: t('accountsEnabled'),
1124
+ 'aria-label': `${t('accountsEnabled')}: ${account.key}`,
1125
+ onChange: (event) => actions.setAccountEnabled(account.key, event.target.checked),
1126
+ }),
1127
+ h(Dot, {
1128
+ key: 'dot',
1129
+ color: account.keyConfigured ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)',
1130
+ title: account.keyConfigured ? t('keyOk') : t('keyMissing'),
1131
+ }),
1132
+ ])),
1133
+ h('td', { key: 'id' }, h('span', { className: 'cp-acct-id' }, [
1134
+ h('span', { key: 'name', className: 'cp-acct-name' }, [
1135
+ h('span', { key: 'text' }, account.displayName),
1136
+ off ? h('span', { key: 'off', className: 'cp-tag' }, t('accountsOff')) : null,
1137
+ active ? h('span', { key: 'tag', className: 'cp-tag' }, t('accountsActive')) : null,
1138
+ ]),
1139
+ h('span', { key: 'ref', className: 'cp-acct-ref', title: account.apiKeyEnv }, account.apiKeyEnv),
1140
+ ])),
1141
+ h('td', { key: 'hint', className: 'cp-acct-hint', title: account.keyHint }, account.keyConfigured ? account.keyHint : t('keyNone')),
1142
+ h('td', { key: 'actions' }, h('span', { className: 'cp-acct-actions' }, [
1143
+ h('button', {
1144
+ key: 'test', type: 'button', className: 'cp-btn', disabled,
1145
+ onClick: () => actions.testKey(account.apiKeyEnv, ''),
1146
+ }, t('test')),
1147
+ active || !pooled ? null : h('button', {
1148
+ key: 'use', type: 'button', className: 'cp-btn',
1149
+ onClick: () => actions.setAccountMode({ active: account.key }),
1150
+ }, t('accountsSetActive')),
1151
+ h('button', {
1152
+ key: 'remove', type: 'button', className: 'cp-btn cp-btn-danger',
1153
+ // Only an explicit account can be removed: a lone implicit account is
1154
+ // the top-level key and its `apiKeyEnv`, so there is no entry to
1155
+ // delete. Gating on the pool size instead left this dead for the
1156
+ // common single-account case.
1157
+ disabled: disabled || !removable,
1158
+ onClick: () => actions.removeAccount(account.key),
1159
+ }, t('remove')),
1160
+ ])),
1161
+ ])
1162
+ }
1163
+
1164
+ /**
1165
+ * The accounts card.
1166
+ *
1167
+ * One table: the key field belongs to the account a request would actually
1168
+ * use, instead of to whichever account happened to be first, and the pool
1169
+ * controls sit in the section body where they apply to the whole list.
1170
+ *
1171
+ * It folds like every other section, with one exception: before an account is
1172
+ * usable this is the only thing left to act on, so it opens itself. A card
1173
+ * whose every section is folded and empty is a dead end.
1174
+ */
655
1175
  function AccountsCard(props) {
656
- const { t, data, disabled, actions } = props
1176
+ const { t, data, disabled, ready, actions } = props
657
1177
  const [name, setName] = React.useState('')
658
1178
  const [key, setKey] = React.useState('')
659
- const primary = data.accounts[0]
660
- return h('div', { className: 'cp-card' }, [
661
- h('div', { key: 'title', className: 'cp-card-title' }, [h('span', { key: 'l' }, t('accounts'))]),
662
- primary !== undefined
663
- ? h(KeyField, {
664
- key: 'key',
665
- t,
666
- account: primary,
667
- disabled,
668
- busy: false,
669
- onSave: (value) => actions.setKey(primary.apiKeyEnv, value),
670
- onTest: (value) => actions.testKey(primary.apiKeyEnv, value),
671
- onSaveTest: (value) => actions.saveAndTest(primary.apiKeyEnv, value),
672
- })
673
- : null,
674
- h('div', { key: 'list', className: 'cp-table' }, data.accounts.map((account) => h('div', { key: account.key, className: 'cp-account' }, [
675
- h('div', { key: 'row', className: 'cp-row' }, [
676
- h(Dot, { key: 'd', color: account.keyConfigured ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)', title: account.keyConfigured ? t('keyOk') : t('keyMissing') }),
677
- h('span', { key: 'n', className: 'cp-grow' }, account.displayName),
678
- h('span', { key: 'k', className: 'cp-muted cp-mono' }, account.keyConfigured ? account.keyHint : '—'),
679
- h('button', {
680
- key: 't', type: 'button', className: 'cp-btn', disabled,
681
- onClick: () => actions.testKey(account.apiKeyEnv, ''),
682
- }, t('test')),
683
- h('button', {
684
- key: 'x', type: 'button', className: 'cp-btn cp-btn-danger',
685
- disabled: disabled || data.accounts.length <= 1,
686
- onClick: () => actions.removeAccount(account.key),
687
- }, t('remove')),
688
- ]),
689
- ]))),
1179
+ const [open, setOpen] = React.useState(ready !== true)
1180
+ const pooled = data.accounts.length > 1
1181
+ const single = data.accountMode !== 'roundrobin'
1182
+ const fallback = data.accounts.find((account) => account.enabled)?.key ?? ''
1183
+ const activeKey = data.activeAccount === '' ? fallback : data.activeAccount
1184
+ // The key field follows the account requests will actually use.
1185
+ const target = data.accounts.find((account) => account.key === activeKey) ?? data.accounts[0]
1186
+ // A row can be deleted only when an explicit `accounts` entry stands behind
1187
+ // it; a lone implicit account is the top-level key, so deleting it would
1188
+ // mean deleting the route's own configuration.
1189
+ const removable = (account) => account.declared !== false
1190
+ return h('details', {
1191
+ className: 'cp-card',
1192
+ open,
1193
+ onToggle: (event) => setOpen(event.currentTarget.open),
1194
+ }, [
1195
+ foldHeader(`${t('accounts')} · ${fill(t('accountsCount'), { n: data.accounts.length })}`),
1196
+ // Everything else in this card is downstream of a stored key, so say so
1197
+ // here rather than leaving an unconfigured panel looking broken.
1198
+ ready === true ? null : h('div', { key: 'firstrun', className: 'cp-muted' }, t('accountsFirstRun')),
690
1199
  // A pool of one account needs none of this; it appears with the second.
691
- data.accounts.length <= 1 ? null : h('div', { key: 'mode', className: 'cp-row' }, [
692
- h('span', { key: 'l', className: 'cp-muted' }, t('accountMode')),
1200
+ pooled ? h('div', { key: 'mode', className: 'cp-row' }, [
1201
+ h('span', { key: 'ml', className: 'cp-muted' }, t('accountMode')),
693
1202
  h('select', {
694
- key: 's', className: 'cp-select', value: data.accountMode, disabled,
1203
+ key: 'ms', className: 'cp-select', value: data.accountMode, disabled,
1204
+ 'aria-label': t('accountMode'),
695
1205
  onChange: (event) => actions.setAccountMode({ mode: event.target.value }),
696
1206
  }, [
697
1207
  h('option', { key: 'single', value: 'single' }, t('modeSingle')),
698
1208
  h('option', { key: 'rr', value: 'roundrobin' }, t('modeRoundrobin')),
699
1209
  ]),
700
- h('span', { key: 'al', className: 'cp-muted' }, t('active')),
701
- h('select', {
1210
+ single ? h('span', { key: 'al', className: 'cp-muted' }, t('active')) : null,
1211
+ single ? h('select', {
702
1212
  key: 'as', className: 'cp-select', value: data.activeAccount, disabled,
1213
+ 'aria-label': t('active'),
703
1214
  onChange: (event) => actions.setAccountMode({ active: event.target.value }),
704
- }, [h('option', { key: '', value: '' }, '(first enabled)')].concat(
1215
+ }, [h('option', { key: '', value: '' }, t('accountsFirst'))].concat(
705
1216
  data.accounts.map((account) => h('option', { key: account.key, value: account.key }, account.key)),
706
- )),
1217
+ )) : null,
1218
+ // How the pool picks an account is not visible from the row, so it is
1219
+ // written down rather than left to be inferred from the mode name.
1220
+ h('div', { key: 'hint', className: 'cp-muted' }, t('accountsPoolHint')),
1221
+ ]) : null,
1222
+ h('table', { key: 'table', className: 'cp-acct-table' }, [
1223
+ h('colgroup', { key: 'cols' }, [
1224
+ h('col', { key: 'dot', className: 'cp-acct-col-dot' }),
1225
+ h('col', { key: 'id' }),
1226
+ h('col', { key: 'hint', className: 'cp-acct-col-hint' }),
1227
+ h('col', { key: 'actions', className: 'cp-acct-col-actions' }),
1228
+ ]),
1229
+ h('thead', { key: 'head' }, h('tr', { key: 'r' }, [
1230
+ h('th', { key: 'dot' }, ''),
1231
+ h('th', { key: 'name' }, t('accountName')),
1232
+ h('th', { key: 'key' }, t('accountsKey')),
1233
+ h('th', { key: 'actions' }, t('accountsActions')),
1234
+ ])),
1235
+ h('tbody', { key: 'body' }, data.accounts.map((account) => h(AccountRow, {
1236
+ key: account.key,
1237
+ t,
1238
+ account,
1239
+ disabled,
1240
+ pooled,
1241
+ removable: removable(account),
1242
+ active: single && account.key === activeKey,
1243
+ actions,
1244
+ }))),
707
1245
  ]),
708
- h('div', { key: 'add', className: 'cp-row' }, [
1246
+ target === undefined ? null : h('div', { key: 'key', className: 'cp-row cp-acct-key' }, [
1247
+ h('span', { key: 'l', className: 'cp-muted' }, fill(t('accountsFor'), { name: target.displayName })),
1248
+ h(KeyField, {
1249
+ key: 'field',
1250
+ t,
1251
+ account: target,
1252
+ disabled,
1253
+ busy: false,
1254
+ inline: true,
1255
+ onSave: (value) => actions.setKey(target.apiKeyEnv, value),
1256
+ onTest: (value) => actions.testKey(target.apiKeyEnv, value),
1257
+ onSaveTest: (value) => actions.saveAndTest(target.apiKeyEnv, value),
1258
+ }),
1259
+ ]),
1260
+ h('div', { key: 'add', className: 'cp-row cp-acct-add' }, [
709
1261
  h('input', {
710
- key: 'n', className: 'cp-input', placeholder: t('accountName'), value: name, disabled,
1262
+ key: 'n', className: 'cp-input cp-acct-newname', placeholder: t('accountName'), value: name, disabled,
711
1263
  'aria-label': t('accountName'),
712
1264
  onChange: (event) => setName(event.target.value),
713
1265
  }),
@@ -726,14 +1278,45 @@ window.__ModuleLoader__.load({
726
1278
  ])
727
1279
  }
728
1280
 
729
- /** The models card. */
1281
+ /**
1282
+ * One fold-away section header.
1283
+ *
1284
+ * The chevron is explicit because the summary is laid out as a flex row,
1285
+ * which drops the browser's own disclosure marker — without it a folded
1286
+ * section looks like a plain card title, not something to open.
1287
+ */
1288
+ function foldHeader(title) {
1289
+ return h('summary', { key: 'sum', className: 'cp-card-title cp-fold' }, [
1290
+ h('span', { key: 'l' }, title),
1291
+ h(Chevron, { key: 'chevron', className: 'cp-chevron' }),
1292
+ ])
1293
+ }
1294
+
1295
+ /**
1296
+ * The models card: a fold-away section, like the request history.
1297
+ *
1298
+ * It is the longest block in the panel and the one a user comes back to
1299
+ * least, so it folds; the count in its header is what keeps a folded section
1300
+ * informative.
1301
+ */
730
1302
  function ModelsCard(props) {
731
1303
  const { t, data, disabled, busy, actions } = props
732
- return h('div', { className: 'cp-card' }, [
733
- h('div', { key: 'title', className: 'cp-card-title' }, [
734
- h('span', { key: 'l' }, `${t('models')} · ${fill(t('modelsCount'), { n: data.models.length })}`),
1304
+ const hidden = Number(data.hiddenModels ?? 0)
1305
+ return h('details', { className: 'cp-card' }, [
1306
+ foldHeader(`${t('models')} · ${fill(t('modelsCount'), { n: data.models.length })}${hidden === 0 ? '' : ` · ${fill(t('hiddenModels'), { n: hidden })}`}`),
1307
+ // The refresh and the bulk checkbox actions sit in the body, not the
1308
+ // header: a button inside a summary would also toggle the fold.
1309
+ h('div', { key: 'bar', className: 'cp-row' }, [
1310
+ h('button', {
1311
+ key: 'all', type: 'button', className: 'cp-btn', disabled,
1312
+ onClick: () => actions.setModelsVisibility('all'),
1313
+ }, t('selectAll')),
735
1314
  h('button', {
736
- key: 'r', type: 'button', className: 'cp-btn', disabled,
1315
+ key: 'invert', type: 'button', className: 'cp-btn', disabled,
1316
+ onClick: () => actions.setModelsVisibility('invert'),
1317
+ }, t('invertSelection')),
1318
+ h('button', {
1319
+ key: 'r', type: 'button', className: 'cp-btn cp-grow-end', disabled,
737
1320
  onClick: () => actions.refreshModels(),
738
1321
  }, t('refresh')),
739
1322
  ]),
@@ -743,29 +1326,199 @@ window.__ModuleLoader__.load({
743
1326
  ])
744
1327
  }
745
1328
 
1329
+ /**
1330
+ * The rows one history entry contributes: the request itself, plus the
1331
+ * failure line when there was one.
1332
+ *
1333
+ * The columns are the four facts a user asks in order ("did the request I
1334
+ * just made work?"), and the whole row is a tooltip so a truncated model id
1335
+ * or error is still recoverable.
1336
+ *
1337
+ * @returns one `<tr>`, or two when the request failed.
1338
+ */
1339
+ function historyRows(t, entry, showAccount) {
1340
+ const failed = String(entry.error ?? '') !== ''
1341
+ const upstream = `${entry.provider || t('historyAuto')}${showAccount && entry.account !== '' ? ` · ${entry.account}` : ''}`
1342
+ // The route prefix is constant across every row of this list, so it is
1343
+ // carried in the tooltip instead of eating the width the model name needs
1344
+ // — centred text that overflows loses BOTH ends, not just the tail.
1345
+ const label = String(entry.model).replace(/^cline-pass\//, '')
1346
+ const figures = latency(entry)
1347
+ const row = h('tr', {
1348
+ key: 'row',
1349
+ // The whole row restates itself on hover, including the figures the three
1350
+ // stacked lines already show, so a screenshot or a copy keeps the facts.
1351
+ title: [
1352
+ entry.model,
1353
+ `${upstream} · ${tokenSummary(entry)}`,
1354
+ `${t('historyFirstChunk')} ${figures.first} · ${t('historyTotal')} ${figures.total} · ${t('historyRate')} ${figures.rate}`,
1355
+ entry.effort === '' ? '' : `${t('historyEffort')} ${entry.effort}`,
1356
+ failed ? entry.error : '',
1357
+ ].filter((line) => line !== '').join('\n'),
1358
+ }, [
1359
+ h('td', { key: 'dot' }, h(Dot, {
1360
+ color: failed ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)',
1361
+ title: failed ? entry.error : 'ok',
1362
+ })),
1363
+ h('td', { key: 'when', className: 'cp-history-when' }, stamp(entry.ts)),
1364
+ // Model and its serving channel share a cell: the panel is ~480px wide and
1365
+ // a column of its own squeezed the model id — the one identifier a reader
1366
+ // scans for — down to a truncated stub.
1367
+ h('td', { key: 'model', className: 'cp-history-model-cell' }, h('div', { className: 'cp-history-model-stack' }, [
1368
+ h('span', { key: 'name', className: 'cp-history-model', title: entry.model }, label),
1369
+ h('span', { key: 'upstream', className: 'cp-history-meta' }, [
1370
+ h('span', { key: 'provider', className: 'cp-tag' }, entry.provider || t('historyAuto')),
1371
+ showAccount && entry.account !== '' ? h('span', { key: 'account', className: 'cp-tag cp-tag-quiet' }, entry.account) : null,
1372
+ ]),
1373
+ ])),
1374
+ // The tooltip carries the EXACT figures, not the bounded rendering: the cell
1375
+ // rounds "236,288" to "236.3k" to keep its width, so the tooltip is the
1376
+ // only place the real digits survive.
1377
+ h('td', {
1378
+ key: 'tokens',
1379
+ className: 'cp-history-tokens',
1380
+ title: `${tokenSummaryExact(entry)}\n${t('historyTokensHint')}`,
1381
+ }, tokenSummary(entry)),
1382
+ h('td', { key: 'load', className: 'cp-history-load' }, [
1383
+ h('span', { key: 'first', className: 'cp-history-load-row' }, [
1384
+ h('span', { key: 'k', className: 'cp-muted' }, t('historyFirstChunk')),
1385
+ h('span', { key: 'v' }, latency(entry).first),
1386
+ ]),
1387
+ h('span', { key: 'total', className: 'cp-history-load-row' }, [
1388
+ h('span', { key: 'k', className: 'cp-muted' }, t('historyTotal')),
1389
+ h('span', { key: 'v' }, latency(entry).total),
1390
+ ]),
1391
+ h('span', { key: 'rate', className: 'cp-history-load-row' }, [
1392
+ h('span', { key: 'k', className: 'cp-muted' }, t('historyRate')),
1393
+ h('span', { key: 'v' }, latency(entry).rate),
1394
+ ]),
1395
+ ]),
1396
+ ])
1397
+ if (!failed) return [row]
1398
+ // Two empty cells keep the message starting under the model column.
1399
+ return [row, h('tr', { key: 'error' }, [
1400
+ h('td', { key: 'a' }, ''),
1401
+ h('td', { key: 'b' }, ''),
1402
+ // From the model column to the end: model, upstream, tokens, latency.
1403
+ h('td', { key: 'message', className: 'cp-history-error', colSpan: 3, title: entry.error }, entry.error),
1404
+ ])]
1405
+ }
1406
+
746
1407
  /** The request-history body, rendered inside the panel's fold-away section. */
747
1408
  function HistoryCard(props) {
748
- const { t, entries, disabled, actions } = props
1409
+ const { t, entries, disabled, actions, showAccount } = props
749
1410
  return h('div', { className: 'cp-panel' }, [
750
1411
  h('div', { key: 'bar', className: 'cp-row' }, [
751
1412
  h('button', { key: 'r', type: 'button', className: 'cp-btn', disabled, onClick: () => actions.loadHistory(25) }, t('refresh')),
752
1413
  ]),
753
1414
  entries === null
754
1415
  ? h('div', { key: 'empty', className: 'cp-muted' }, t('noHistory'))
755
- : h('div', { key: 'rows', className: 'cp-history' }, entries.map((entry, index) => h('div', { key: `${entry.ts}-${index}`, className: 'cp-history-row' }, [
756
- h('span', { key: 'm', className: 'cp-grow' }, entry.model),
757
- h('span', { key: 'p' }, entry.provider || '(auto)'),
758
- h('span', { key: 'ms', className: 'cp-muted' }, ms(entry.ms)),
759
- entry.error ? h('span', { key: 'e', style: { color: 'var(--dsw-alias-state-error-primary)' } }, entry.error) : null,
760
- ]))),
1416
+ : h('div', { key: 'table', className: 'cp-history' }, h('table', { className: 'cp-history-table' }, [
1417
+ h('colgroup', { key: 'cols' }, [
1418
+ h('col', { key: 'dot', className: 'cp-history-col-dot' }),
1419
+ h('col', { key: 'when', className: 'cp-history-col-when' }),
1420
+ h('col', { key: 'model' }),
1421
+ h('col', { key: 'tokens', className: 'cp-history-col-tokens' }),
1422
+ h('col', { key: 'load', className: 'cp-history-col-load' }),
1423
+ ]),
1424
+ h('thead', { key: 'head' }, h('tr', { key: 'r' }, [
1425
+ h('th', { key: 'dot' }, ''),
1426
+ h('th', { key: 'when' }, t('historyWhen')),
1427
+ h('th', { key: 'model' }, `${t('historyModel')} / ${t('historyUpstream')}`),
1428
+ h('th', { key: 'tokens', className: 'cp-history-tokens' }, t('historyTokens')),
1429
+ h('th', { key: 'load', className: 'cp-history-load' }, t('historyLoad')),
1430
+ ])),
1431
+ h('tbody', { key: 'body' }, entries.flatMap((entry) => historyRows(t, entry, showAccount))),
1432
+ ])),
761
1433
  ])
762
1434
  }
763
1435
 
764
- /** The full panel: what the Settings page and the Plugins card render. */
1436
+ /**
1437
+ * The official quota card.
1438
+ *
1439
+ * The window list is whatever the gateway reported, in its order, so a window
1440
+ * Cline adds later shows up here without this file naming it — the three
1441
+ * known windows only contribute their localized labels.
1442
+ *
1443
+ * One reading per window: the official percentage, plus when it resets.
1444
+ */
1445
+ function UsageCard(props) {
1446
+ const { t, usage, disabled, actions, unavailable } = props
1447
+ const fetched = Number(usage?.fetchedAt ?? 0) > 0
1448
+ const accounts = usage?.accounts ?? []
1449
+ const anyWindow = accounts.some((entry) => (entry.limits ?? []).length > 0)
1450
+ const windowRow = (type, key, limit) => {
1451
+ const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
1452
+ const left = countdown(t, limit.resetsAt)
1453
+ // The percentage is the whole reading: it comes from the official quota
1454
+ // endpoint, is always populated, and needs no interpretation. The money
1455
+ // and token figures that used to sit under it were derived, unverifiable
1456
+ // and different from the gateway's own accounting, so they are gone.
1457
+ return h('div', { key: type, className: 'cp-usage-item' }, [
1458
+ h('div', { key: 'head', className: 'cp-usage-head' }, [
1459
+ h('span', { key: 'l', className: 'cp-usage-label' }, key === '' ? type : t(key)),
1460
+ h('span', { key: 'v', className: 'cp-usage-value' }, `${Math.round(percent)}%`),
1461
+ ]),
1462
+ h('div', { key: 'track', className: 'cp-usage-track' },
1463
+ h('div', { key: 'fill', className: 'cp-usage-fill', style: { width: `${percent}%`, background: usageColor(percent) } })),
1464
+ left === '' ? null : h('div', { key: 'note', className: 'cp-usage-note' }, fill(t('usageResetsIn'), { t: left })),
1465
+ ])
1466
+ }
1467
+ /** One account's block: its name when there is more than one, then its windows. */
1468
+ const accountGroup = (account) => {
1469
+ const limits = account.limits ?? []
1470
+ const known = new Map(limits.map((limit) => [String(limit.type), limit]))
1471
+ const windows = [
1472
+ ...USAGE_WINDOWS.filter(([type]) => known.has(type)).map(([type, key]) => [type, key, known.get(type)]),
1473
+ ...limits
1474
+ .filter((limit) => !USAGE_WINDOWS.some(([type]) => type === String(limit.type)))
1475
+ .map((limit) => [String(limit.type), '', limit]),
1476
+ ]
1477
+ const inner = account.ok !== true
1478
+ ? h('div', { key: 'err', className: 'cp-muted' }, `${t('usageUnavailable')}${account.error ? ` ${account.error}` : ''}`)
1479
+ : windows.length === 0
1480
+ ? h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1481
+ : h('div', { key: 'bars', className: 'cp-usage' }, windows.map(([type, key, limit]) => windowRow(type, key, limit)))
1482
+ return h('div', { key: account.account, className: 'cp-usage-group' }, [
1483
+ accounts.length > 1 ? h('div', { key: 'name', className: 'cp-usage-account' }, account.account) : null,
1484
+ inner,
1485
+ ])
1486
+ }
1487
+ let body
1488
+ if (!fetched) body = h('div', { key: 'wait', className: 'cp-muted' }, unavailable === true ? t('usageUnavailable') : t('usageLoading'))
1489
+ else if (accounts.length === 0) body = h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1490
+ else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accounts.map(accountGroup))
1491
+ const title = accounts.length > 1
1492
+ ? `${t('usageTitle')} · ${fill(t('accountsCount'), { n: accounts.length })}`
1493
+ : `${t('usageTitle')}${accounts[0] ? ` · ${fill(t('usageAccount'), { name: accounts[0].account })}` : ''}`
1494
+ return h('div', { className: 'cp-card' }, [
1495
+ h('div', { key: 'title', className: 'cp-card-title' }, [
1496
+ h('span', { key: 'l' }, title),
1497
+ h('span', { key: 'btns', className: 'cp-inline' }, [
1498
+ h('button', { key: 'r', type: 'button', className: 'cp-btn', disabled, onClick: () => actions.loadUsage(true) }, t('usageRefresh')),
1499
+ ]),
1500
+ ]),
1501
+ body,
1502
+ ])
1503
+ }
1504
+
1505
+ /**
1506
+ * The full panel: the Settings page body, and the collapsed card's content.
1507
+ *
1508
+ * `hideTitle` drops the panel's own heading where the surrounding chrome
1509
+ * already names the provider — the Plugins card's header does.
1510
+ */
765
1511
  function Panel(props) {
766
1512
  const t = props.t
767
1513
  const state = props.useClinePass((snapshot) => snapshot)
1514
+ /** The last history read: `{ entries, total }`, or null before the first. */
768
1515
  const [history, setHistory] = React.useState(null)
1516
+ const usageAsked = React.useRef(false)
1517
+ const planAsked = React.useRef(false)
1518
+ /** Set once an automatic quota read has failed, so the card stops saying "loading". */
1519
+ const [readFailed, setReadFailed] = React.useState(false)
1520
+ /** The window measurement already requested in this mount: one attempt each. */
1521
+ const windowsAsked = React.useRef(-1)
769
1522
 
770
1523
  // Read once on mount, while there is nothing to render. The dependency is
771
1524
  // the loaded data itself, not the status: a failed read leaves `data`
@@ -775,9 +1528,34 @@ window.__ModuleLoader__.load({
775
1528
  if (state.data === null) props.refresh()
776
1529
  }, [state.data])
777
1530
 
1531
+ // Quota is one request per mount, not per render: the reading is cached
1532
+ // host-side, so reopening the card shows the last one immediately. A route
1533
+ // with no usable key behind it has nothing to read, so it is not asked.
1534
+ React.useEffect(() => {
1535
+ if (usageAsked.current || state.data === null || state.data.ready !== true) return
1536
+ usageAsked.current = true
1537
+ void props.loadUsage().then((result) => setReadFailed(result === undefined))
1538
+ }, [state.data])
1539
+
1540
+ // The history section follows the host's retained count: every panel read
1541
+ // carries it, so the rows are re-read whenever new requests landed instead
1542
+ // of going stale the moment the first read completed.
778
1543
  React.useEffect(() => {
779
- if (state.action?.kind === 'history') setHistory(state.action.data.entries ?? [])
780
- }, [state.action])
1544
+ if (state.data === null) return
1545
+ let cancelled = false
1546
+ void props.loadHistory(25).then((result) => {
1547
+ if (cancelled || result === undefined) return
1548
+ setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
1549
+ })
1550
+ return () => { cancelled = true }
1551
+ }, [state.data?.historySize])
1552
+
1553
+
1554
+
1555
+ const rememberHistory = (result) => {
1556
+ if (result !== undefined) setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
1557
+ return result
1558
+ }
781
1559
 
782
1560
  const actions = {
783
1561
  refresh: props.refresh,
@@ -787,25 +1565,26 @@ window.__ModuleLoader__.load({
787
1565
  addAccount: props.addAccount,
788
1566
  removeAccount: props.removeAccount,
789
1567
  setAccountMode: props.setAccountMode,
1568
+ setAccountEnabled: props.setAccountEnabled,
790
1569
  pinModel: props.pinModel,
791
- setupModel: props.setupModel,
1570
+ setModelVisible: props.setModelVisible,
1571
+ setModelsVisibility: props.setModelsVisibility,
792
1572
  probeModel: props.probeModel,
793
1573
  validateModel: props.validateModel,
794
1574
  testModel: props.testModel,
795
1575
  resetModel: props.resetModel,
796
1576
  refreshModels: props.refreshModels,
797
- loadHistory: async (limit) => {
798
- const result = await props.loadHistory(limit)
799
- if (result !== undefined) setHistory(result.entries ?? [])
800
- },
1577
+ loadUsage: (explicit) => props.loadUsage(explicit),
1578
+ loadHistory: async (limit) => rememberHistory(await props.loadHistory(limit)),
801
1579
  }
802
1580
 
803
1581
  const data = state.data
804
1582
  const disabled = state.busy !== null || (data !== null && data.settingsAvailable === false)
805
1583
  const ready = data?.ready === true
1584
+ const total = Number(data?.historySize ?? history?.total ?? 0)
806
1585
 
807
1586
  return h('div', { className: 'cp-panel' }, [
808
- h('h2', { key: 'title', className: 'cp-title' }, t('title')),
1587
+ props.hideTitle === true ? null : h('h2', { key: 'title', className: 'cp-title' }, t('title')),
809
1588
  // One status line replaces the old route/baseURL block: the endpoint is
810
1589
  // not something a user sets, so showing it was pure noise.
811
1590
  h('div', { key: 'status', className: 'cp-row cp-muted' }, [
@@ -820,15 +1599,73 @@ window.__ModuleLoader__.load({
820
1599
  state.status === 'loading' && data === null
821
1600
  ? h('div', { key: 'loading', className: 'cp-muted' }, t('busy'))
822
1601
  : null,
823
- data === null ? null : h(AccountsCard, { key: 'accounts', t, data, disabled, actions }),
824
- data === null ? null : (ready
825
- ? h(ModelsCard, { key: 'models', t, data, disabled, busy: state.busy, actions })
826
- : h('div', { key: 'notready', className: 'cp-msg cp-msg-warn' }, t('notReady'))),
1602
+ // Accounts first, quota second: the account is what has to be set up
1603
+ // before any window reading means anything, and a quota card with no key
1604
+ // behind it has nothing to say.
1605
+ data === null ? null : h(AccountsCard, { key: 'accounts', t, data, disabled, ready, actions }),
1606
+ data === null || !ready ? null : h(UsageCard, {
1607
+ key: 'usage',
1608
+ t,
1609
+ usage: data.usage,
1610
+ unavailable: readFailed,
1611
+ disabled,
1612
+ actions,
1613
+ }),
1614
+ // Everything below the accounts card reads through the key that card
1615
+ // stores, so none of it exists until there is one.
1616
+ data === null || !ready ? null : h(ModelsCard, { key: 'models', t, data, disabled, busy: state.busy, actions }),
827
1617
  // Diagnostic detail, so it stays folded away until asked for.
828
- h('details', { key: 'history', className: 'cp-card' }, [
829
- h('summary', { key: 'sum', className: 'cp-card-title' }, t('history')),
830
- h(HistoryCard, { key: 'body', t, entries: history, disabled: state.busy !== null, actions }),
1618
+ data === null || !ready ? null : h('details', { key: 'history', className: 'cp-card' }, [
1619
+ foldHeader(`${t('history')} · ${total}`),
1620
+ h(HistoryCard, {
1621
+ key: 'body',
1622
+ t,
1623
+ entries: history?.entries ?? null,
1624
+ disabled: state.busy !== null,
1625
+ // Which account served a request only means something in a pool.
1626
+ showAccount: Number(data?.accounts?.length ?? 0) > 1,
1627
+ actions,
1628
+ }),
1629
+ ]),
1630
+ ])
1631
+ }
1632
+
1633
+ /**
1634
+ * The Plugins settings tab: the same panel, folded away behind a header row.
1635
+ *
1636
+ * The section that owns `settings.plugins.tab` mounts each contribution
1637
+ * inside a `div[role=tabpanel]` rather than a card list, so this renders a
1638
+ * `div` — an `li` outside a `ul` would be invalid there.
1639
+ *
1640
+ * Disclosure is card-local state, re-read on every mount, exactly like the
1641
+ * host's own cards — and because the panel only mounts once opened, a folded
1642
+ * card costs no round trip.
1643
+ */
1644
+ function PluginSettingsCard(props) {
1645
+ const t = props.t
1646
+ const [open, setOpen] = React.useState(false)
1647
+ const title = t('title')
1648
+ return h('div', { className: `cp-set-card${open ? ' cp-set-card-open' : ''}` }, [
1649
+ h('button', {
1650
+ key: 'head',
1651
+ type: 'button',
1652
+ className: 'cp-set-header',
1653
+ 'aria-expanded': open,
1654
+ 'aria-label': `${t(open ? 'collapse' : 'expand')}: ${title}`,
1655
+ onClick: () => setOpen(!open),
1656
+ }, [
1657
+ h('span', { key: 'text', className: 'cp-set-head-text' }, [
1658
+ h('span', { key: 'name', className: 'cp-set-name' }, title),
1659
+ h('span', { key: 'desc', className: 'cp-set-desc' }, t('description')),
1660
+ ]),
1661
+ h(Chevron, {
1662
+ key: 'chevron',
1663
+ className: `cp-set-chevron${open ? ' cp-set-chevron-open' : ''}`,
1664
+ }),
831
1665
  ]),
1666
+ open
1667
+ ? h('div', { key: 'body', className: 'cp-set-body' }, h(Panel, { ...props, hideTitle: true }))
1668
+ : null,
832
1669
  ])
833
1670
  }
834
1671
 
@@ -902,29 +1739,28 @@ window.__ModuleLoader__.load({
902
1739
  return controller.t(key, params)
903
1740
  }
904
1741
 
905
- const renderPanel = (props) => h(Panel, { ...props, ...face, t: translate })
1742
+ const renderPluginCard = (props) => h(PluginSettingsCard, { ...props, ...face, t: translate })
906
1743
  const renderQuick = (props) => h(QuickCard, { ...props, ...face, t: translate })
907
1744
 
908
- // 1. A dedicated settings page: the full configuration surface.
909
- ctx.slots.inject('settings.section', () => ctx.slots.register({
910
- name: 'settings.section',
1745
+ // 1. The one configuration surface: a tab inside the Plugins settings
1746
+ // section, which already enumerates this namespace. No
1747
+ // `settings.section` alongside it — a second page in the Settings nav
1748
+ // would only duplicate this panel, and the nav is for surfaces that are
1749
+ // not already listed there.
1750
+ //
1751
+ // `settings.plugins.tab` is a list slot: its key is `id`, its position
1752
+ // is `order` (the host's own inventory tab sits at 10), and its tab
1753
+ // label is a thunk so it follows the active locale.
1754
+ ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
1755
+ name: 'settings.plugins.tab',
911
1756
  id: NS,
912
- order: 12,
1757
+ order: 20,
913
1758
  label: () => translate('title'),
914
1759
  locale: NS,
915
1760
  inject: face,
916
- }, renderPanel))
917
-
918
- // 2. A card on the Plugins page, which already enumerates this
919
- // namespace, so the panel is also where users look for plugin config.
920
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
921
- name: 'settings.plugin.item',
922
- key: NS,
923
- locale: NS,
924
- inject: face,
925
- }, renderPanel))
1761
+ }, renderPluginCard))
926
1762
 
927
- // 3. A compact key card on the Models page, right where the missing key
1763
+ // 2. A compact key card on the Models page, right where the missing key
928
1764
  // for this route is reported.
929
1765
  ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register({
930
1766
  name: 'settings.models.provider-card',