@goodandready/dsh-clinebot 0.5.28 → 0.5.30

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
@@ -10,9 +10,6 @@ window.__ModuleLoader__.load({
10
10
 
11
11
  // --- src/client/constants.js ---
12
12
  const NS = 'dsh-clinebot'
13
- // Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
14
- const ROW_ID = 'dsh-clinebot'
15
- const ROW_CONFIG_KEY = '@goodandready/dsh-clinebot#' + ROW_ID
16
13
  const ROUTE_PREFIX = '/dsh-clinebot'
17
14
  // Stable (frozen) fallback reference: useSyncExternalStore compares snapshots by
18
15
  // identity (Object.is), so returning a fresh object literal causes an infinite render loop
@@ -24,6 +21,10 @@ function readConfigForms(ctx) {
24
21
  try { return ctx.get('configForms') || undefined } catch { return undefined }
25
22
  }
26
23
 
24
+ // Returns the namespace's ConfigFormController snapshot — `{ status, value, … }`.
25
+ // `status` is 'loading' | 'ready' | 'unavailable'; `value` is the decoded config
26
+ // object once the namespace is ready. Consumers that only need the status must
27
+ // read `snapshot?.status`, not the snapshot itself.
27
28
  function useConfigFormsSnapshot(ctx) {
28
29
  const scope = React.useMemo(() => {
29
30
  try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
@@ -34,11 +35,12 @@ function useConfigFormsSnapshot(ctx) {
34
35
  const getSnapshot = React.useCallback(() => {
35
36
  try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
36
37
  }, [scope])
37
- return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
38
+ return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY) || SNAPSHOT_READY
38
39
  }
39
40
 
40
41
  // --- src/client/locales.js ---
41
42
  const en = {
43
+ nav: 'ClineBot',
42
44
  title: 'ClineBot',
43
45
  subtitle: 'ClinePass subscription with dynamic plan models.',
44
46
  'settings.loading': 'Loading ClineBot settings…',
@@ -53,7 +55,7 @@ const en = {
53
55
  'badge.key_missing': 'Key missing',
54
56
  'badge.registered': 'DSH Registered ({count} models)',
55
57
  'badge.not_registered': 'Not registered',
56
- 'key.title': '🔑 Authorization & API Key',
58
+ 'key.title': 'Authorization & API Key',
57
59
  'key.desc': 'The key is saved directly to DSH Credentials storage (~/.dsh/.credentials.yaml) and never exposed in plaintext configuration files.',
58
60
  'key.placeholder_has': '••••••••••••••••••••••••',
59
61
  'key.placeholder_empty': 'Paste ClinePass key (cline_...)',
@@ -61,7 +63,7 @@ const en = {
61
63
  'key.hide': 'Hide',
62
64
  'key.save': 'Save Key',
63
65
  'key.saving': 'Saving…',
64
- 'key.login_fast': '🔑 Open API Keys ↗',
66
+ 'key.login_fast': 'Open API Keys ↗',
65
67
  'key.verifying': 'Validating key with Cline…',
66
68
  'key.verified': '✓ Valid key for {email} ({plan})',
67
69
  'key.invalid': '✗ Key validation failed: {error}',
@@ -69,7 +71,7 @@ const en = {
69
71
  'key.get_key': 'Get key in app.cline.bot console ↗',
70
72
  'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
71
73
  'key.empty_err': 'Please enter an API key before saving',
72
- 'accounts.title': '👥 Multi-Account Failover Pool',
74
+ 'accounts.title': 'Multi-Account Failover Pool',
73
75
  'accounts.desc': 'Add extra accounts. When an account hits 429 rate limits, ClineBot switches to the next available account automatically.',
74
76
  'accounts.active_badge': 'Active',
75
77
  'accounts.pinned_badge': 'Pinned',
@@ -91,17 +93,17 @@ const en = {
91
93
  'accounts.route_model_placeholder': 'Model ID (e.g. cline-pass/deepseek-v4-pro)',
92
94
  'accounts.route_account_placeholder': 'Target Account',
93
95
  'accounts.route_btn_add': 'Add Route',
94
- 'quota.title': '📊 ClinePass Quota & Rate Limits',
96
+ 'quota.title': 'ClinePass Quota & Rate Limits',
95
97
  'quota.desc': 'Official rolling window request limits from ClinePass',
96
98
  'quota.account': 'Account: {email} · Plan: {plan}',
97
99
  'quota.refresh': 'Refresh Quotas',
98
100
  'quota.refreshing': 'Refreshing…',
99
101
  'quota.refreshed_msg': 'Subscription quotas refreshed',
100
102
  'quota.window_5h': '⏱ 5-Hour Rolling Limit',
101
- 'quota.window_weekly': '📅 Weekly Window',
103
+ 'quota.window_weekly': 'Weekly Window',
102
104
  'quota.used': '{pct}% used',
103
105
  'quota.reset_at': 'Reset: {time}',
104
- 'quota.window_monthly': '📅 Monthly Window',
106
+ 'quota.window_monthly': 'Monthly Window',
105
107
  'quota.countdown': 'Resets in {time}',
106
108
  'quota.resetting': 'Resetting…',
107
109
  'quota.warning_low': 'Quota is below 10%. Requests may be throttled soon.',
@@ -109,7 +111,7 @@ const en = {
109
111
  'quota.warning_5h': 'Low quota warning: {percentUsed}% of 5-hour limit consumed (<10% remaining).',
110
112
  'quota.warning_weekly': 'Low weekly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
111
113
  'quota.warning_monthly': 'Low monthly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
112
- 'models.reasoning_tag': '🧠 Reasoning',
114
+ 'models.reasoning_tag': 'Reasoning',
113
115
  'models.effort_low': 'Low',
114
116
  'models.effort_medium': 'Medium',
115
117
  'models.effort_high': 'High',
@@ -133,10 +135,10 @@ const en = {
133
135
  'models.show_all': 'Show All',
134
136
  'models.clean_stale': 'Clean Stale ({count})',
135
137
  'models.models_selected': '{selected} of {total} selected for chat',
136
- 'models.title': '🎯 Model Picker in DSH ({enabled} of {total} active)',
138
+ 'models.title': 'Model Picker in DSH ({enabled} of {total} active)',
137
139
  'models.desc': 'Catalog synchronizes directly from ClinePass subscription. New plan models are enabled automatically. Uncheck models to hide them from DSH chat picker. Thinking effort is selectable in the composer model menu.',
138
140
  'models.reasoning_tooltip': 'Supports thinking effort selection in composer model menu (Off / Low / Medium / High / Max)',
139
- 'models.sync': '🔄 Check Plan Models',
141
+ 'models.sync': 'Check Plan Models',
140
142
  'models.syncing': 'Checking…',
141
143
  'models.synced_msg': 'Synchronized subscription models: {total} ({discovered} discovered from ClinePass)',
142
144
  'models.verified': 'Verified with plan',
@@ -162,7 +164,7 @@ const en = {
162
164
  'models.btn_probe': 'Ping',
163
165
  'models.star': 'Star',
164
166
  'models.auto_new': 'New',
165
- 'models.apply_original_all': '⚡ Original Specs',
167
+ 'models.apply_original_all': 'Original Specs',
166
168
  'models.reset_contexts_all': '↺ Reset Contexts',
167
169
  'models.context_updated_msg': 'Model context limits updated and synchronized to DSH.',
168
170
  'models.ctx_default': 'Default ({ctx} / {max})',
@@ -179,7 +181,7 @@ const en = {
179
181
  'models.custom_max_label': 'Max Out',
180
182
  'models.ctx_tag_original': 'Original',
181
183
  'models.ctx_tag_custom': 'Custom',
182
- 'stats.title': '📈 Current DSH Session Telemetry',
184
+ 'stats.title': 'Current DSH Session Telemetry',
183
185
  'stats.desc': 'Local monitoring of requests, network latency, and estimated token usage via ClinePass.',
184
186
  'stats.requests': 'Successful / Total Requests',
185
187
  'stats.tokens': 'Estimated Session Tokens',
@@ -198,7 +200,7 @@ const en = {
198
200
  'stats.reset_btn': 'Reset Stats',
199
201
  'stats.confirm_reset': 'Confirm Reset?',
200
202
  'stats.cancel_reset': 'Cancel',
201
- 'diag.title': '⚡ Diagnostics & Auto-Registration',
203
+ 'diag.title': 'Diagnostics & Auto-Registration',
202
204
  'diag.desc': 'ClineBot registers into DSH Models automatically when key is configured. You can run connection smoke test or force re-sync.',
203
205
  'diag.smoke_btn': 'Run Smoke Test (Ping)',
204
206
  'diag.smoke_testing': 'Testing…',
@@ -234,16 +236,20 @@ const en = {
234
236
  'dashboard.account_pool': 'Active Routing Account',
235
237
  'dashboard.credits': 'Credit Balance',
236
238
  'dashboard.updated_at': 'Updated at',
237
- 'card.title': 'ClineBot Provider',
238
- 'card.status_connected': 'Connected',
239
- 'card.status_no_key': 'Key missing',
240
- 'card.open_settings': 'Settings & Accounts',
241
- 'card.save_key': 'Save Key',
242
- 'card.key_saved': 'Key saved',
243
- 'card.models_active': '{count} active models',
239
+ 'composer.cost_title': 'Estimated Session Cost',
240
+ 'composer.cost_breakdown': 'Token & Cost Breakdown',
241
+ 'composer.prompt_tok': 'Prompt Tokens',
242
+ 'composer.completion_tok': 'Completion Tokens',
243
+ 'composer.reasoning_tok': 'Reasoning Tokens',
244
+ 'composer.est_cost': 'Estimated Cost',
245
+ 'discovery.title': 'Discovered Local Keys',
246
+ 'discovery.import_btn': 'Import from {source}',
247
+ 'discovery.imported': 'Imported to accounts pool',
248
+ 'discovery.scanning': 'Scanning for local Cline keys…',
244
249
  }
245
250
 
246
251
  const zh = {
252
+ nav: 'ClineBot',
247
253
  title: 'ClineBot',
248
254
  subtitle: 'ClinePass 订阅服务,包含动态套餐模型。',
249
255
  'settings.loading': '正在加载 ClineBot 设置…',
@@ -258,7 +264,7 @@ const en = {
258
264
  'badge.key_missing': '未配置密钥',
259
265
  'badge.registered': '已接入 DSH ({count} 个模型)',
260
266
  'badge.not_registered': '未接入 DSH',
261
- 'key.title': '🔑 授权与 API 密钥',
267
+ 'key.title': '授权与 API 密钥',
262
268
  'key.desc': '密钥直接加密保存在 DSH 凭据管理器 (~/.dsh/.credentials.yaml),绝不会以明文形式暴露在普通配置文件中。',
263
269
  'key.placeholder_has': '••••••••••••••••••••••••',
264
270
  'key.placeholder_empty': '粘贴 ClinePass 密钥 (cline_...)',
@@ -266,7 +272,7 @@ const en = {
266
272
  'key.hide': '隐藏',
267
273
  'key.save': '保存密钥',
268
274
  'key.saving': '正在保存…',
269
- 'key.login_fast': '🔑 打开 API 密钥页 ↗',
275
+ 'key.login_fast': '打开 API 密钥页 ↗',
270
276
  'key.verifying': '正在连接 Cline 验证密钥…',
271
277
  'key.verified': '✓ 密钥有效:{email} ({plan})',
272
278
  'key.invalid': '✗ 密钥验证未通过:{error}',
@@ -274,7 +280,7 @@ const en = {
274
280
  'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
275
281
  'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
276
282
  'key.empty_err': '保存前请输入 API 密钥',
277
- 'accounts.title': '👥 多账号故障转移与轮换池 (Failover)',
283
+ 'accounts.title': '多账号故障转移与轮换池 (Failover)',
278
284
  'accounts.desc': '添加备用账号。当某个账号触发 429 速率限制或配额耗尽时,ClineBot 会智能无缝切换至剩余额度最高的备用账号。',
279
285
  'accounts.active_badge': '当前活动',
280
286
  'accounts.pinned_badge': '固定使用',
@@ -296,17 +302,17 @@ const en = {
296
302
  'accounts.route_model_placeholder': '模型 ID (例如 cline-pass/deepseek-v4-pro)',
297
303
  'accounts.route_account_placeholder': '目标账号',
298
304
  'accounts.route_btn_add': '添加路由',
299
- 'quota.title': '📊 ClinePass 用量配额与限额监控',
305
+ 'quota.title': 'ClinePass 用量配额与限额监控',
300
306
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
301
307
  'quota.account': '账号:{email} · 套餐:{plan}',
302
308
  'quota.refresh': '刷新配额',
303
309
  'quota.refreshing': '正在刷新…',
304
310
  'quota.refreshed_msg': '订阅用量配额已刷新',
305
311
  'quota.window_5h': '⏱ 5 小时滚动限额',
306
- 'quota.window_weekly': '📅 每周滚动窗口',
312
+ 'quota.window_weekly': '每周滚动窗口',
307
313
  'quota.used': '已使用 {pct}%',
308
314
  'quota.reset_at': '重置时间:{time}',
309
- 'quota.window_monthly': '📅 每月滚动窗口',
315
+ 'quota.window_monthly': '每月滚动窗口',
310
316
  'quota.countdown': '{time}后重置',
311
317
  'quota.resetting': '正在重置',
312
318
  'quota.warning_low': '额度低于 10%,请求可能很快会受到限制。',
@@ -314,7 +320,7 @@ const en = {
314
320
  'quota.warning_5h': '低额度警告:5 小时限额已消耗 {percentUsed}%(剩余不足 10%)。',
315
321
  'quota.warning_weekly': '周限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
316
322
  'quota.warning_monthly': '月限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
317
- 'models.reasoning_tag': '🧠 深度思考',
323
+ 'models.reasoning_tag': '深度思考',
318
324
  'models.effort_low': '低',
319
325
  'models.effort_medium': '中',
320
326
  'models.effort_high': '高',
@@ -338,10 +344,10 @@ const en = {
338
344
  'models.show_all': '显示全部',
339
345
  'models.clean_stale': '清理过期 ({count})',
340
346
  'models.models_selected': '已选择 {selected} / {total} 个模型用于聊天',
341
- 'models.title': '🎯 DSH 模型选择列表(已启用 {enabled}/{total})',
347
+ 'models.title': 'DSH 模型选择列表(已启用 {enabled}/{total})',
342
348
  'models.desc': '模型目录与 ClinePass 官方订阅实时同步。新订阅模型默认自动启用。取消勾选可在 DSH 对话模型列表中隐藏不需要的模型。支持在对话输入框上方切换推理思考强度(Effort)。',
343
349
  'models.reasoning_tooltip': '支持在对话框上方模型菜单中选择思考强度(Off / Low / Medium / High / Max)',
344
- 'models.sync': '🔄 同步订阅模型',
350
+ 'models.sync': '同步订阅模型',
345
351
  'models.syncing': '正在同步…',
346
352
  'models.synced_msg': '已成功同步订阅模型:共 {total} 个(发现 {discovered} 个新模型)',
347
353
  'models.verified': '已与套餐同步',
@@ -367,7 +373,7 @@ const en = {
367
373
  'models.btn_probe': '测试延迟',
368
374
  'models.star': '推荐',
369
375
  'models.auto_new': '新上线',
370
- 'models.apply_original_all': '⚡ 原厂规格',
376
+ 'models.apply_original_all': '原厂规格',
371
377
  'models.reset_contexts_all': '↺ 重置上下文',
372
378
  'models.context_updated_msg': '模型上下文限制已更新并同步生效至 DSH。',
373
379
  'models.ctx_default': '默认 ({ctx} / {max})',
@@ -384,7 +390,7 @@ const en = {
384
390
  'models.custom_max_label': '最大输出',
385
391
  'models.ctx_tag_original': '原厂',
386
392
  'models.ctx_tag_custom': '自定义',
387
- 'stats.title': '📈 当前 DSH 会话请求遥测',
393
+ 'stats.title': '当前 DSH 会话请求遥测',
388
394
  'stats.desc': '本地实时监控请求次数、网络延迟以及通过 ClinePass 消耗的估算 Token 数量。',
389
395
  'stats.requests': '成功 / 总请求次数',
390
396
  'stats.tokens': '会话累计 Token 估算',
@@ -403,7 +409,7 @@ const en = {
403
409
  'stats.reset_btn': '重置统计',
404
410
  'stats.confirm_reset': '确认重置?',
405
411
  'stats.cancel_reset': '取消',
406
- 'diag.title': '⚡ 诊断工具与服务接入',
412
+ 'diag.title': '诊断工具与服务接入',
407
413
  'diag.desc': '配置 API 密钥后,ClineBot 会自动向 DSH 注册模型服务。您可以在此进行连通性测试或强制重新同步。',
408
414
  'diag.smoke_btn': '执行连通性冒烟测试 (Ping)',
409
415
  'diag.smoke_testing': '正在测试…',
@@ -439,13 +445,16 @@ const en = {
439
445
  'dashboard.account_pool': '当前承载账户',
440
446
  'dashboard.credits': '点数余额',
441
447
  'dashboard.updated_at': '更新于',
442
- 'card.title': 'ClineBot 模型服务',
443
- 'card.status_connected': '已连接',
444
- 'card.status_no_key': '未配置密钥',
445
- 'card.open_settings': '完整设置与多账户',
446
- 'card.save_key': '保存密钥',
447
- 'card.key_saved': '密钥已保存',
448
- 'card.models_active': '{count} 个可用模型',
448
+ 'composer.cost_title': '预估会话成本',
449
+ 'composer.cost_breakdown': 'Token 与费用明细',
450
+ 'composer.prompt_tok': '提示词 Token',
451
+ 'composer.completion_tok': '补全 Token',
452
+ 'composer.reasoning_tok': '思考推理 Token',
453
+ 'composer.est_cost': '预估花费',
454
+ 'discovery.title': '发现本地密钥',
455
+ 'discovery.import_btn': '从 {source} 导入',
456
+ 'discovery.imported': '已成功导入账户池',
457
+ 'discovery.scanning': '正在扫描本地 Cline 密钥…',
449
458
  }
450
459
 
451
460
  function makeT(dict, fallback) {
@@ -463,28 +472,50 @@ const en = {
463
472
  }
464
473
  }
465
474
 
466
- // --- src/client/chevron.js ---
467
- function FallbackChevron() {
468
- return React.createElement(
469
- 'svg',
470
- { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': true },
471
- React.createElement('path', {
472
- d: 'M3.5 5.25L7 8.75L10.5 5.25',
473
- stroke: 'currentColor',
474
- strokeWidth: 1.5,
475
- strokeLinecap: 'round',
476
- strokeLinejoin: 'round',
477
- })
478
- )
479
- }
480
- let ChevronIcon = null
475
+ // --- src/client/primitives.js ---
476
+ // The shell's module table provides the design-system primitives, so the plugin
477
+ // renders the same controls (and inherits the same hover/focus/disabled treatment)
478
+ // as the native settings surfaces instead of re-drawing them. Every lookup is
479
+ // defensive: a missing primitive degrades to the plugin's own styled fallback
480
+ // rather than failing the render.
481
+ let primitives = null
481
482
  try {
482
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
483
- ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
483
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives') || null
484
484
  } catch (_) {
485
- ChevronIcon = null
485
+ primitives = null
486
+ }
487
+
488
+ /** Native toggle for boolean preferences; falls back to a checkbox. */
489
+ const Switch = (primitives && primitives.Switch) || null
490
+
491
+ /** Native dialog window; the plugin draws a plainer one when it is absent. */
492
+ const Modal = (primitives && primitives.Modal) || null
493
+
494
+ /**
495
+ * Boolean preference control: the native Switch when the shell provides it, the
496
+ * plugin's own checkbox otherwise. Both take the same `checked`/`disabled`/`onChange`.
497
+ * @param props - checked state, change handler, accessible label and disabled flag.
498
+ * @returns the control element.
499
+ */
500
+ function BooleanControl(props) {
501
+ const { checked, disabled, label, onChange } = props
502
+ if (Switch) {
503
+ return React.createElement(Switch, {
504
+ checked: !!checked,
505
+ disabled: !!disabled,
506
+ label,
507
+ onChange: (next) => { if (typeof onChange === 'function') onChange(next) },
508
+ })
509
+ }
510
+ return React.createElement('input', {
511
+ type: 'checkbox',
512
+ style: { cursor: disabled ? 'not-allowed' : 'pointer', width: '16px', height: '16px', flex: 'none' },
513
+ checked: !!checked,
514
+ disabled: !!disabled,
515
+ 'aria-label': label,
516
+ onChange: (e) => { if (typeof onChange === 'function') onChange(e.target.checked) },
517
+ })
486
518
  }
487
- const Chevron = ChevronIcon || FallbackChevron
488
519
 
489
520
  // --- src/client/styles.js ---
490
521
  function ensureCss() {
@@ -494,88 +525,97 @@ function ensureCss() {
494
525
  style.id = 'dsh-clinebot-full-css'
495
526
  style.dataset.dshPlugin = NS
496
527
  style.textContent = `
497
- .cb-page{display:flex;flex-direction:column;gap:20px;padding:8px 0 32px;max-width:960px}
498
- .cb-chevron{display:inline-flex;align-items:center;justify-content:center;transition:transform .16s ease;color:var(--dsw-alias-label-tertiary)}
499
- .cb-chevron-open{transform:rotate(180deg)}
500
- .cb-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}
501
- .cb-page-title{font-size:22px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}
502
- .cb-page-sub{font-size:14px;color:var(--dsw-alias-label-secondary);line-height:1.5}
503
-
504
- .cb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:14px}
505
- .cb-section-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}
506
- .cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-6px;line-height:1.4}
507
-
508
- .cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
509
- .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:14px}
510
-
511
- .cb-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
512
- .cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}
513
- .cb-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent)}
514
- .cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}
515
-
516
- .cb-input{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;width:100%;box-sizing:border-box}
528
+ .cb-page{display:flex;flex-direction:column;gap:16px;width:100%}
529
+ .cb-header{display:flex;flex-direction:column;gap:4px;padding:2px 0 4px}
530
+ .cb-page-title{display:flex;flex-wrap:wrap;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:600;line-height:24px}
531
+ .cb-page-sub{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
532
+
533
+ .cb-section-card{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}
534
+ .cb-section-title{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 12px;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
535
+ .cb-section-desc{margin-top:-6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
536
+
537
+ .cb-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
538
+ .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:12px}
539
+
540
+ .cb-badge{display:inline-flex;align-items:center;gap:4px;flex:none;padding:1px 6px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xs);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;font-weight:400}
541
+ .cb-badge-ok{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent)}
542
+ .cb-badge-warn{color:var(--dsw-alias-state-warn-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent)}
543
+ .cb-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)}
544
+
545
+ .cb-input{box-sizing:border-box;width:100%;height:36px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px}
546
+ .cb-input::placeholder{color:var(--dsw-alias-label-tertiary)}
517
547
  .cb-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
518
- .cb-input-group{display:flex;gap:8px;align-items:center}
519
-
520
- .cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 14px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
521
- .cb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
522
- .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
523
- .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88;visibility:visible !important}
524
- .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}
525
- .cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent) !important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}
526
- .cb-btn-disabled{opacity:0.5;cursor:not-allowed}
527
-
528
- .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
529
- .cb-progress-head, .cb-bar-head{display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary);width:100%}
548
+ .cb-input-group{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
549
+ .cb-input-group > .cb-input{flex:1 1 240px;min-width:0}
550
+ .cb-input-group > .cb-btn{flex:none}
551
+
552
+ .cb-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;white-space:nowrap;cursor:pointer}
553
+ .cb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
554
+ .cb-btn:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
555
+ .cb-btn:disabled{opacity:.5;cursor:not-allowed}
556
+ .cb-btn-primary{border-color:transparent;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-2)}
557
+ .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary);opacity:.88}
558
+ .cb-btn-danger{color:var(--dsw-alias-state-error-primary)}
559
+ .cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}
560
+ .cb-btn-disabled{opacity:.5;cursor:not-allowed}
561
+
562
+ .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
563
+ .cb-progress-head, .cb-bar-head{display:flex;justify-content:space-between;align-items:baseline;gap:16px;width:100%;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
530
564
  .cb-progress-label{font-weight:500;white-space:nowrap}
531
- .cb-progress-meta, .cb-bar-meta{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;margin-left:auto}
532
- .cb-progress-track, .cb-bar-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
533
- .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-brand-primary, var(--dsw-alias-state-success-primary))}
534
- .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warn-primary)}
535
- .cb-progress-bar-bad{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-error-primary)}
536
-
537
- .cb-table{width:100%;border-collapse:collapse;margin-top:8px}
538
- .cb-table th{text-align:left;font-size:12px;color:var(--dsw-alias-label-secondary);padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);font-weight:600}
539
- .cb-table td{padding:10px;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:13px;color:var(--dsw-alias-label-primary)}
540
- .cb-table tr:hover{background:var(--dsw-alias-bg-layer-2)}
541
-
542
- .cb-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px}
543
- .cb-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
544
- .cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
545
- .cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
546
- .cb-banner-exhausted{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:600}
547
- .cb-stat-box{padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:4px}
548
- .cb-stat-val{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary)}
549
- .cb-stat-lbl{font-size:12px;color:var(--dsw-alias-label-secondary)}
550
- .cb-preview{padding:12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-family:monospace;font-size:12px;white-space:pre-wrap;word-break:break-all;border:1px solid var(--dsw-alias-border-l2)}
551
-
552
- [class*="_footArea"] [class*="_footerActions"]{flex-direction:column}
553
- .cb-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:transparent;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex}
554
- .cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
555
- .cb-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
556
- .cb-footTop{align-items:center;gap:8px;min-width:0;display:flex}
557
- .cb-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px}
558
- .cb-footRow{flex-direction:column;gap:4px;min-width:0;display:flex}
559
- .cb-footHead{align-items:baseline;gap:8px;min-width:0;display:flex}
565
+ .cb-progress-meta, .cb-bar-meta{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:nowrap}
566
+ .cb-progress-track, .cb-bar-track{width:100%;height:6px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
567
+ .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
568
+ .cb-progress-bar-warn{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-state-warn-primary);transition:width .3s ease}
569
+ .cb-progress-bar-bad{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-state-error-primary);transition:width .3s ease}
570
+
571
+ .cb-table{width:100%;border-collapse:collapse;margin-top:4px}
572
+ .cb-table th{padding:8px 10px;border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);text-align:left;font-size:12px;font-weight:500;line-height:18px}
573
+ .cb-table td{padding:10px;border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px}
574
+ .cb-table tr:hover{background:var(--dsw-alias-interactive-bg-hover)}
575
+
576
+ .cb-alert{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:var(--dsw-radius-md);font-size:12px;line-height:18px}
577
+ .cb-alert-ok{padding:10px 12px;border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:18px}
578
+ .cb-alert-bad{padding:10px 12px;border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
579
+ .cb-alert-err{padding:10px 12px;border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
580
+ .cb-banner-warning{display:flex;align-items:center;gap:8px;padding:10px 12px;border:.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent);border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent);color:var(--dsw-alias-state-warn-primary);font-size:12px;font-weight:500;line-height:18px}
581
+ .cb-banner-exhausted{display:flex;align-items:center;gap:8px;padding:10px 12px;border:.5px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px;font-weight:600;line-height:18px}
582
+ .cb-stat-box{display:flex;flex-direction:column;gap:2px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
583
+ .cb-stat-val{color:var(--dsw-alias-label-primary);font-size:16px;font-weight:600;line-height:24px}
584
+ .cb-stat-lbl{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
585
+ .cb-preview{padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);font-family:var(--ds-font-family-code,monospace);font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-all}
586
+
587
+ /* The shell lays the footer action list out as a row, which a full-width meter
588
+ cannot share: with another plugin's entry beside it the plan name truncates and
589
+ the window label wraps. Stack the list while this meter is in it. The :has()
590
+ scoping keeps the rule off any footer that does not contain this plugin, so it
591
+ cannot reach across other registrants the way a plain descendant selector would. */
592
+ [class*="_footerActions"]:has(.cb-foot), [class*="_footerActions"]:has(.cb-railButton){flex-direction:column}
593
+ .cb-foot{box-sizing:border-box;flex:1 1 auto;width:100%;min-width:0;display:flex;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;text-align:left;cursor:pointer}
594
+ .cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3)}
595
+ .cb-foot:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
596
+ .cb-footTop{display:flex;align-items:center;gap:8px;min-width:0}
597
+ .cb-footName{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:20px;white-space:nowrap;text-overflow:ellipsis}
598
+ .cb-footRow{display:flex;flex-direction:column;gap:4px;min-width:0}
599
+ .cb-footHead{display:flex;align-items:baseline;gap:8px;min-width:0}
560
600
  .cb-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
561
601
  .cb-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}
562
- .cb-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden}
563
- .cb-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}
602
+ .cb-footBar{display:block;height:4px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
603
+ .cb-footFill{display:block;height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
564
604
  .cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
565
605
  .cb-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
566
- .cb-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:transparent;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex}
606
+ .cb-railButton{box-sizing:border-box;flex:none;display:inline-flex;justify-content:center;align-items:center;width:36px;height:36px;margin:0 0 4px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
567
607
  .cb-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
568
- .cb-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
569
- .cb-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)}
608
+ .cb-railButton:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
609
+ .cb-glyph{flex:none;display:inline-flex;justify-content:center;align-items:center;color:var(--dsw-alias-brand-primary)}
570
610
 
571
- .cb-mainOverlay{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block}
572
- .cb-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)}
573
- .cb-mainHeader{align-items:center;gap:10px;display:flex;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:14px}
574
- .cb-mainTitle{margin:0;font-size:18px;font-weight:600;line-height:1.4}
575
- .cb-mainSub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}
576
- .cb-mainCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:14px}
611
+ /* The window chrome and its width belong to the shell's Modal, so the body only
612
+ fills whatever it is given — a fixed width here overflows the panel. */
613
+ .cb-dialog{display:flex;flex-direction:column;gap:12px;width:100%;min-width:0;max-height:min(72vh, 640px);overflow-y:auto;color:var(--dsw-alias-label-primary)}
614
+ .cb-dialogMeta{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
615
+ .cb-dialogCard{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}
616
+ .cb-dialogFallback{position:fixed;inset:0;z-index:900;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1)}
617
+ .cb-dialogFallbackPanel{display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 96px);overflow-y:auto;padding:18px;border-radius:var(--dsw-radius-panel);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
577
618
 
578
- .cb-providerCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px;margin:8px 0}
579
619
  `
580
620
  document.head.appendChild(style)
581
621
  }
@@ -950,6 +990,40 @@ function AccountsSection({
950
990
  const [deleteSecret, setDeleteSecret] = React.useState(true)
951
991
  const [routeModel, setRouteModel] = React.useState('')
952
992
  const [routeAccount, setRouteAccount] = React.useState('')
993
+ const [discoveredKeys, setDiscoveredKeys] = React.useState([])
994
+
995
+ React.useEffect(() => {
996
+ let active = true
997
+ fetchWithTimeout(`${ROUTE_PREFIX}/keys/discovered`, { cache: 'no-store' })
998
+ .then((r) => r.json())
999
+ .then((data) => {
1000
+ if (active && data?.ok && Array.isArray(data.discovered)) {
1001
+ setDiscoveredKeys(data.discovered)
1002
+ }
1003
+ })
1004
+ .catch(() => {})
1005
+ return () => { active = false }
1006
+ }, [])
1007
+
1008
+ async function handleImportDiscovered(item) {
1009
+ let max = 1
1010
+ for (const a of (status.accounts || [])) {
1011
+ const m = String(a.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
1012
+ if (m) {
1013
+ const n = parseInt(m[1], 10)
1014
+ if (n >= max) max = n + 1
1015
+ }
1016
+ }
1017
+ const targetEnv = `CLINEBOT_API_KEY_${max}`
1018
+ if (typeof handleAddAccount === 'function') {
1019
+ await handleAddAccount({
1020
+ label: item.source || targetEnv,
1021
+ apiKeyEnv: targetEnv,
1022
+ apiKey: item.key,
1023
+ })
1024
+ setDiscoveredKeys((prev) => prev.filter((k) => k.key !== item.key))
1025
+ }
1026
+ }
953
1027
 
954
1028
  function handleOpenAdd() {
955
1029
  let max = 1
@@ -1011,6 +1085,31 @@ function AccountsSection({
1011
1085
  : null
1012
1086
  ),
1013
1087
  React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
1088
+ discoveredKeys.length > 0
1089
+ ? React.createElement(
1090
+ 'div',
1091
+ { className: 'cb-alert-ok', style: { display: 'flex', flexDirection: 'column', gap: '6px', margin: '6px 0' } },
1092
+ React.createElement('div', { style: { fontWeight: 600 } }, `🔍 ${t('discovery.title')}`),
1093
+ discoveredKeys.map((item, idx) =>
1094
+ React.createElement(
1095
+ 'div',
1096
+ { key: idx, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1097
+ React.createElement('span', null, `${item.maskedKey} (${item.source})`),
1098
+ React.createElement(
1099
+ 'button',
1100
+ {
1101
+ type: 'button',
1102
+ className: 'cb-btn',
1103
+ style: { fontSize: '11px', padding: '2px 8px' },
1104
+ disabled: !!busy,
1105
+ onClick: () => handleImportDiscovered(item),
1106
+ },
1107
+ t('discovery.import_btn', { source: item.source })
1108
+ )
1109
+ )
1110
+ )
1111
+ )
1112
+ : null,
1014
1113
  React.createElement(
1015
1114
  'div',
1016
1115
  {
@@ -1454,18 +1553,17 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1454
1553
  ),
1455
1554
  React.createElement(
1456
1555
  'div',
1457
- { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '4px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1556
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', marginTop: '4px', paddingTop: '16px', borderTop: '.5px solid var(--dsw-alias-border-l2)' } },
1458
1557
  React.createElement(
1459
1558
  'div',
1460
- { style: { display: 'flex', flexDirection: 'column' } },
1461
- React.createElement('span', { style: { fontSize: '13px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, t('sidebar.show_quota')),
1462
- React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, t('sidebar.show_quota_hint'))
1559
+ { style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
1560
+ React.createElement('span', { style: { fontSize: '14px', lineHeight: '20px', color: 'var(--dsw-alias-label-primary)' } }, t('sidebar.show_quota')),
1561
+ React.createElement('span', { style: { marginTop: '4px', fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' } }, t('sidebar.show_quota_hint'))
1463
1562
  ),
1464
- React.createElement('input', {
1465
- type: 'checkbox',
1466
- style: { cursor: 'pointer', width: '16px', height: '16px' },
1563
+ React.createElement(BooleanControl, {
1467
1564
  checked: showSidebarQuota !== false,
1468
- onChange: (e) => onToggleSidebarQuota && onToggleSidebarQuota(e.target.checked),
1565
+ label: t('sidebar.show_quota'),
1566
+ onChange: (next) => onToggleSidebarQuota && onToggleSidebarQuota(next),
1469
1567
  })
1470
1568
  )
1471
1569
  )
@@ -1735,17 +1833,21 @@ function VisibleModelsSub({
1735
1833
  },
1736
1834
  React.createElement(
1737
1835
  'div',
1738
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px' } },
1836
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' } },
1739
1837
  React.createElement(
1740
- 'label',
1741
- { style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '13px', fontWeight: '500' } },
1742
- React.createElement('input', {
1743
- type: 'checkbox',
1838
+ 'div',
1839
+ { style: { display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 } },
1840
+ React.createElement(
1841
+ 'span',
1842
+ { style: { fontSize: '14px', lineHeight: '20px', color: 'var(--dsw-alias-label-primary)' } },
1843
+ t('models.filter_by_plan')
1844
+ ),
1845
+ React.createElement(BooleanControl, {
1744
1846
  checked: !!filterModelsByPlan,
1745
1847
  disabled: !!busy,
1746
- onChange: (e) => onConfigUpdate && onConfigUpdate({ filterModelsByPlan: e.target.checked }),
1747
- }),
1748
- React.createElement('span', null, t('models.filter_by_plan'))
1848
+ label: t('models.filter_by_plan'),
1849
+ onChange: (next) => onConfigUpdate && onConfigUpdate({ filterModelsByPlan: next }),
1850
+ })
1749
1851
  ),
1750
1852
  React.createElement(
1751
1853
  'div',
@@ -2589,25 +2691,25 @@ function DiagSection({
2589
2691
  )
2590
2692
  ),
2591
2693
  React.createElement(
2592
- 'label',
2593
- { style: { display: 'inline-flex', alignItems: 'center', gap: '8px', cursor: 'pointer', whiteSpace: 'nowrap' } },
2594
- React.createElement('input', {
2595
- type: 'checkbox',
2596
- checked: !!proxyMode,
2597
- disabled: !!busy,
2598
- onChange: (e) => onToggleProxy && onToggleProxy(e.target.checked),
2599
- }),
2694
+ 'div',
2695
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px' } },
2600
2696
  React.createElement(
2601
2697
  'span',
2602
2698
  {
2603
2699
  style: {
2604
- fontSize: '13px',
2605
- fontWeight: 500,
2700
+ fontSize: '14px',
2701
+ lineHeight: '20px',
2606
2702
  color: proxyMode ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
2607
2703
  },
2608
2704
  },
2609
2705
  proxyMode ? t('diag.proxy_enabled') : t('diag.proxy_disabled')
2610
- )
2706
+ ),
2707
+ React.createElement(BooleanControl, {
2708
+ checked: !!proxyMode,
2709
+ disabled: !!busy,
2710
+ label: t('diag.proxy_title'),
2711
+ onChange: (next) => onToggleProxy && onToggleProxy(next),
2712
+ })
2611
2713
  )
2612
2714
  ),
2613
2715
  React.createElement(
@@ -2700,10 +2802,14 @@ function MiniRing({ percent, size = 18, warn = false }) {
2700
2802
  )
2701
2803
  }
2702
2804
 
2703
- function SidebarQuota({ ctx, wide, open, t: propT }) {
2805
+ function SidebarQuota({ ctx, wide, t: propT }) {
2704
2806
  const t = propT || makeT(en, en)
2705
2807
  const [data, setData] = React.useState(null)
2706
- const [visible, setVisible] = React.useState(true)
2808
+ const [open, setOpen] = React.useState(false)
2809
+
2810
+ // Check the configForms snapshot for the showSidebarQuota toggle
2811
+ const configSnapshot = useConfigFormsSnapshot(ctx)
2812
+ const show = configSnapshot?.value?.showSidebarQuota !== false
2707
2813
 
2708
2814
  const loadQuota = React.useCallback(async () => {
2709
2815
  try {
@@ -2716,7 +2822,9 @@ function SidebarQuota({ ctx, wide, open, t: propT }) {
2716
2822
  }
2717
2823
  }, [])
2718
2824
 
2825
+ // Poll only while the meter is on screen — a hidden meter has nothing to refresh.
2719
2826
  React.useEffect(() => {
2827
+ if (!show) return undefined
2720
2828
  let timer = null
2721
2829
  const scheduleNext = () => {
2722
2830
  timer = setTimeout(async () => {
@@ -2727,12 +2835,9 @@ function SidebarQuota({ ctx, wide, open, t: propT }) {
2727
2835
  loadQuota()
2728
2836
  scheduleNext()
2729
2837
  return () => { if (timer) clearTimeout(timer) }
2730
- }, [loadQuota])
2838
+ }, [loadQuota, show])
2731
2839
 
2732
- // Check configForms snapshot for showSidebarQuota toggle
2733
- const config = useConfigFormsSnapshot(ctx)
2734
- const show = config?.showSidebarQuota !== false
2735
- if (!show || !visible) return null
2840
+ if (!show) return null
2736
2841
 
2737
2842
  const fiveHour = data?.windows?.fiveHour
2738
2843
  const weekly = data?.windows?.weekly
@@ -2741,85 +2846,101 @@ function SidebarQuota({ ctx, wide, open, t: propT }) {
2741
2846
  const isWarn = pct >= 85
2742
2847
  const planName = data?.plan || t('title')
2743
2848
 
2744
- const handleOpen = () => {
2745
- if (typeof open === 'function') {
2746
- open()
2747
- } else {
2748
- ctx?.get?.('layout')?.selectPanel?.('clinebot-dashboard')
2749
- }
2750
- }
2849
+ // The window is local to the meter: nothing is selected or navigated, so the
2850
+ // conversation stays where it is and closing simply drops the window.
2851
+ const handleOpen = () => setOpen(true)
2751
2852
 
2752
2853
  const tooltip = `${t('sidebar.quota_title')}: ${pct}% ${t('dashboard.used')} (${planName})`
2854
+ const dialog = React.createElement(DashboardDialog, { t, open, onClose: () => setOpen(false) })
2753
2855
 
2754
2856
  if (!wide) {
2755
2857
  return React.createElement(
2858
+ React.Fragment,
2859
+ null,
2860
+ React.createElement(
2861
+ 'button',
2862
+ {
2863
+ type: 'button',
2864
+ className: 'cb-railButton',
2865
+ 'aria-label': tooltip,
2866
+ 'aria-expanded': open,
2867
+ title: tooltip,
2868
+ onClick: handleOpen,
2869
+ },
2870
+ React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 20 })
2871
+ ),
2872
+ dialog
2873
+ )
2874
+ }
2875
+
2876
+ return React.createElement(
2877
+ React.Fragment,
2878
+ null,
2879
+ React.createElement(
2756
2880
  'button',
2757
2881
  {
2758
2882
  type: 'button',
2759
- className: 'cb-railButton',
2883
+ className: 'cb-foot',
2760
2884
  'aria-label': tooltip,
2885
+ 'aria-expanded': open,
2761
2886
  title: tooltip,
2762
2887
  onClick: handleOpen,
2763
2888
  },
2764
- React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 20 })
2765
- )
2766
- }
2767
-
2768
- return React.createElement(
2769
- 'button',
2770
- {
2771
- type: 'button',
2772
- className: 'cb-foot',
2773
- 'aria-label': tooltip,
2774
- title: tooltip,
2775
- onClick: handleOpen,
2776
- },
2777
- React.createElement(
2778
- 'div',
2779
- { className: 'cb-footTop' },
2780
- React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
2781
- React.createElement('span', { className: 'cb-footName' }, planName),
2782
- React.createElement('span', { className: 'cb-footPct', style: { marginLeft: 'auto', fontWeight: 600, color: isWarn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)' } }, `${pct}%`)
2783
- ),
2784
- React.createElement(
2785
- 'div',
2786
- { className: 'cb-footRow' },
2787
2889
  React.createElement(
2788
2890
  'div',
2789
- { className: 'cb-footHead' },
2790
- React.createElement('span', { className: 'cb-footLabel' }, t('dashboard.rolling_window')),
2791
- React.createElement('span', { className: 'cb-footAmount' }, `${100 - pct}% ${t('dashboard.remaining')}`)
2891
+ { className: 'cb-footTop' },
2892
+ React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
2893
+ React.createElement('span', { className: 'cb-footName' }, planName),
2894
+ React.createElement('span', { className: 'cb-footPct', style: { marginLeft: 'auto', fontWeight: 600, color: isWarn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)' } }, `${pct}%`)
2792
2895
  ),
2793
2896
  React.createElement(
2794
- 'span',
2795
- { className: 'cb-footBar' },
2796
- React.createElement('span', {
2797
- className: isWarn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
2798
- style: { width: `${Math.min(100, Math.max(2, pct))}%` },
2799
- })
2897
+ 'div',
2898
+ { className: 'cb-footRow' },
2899
+ React.createElement(
2900
+ 'div',
2901
+ { className: 'cb-footHead' },
2902
+ React.createElement('span', { className: 'cb-footLabel' }, t('dashboard.rolling_window')),
2903
+ React.createElement('span', { className: 'cb-footAmount' }, `${100 - pct}% ${t('dashboard.remaining')}`)
2904
+ ),
2905
+ React.createElement(
2906
+ 'span',
2907
+ { className: 'cb-footBar' },
2908
+ React.createElement('span', {
2909
+ className: isWarn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
2910
+ style: { width: `${Math.min(100, Math.max(2, pct))}%` },
2911
+ })
2912
+ )
2800
2913
  )
2801
- )
2914
+ ),
2915
+ dialog
2802
2916
  )
2803
2917
  }
2804
2918
 
2805
- // --- src/client/components/dashboard-overlay.js ---
2806
- function DashboardOverlay(props) {
2807
- const ctx = props?.ctx
2919
+ // --- src/client/components/dashboard-dialog.js ---
2920
+ /**
2921
+ * The quota & status window, opened from the sidebar meter.
2922
+ *
2923
+ * This used to be a `main`-slot panel: clicking the meter called
2924
+ * `layout.selectPanel('clinebot-dashboard')`, which swapped the conversation out
2925
+ * of the centre column for a full-bleed dashboard. A status read-out has no
2926
+ * business replacing the conversation, so it is a window now — the shell's own
2927
+ * `Modal`, which brings the backdrop, the close affordance, Escape and focus
2928
+ * handling with it.
2929
+ *
2930
+ * `open` is owned by the caller (the meter), and the data is fetched on open
2931
+ * rather than on mount, so a closed window costs nothing.
2932
+ */
2933
+ function DashboardDialog(props) {
2808
2934
  const t = props?.t || makeT(en, en)
2935
+ const open = props?.open !== false
2936
+ const onClose = typeof props?.onClose === 'function' ? props.onClose : () => {}
2937
+
2809
2938
  const [data, setData] = React.useState(null)
2810
2939
  const [status, setStatus] = React.useState(null)
2811
2940
  const [loading, setLoading] = React.useState(false)
2812
2941
  const [updatedAt, setUpdatedAt] = React.useState('')
2813
2942
  const [err, setErr] = React.useState('')
2814
2943
 
2815
- const handleClose = () => {
2816
- if (typeof props?.close === 'function') {
2817
- props.close()
2818
- } else {
2819
- ctx?.get?.('layout')?.selectPanel?.(null)
2820
- }
2821
- }
2822
-
2823
2944
  const loadData = React.useCallback(async () => {
2824
2945
  setLoading(true)
2825
2946
  setErr('')
@@ -2841,299 +2962,294 @@ function DashboardOverlay(props) {
2841
2962
  }, [])
2842
2963
 
2843
2964
  React.useEffect(() => {
2844
- loadData()
2845
- }, [loadData])
2965
+ if (open) loadData()
2966
+ }, [open, loadData])
2967
+
2968
+ if (!open) return null
2846
2969
 
2847
2970
  const plan = data?.plan || status?.config?.defaultModel || 'ClinePass'
2848
2971
  const email = data?.user?.email || ''
2849
2972
  const activeAcc = status?.config?.activeAccount || 'Auto (Least-used)'
2850
2973
  const enabledCount = status?.config?.enabledModels?.length || 0
2974
+ const routingMode = status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used')
2851
2975
 
2852
- return React.createElement(
2976
+ const body = React.createElement(
2853
2977
  'div',
2854
- { className: 'cb-mainOverlay', role: 'region', 'aria-label': t('dashboard.title') },
2978
+ { className: 'cb-dialog' },
2855
2979
  React.createElement(
2856
2980
  'div',
2857
- { className: 'cb-mainInner' },
2858
- // Header
2981
+ { className: 'cb-dialogMeta' },
2982
+ updatedAt ? React.createElement('span', null, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
2983
+ React.createElement('span', { style: { flex: 1 } }),
2859
2984
  React.createElement(
2860
- 'header',
2861
- { className: 'cb-mainHeader' },
2862
- React.createElement(
2863
- 'div',
2864
- { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 } },
2865
- React.createElement('h2', { className: 'cb-mainTitle' }, t('dashboard.title')),
2866
- React.createElement('p', { className: 'cb-mainSub' }, t('dashboard.subtitle'))
2867
- ),
2868
- React.createElement('span', { style: { flex: 1 } }),
2869
- updatedAt
2870
- ? React.createElement(
2871
- 'span',
2872
- { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', fontVariantNumeric: 'tabular-nums' } },
2873
- `${t('dashboard.updated_at')} ${updatedAt}`
2874
- )
2875
- : null,
2876
- React.createElement(
2877
- 'button',
2878
- {
2879
- type: 'button',
2880
- className: 'cb-btn',
2881
- disabled: loading,
2882
- onClick: loadData,
2883
- },
2884
- loading ? t('dashboard.refreshing') : t('dashboard.refresh')
2885
- ),
2886
- React.createElement(
2887
- 'button',
2888
- {
2889
- type: 'button',
2890
- className: 'cb-btn',
2891
- style: { minWidth: '32px', padding: '0 8px', fontSize: '16px' },
2892
- 'aria-label': t('dashboard.close'),
2893
- title: t('dashboard.close'),
2894
- onClick: handleClose,
2895
- },
2896
- '✕'
2897
- )
2898
- ),
2985
+ 'button',
2986
+ { type: 'button', className: 'cb-btn', disabled: loading, onClick: loadData },
2987
+ loading ? t('dashboard.refreshing') : t('dashboard.refresh')
2988
+ )
2989
+ ),
2899
2990
 
2900
- err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
2991
+ err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
2901
2992
 
2902
- // Card 1: Account & Plan Status
2993
+ // Account and plan
2994
+ React.createElement(
2995
+ 'div',
2996
+ { className: 'cb-dialogCard' },
2903
2997
  React.createElement(
2904
2998
  'div',
2905
- { className: 'cb-mainCard' },
2999
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
2906
3000
  React.createElement(
2907
3001
  'div',
2908
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
2909
- React.createElement(
2910
- 'div',
2911
- { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
2912
- React.createElement('span', { style: { fontSize: '20px' } }, '💳'),
2913
- React.createElement(
2914
- 'div',
2915
- null,
2916
- React.createElement('div', { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, plan),
2917
- email ? React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, email) : null
2918
- )
2919
- ),
2920
- React.createElement(
2921
- 'span',
2922
- { className: 'cb-badge cb-badge-ok' },
2923
- t('badge.registered', { count: enabledCount })
2924
- )
3002
+ { style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
3003
+ React.createElement('div', { className: 'cb-row-title' }, plan),
3004
+ email ? React.createElement('div', { className: 'cb-row-desc' }, email) : null
2925
3005
  ),
2926
- React.createElement(
2927
- 'div',
2928
- { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px', marginTop: '6px' } },
2929
- React.createElement(
2930
- 'div',
2931
- { className: 'cb-stat-box' },
2932
- React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
2933
- React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px', wordBreak: 'break-all' } }, activeAcc)
2934
- ),
2935
- React.createElement(
2936
- 'div',
2937
- { className: 'cb-stat-box' },
2938
- React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
2939
- React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px' } }, status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used'))
2940
- )
2941
- )
3006
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
2942
3007
  ),
2943
-
2944
- // Card 2: Quota Windows
2945
3008
  React.createElement(
2946
3009
  'div',
2947
- { className: 'cb-mainCard' },
3010
+ { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px' } },
2948
3011
  React.createElement(
2949
3012
  'div',
2950
- { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
2951
- t('quota.title')
3013
+ { className: 'cb-stat-box' },
3014
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
3015
+ React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px', wordBreak: 'break-all' } }, activeAcc)
2952
3016
  ),
2953
3017
  React.createElement(
2954
3018
  'div',
2955
- { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
2956
- data?.windows?.fiveHour
2957
- ? React.createElement(ProgressBar, {
2958
- label: t('quota.window_5h'),
2959
- percentUsed: data.windows.fiveHour.percentUsed || 0,
2960
- remainingPercent: data.windows.fiveHour.remainingPercent || 100,
2961
- resetsAt: data.windows.fiveHour.resetsAt,
2962
- t,
2963
- })
2964
- : null,
2965
- React.createElement(ProgressBar, {
2966
- label: t('quota.window_weekly'),
2967
- percentUsed: data?.windows?.weekly?.percentUsed || 0,
2968
- remainingPercent: data?.windows?.weekly?.remainingPercent || 100,
2969
- resetsAt: data?.windows?.weekly?.resetsAt,
2970
- t,
2971
- }),
2972
- data?.windows?.monthly
2973
- ? React.createElement(ProgressBar, {
2974
- label: t('quota.window_monthly'),
2975
- percentUsed: data.windows.monthly.percentUsed || 0,
2976
- remainingPercent: data.windows.monthly.remainingPercent || 100,
2977
- resetsAt: data.windows.monthly.resetsAt,
2978
- t,
2979
- })
2980
- : null
3019
+ { className: 'cb-stat-box' },
3020
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
3021
+ React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px' } }, routingMode)
2981
3022
  )
2982
3023
  )
3024
+ ),
3025
+
3026
+ // Quota windows
3027
+ React.createElement(
3028
+ 'div',
3029
+ { className: 'cb-dialogCard' },
3030
+ React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
3031
+ React.createElement(
3032
+ 'div',
3033
+ { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
3034
+ data?.windows?.fiveHour
3035
+ ? React.createElement(ProgressBar, {
3036
+ label: t('quota.window_5h'),
3037
+ percentUsed: data.windows.fiveHour.percentUsed || 0,
3038
+ remainingPercent: data.windows.fiveHour.remainingPercent || 100,
3039
+ resetsAt: data.windows.fiveHour.resetsAt,
3040
+ t,
3041
+ })
3042
+ : null,
3043
+ React.createElement(ProgressBar, {
3044
+ label: t('quota.window_weekly'),
3045
+ percentUsed: data?.windows?.weekly?.percentUsed || 0,
3046
+ remainingPercent: data?.windows?.weekly?.remainingPercent || 100,
3047
+ resetsAt: data?.windows?.weekly?.resetsAt,
3048
+ t,
3049
+ }),
3050
+ data?.windows?.monthly
3051
+ ? React.createElement(ProgressBar, {
3052
+ label: t('quota.window_monthly'),
3053
+ percentUsed: data.windows.monthly.percentUsed || 0,
3054
+ remainingPercent: data.windows.monthly.remainingPercent || 100,
3055
+ resetsAt: data.windows.monthly.resetsAt,
3056
+ t,
3057
+ })
3058
+ : null
3059
+ )
3060
+ )
3061
+ )
3062
+
3063
+ if (Modal) {
3064
+ return React.createElement(Modal, {
3065
+ open: true,
3066
+ onClose,
3067
+ title: t('dashboard.title'),
3068
+ description: t('dashboard.subtitle'),
3069
+ closeLabel: t('dashboard.close'),
3070
+ children: body,
3071
+ })
3072
+ }
3073
+
3074
+ // Older shells without the primitive still get a window, just a plainer one.
3075
+ return React.createElement(
3076
+ 'div',
3077
+ { className: 'cb-dialogFallback', role: 'dialog', 'aria-label': t('dashboard.title'), onClick: onClose },
3078
+ React.createElement(
3079
+ 'div',
3080
+ { className: 'cb-dialogFallbackPanel', onClick: (e) => e.stopPropagation() },
3081
+ React.createElement(
3082
+ 'div',
3083
+ { className: 'cb-dialogMeta' },
3084
+ React.createElement('span', { className: 'cb-row-title' }, t('dashboard.title')),
3085
+ React.createElement('span', { style: { flex: 1 } }),
3086
+ React.createElement(
3087
+ 'button',
3088
+ { type: 'button', className: 'cb-btn', 'aria-label': t('dashboard.close'), onClick: onClose },
3089
+ t('dashboard.close')
3090
+ )
3091
+ ),
3092
+ body
2983
3093
  )
2984
3094
  )
2985
3095
  }
2986
3096
 
2987
- // --- src/client/components/models-provider-card.js ---
2988
- function ModelsProviderCard(props) {
2989
- const ctx = props?.ctx
2990
- const t = props?.t || makeT(en, en)
2991
- const [status, setStatus] = React.useState(null)
2992
- const [apiKey, setApiKey] = React.useState('')
2993
- const [showKey, setShowKey] = React.useState(false)
2994
- const [saving, setSaving] = React.useState(false)
2995
- const [msg, setMsg] = React.useState('')
2996
- const [err, setErr] = React.useState('')
3097
+ // --- src/client/components/composer-cost.js ---
3098
+ function formatTokens(count) {
3099
+ const n = Number(count) || 0
3100
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
3101
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
3102
+ return String(n)
3103
+ }
2997
3104
 
2998
- const loadStatus = React.useCallback(async () => {
2999
- try {
3000
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
3001
- if (!res.ok) return
3002
- const json = await res.json().catch(() => null)
3003
- if (json && json.ok) setStatus(json)
3004
- } catch {
3005
- // quiet on network fail
3006
- }
3007
- }, [])
3105
+ function calculateCost(tokens, modelId = '') {
3106
+ const isPremium = String(modelId).includes('claude') || String(modelId).includes('pro')
3107
+ // Rates per 1M tokens
3108
+ const promptRate = isPremium ? 3.0 : 0.15
3109
+ const completionRate = isPremium ? 15.0 : 0.60
3110
+ const reasoningRate = isPremium ? 15.0 : 0.60
3008
3111
 
3009
- React.useEffect(() => {
3010
- loadStatus()
3011
- }, [loadStatus])
3112
+ const pTok = Number(tokens?.promptTokens || tokens?.inputTokens || 0)
3113
+ const cTok = Number(tokens?.completionTokens || tokens?.outputTokens || 0)
3114
+ const rTok = Number(tokens?.reasoningTokens || 0)
3012
3115
 
3013
- const keyConfigured = Boolean(status?.keyPresent || status?.config?.hasKey)
3014
- const modelsCount = status?.config?.enabledModels?.length || status?.availableModels?.length || 0
3116
+ const cost = (pTok * promptRate + cTok * completionRate + rTok * reasoningRate) / 1000000
3117
+ return { cost, pTok, cTok, rTok, total: pTok + cTok + rTok }
3118
+ }
3015
3119
 
3016
- const handleSaveKey = async () => {
3017
- const keyVal = String(apiKey || '').trim()
3018
- if (!keyVal) {
3019
- setErr(t('key.empty_err'))
3020
- return
3021
- }
3022
- setSaving(true)
3023
- setErr('')
3024
- setMsg('')
3025
- try {
3026
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/save-key`, {
3027
- method: 'POST',
3028
- headers: { 'Content-Type': 'application/json' },
3029
- body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: status?.config?.apiKeyEnv }),
3030
- })
3031
- const data = await res.json().catch(() => ({}))
3032
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3033
- setApiKey('')
3034
- setMsg(t('card.key_saved'))
3035
- await loadStatus()
3036
- } catch (e) {
3037
- setErr(String(e?.message || e))
3038
- } finally {
3039
- setSaving(false)
3120
+ function ComposerCostDock(props) {
3121
+ const t = props?.t || makeT(en, en)
3122
+ const [stats, setStats] = React.useState(null)
3123
+ const [showPopover, setShowPopover] = React.useState(false)
3124
+
3125
+ // Use projection hook if passed by DSH composer dock, or poll session stats
3126
+ const projectedTokens = typeof props?.useProjection === 'function' ? props.useProjection('tokenUsage') : null
3127
+ const projectedModel = typeof props?.useProjection === 'function' ? props.useProjection('modelSelection') : null
3128
+
3129
+ React.useEffect(() => {
3130
+ if (projectedTokens) return
3131
+ let active = true
3132
+ const loadStats = async () => {
3133
+ try {
3134
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' })
3135
+ if (!res.ok) return
3136
+ const data = await res.json().catch(() => null)
3137
+ if (active && data?.stats?.summary) {
3138
+ setStats(data.stats.summary)
3139
+ }
3140
+ } catch {
3141
+ // quiet
3142
+ }
3040
3143
  }
3144
+ loadStats()
3145
+ const timer = setTimeout(loadStats, 30000)
3146
+ return () => { active = false; clearTimeout(timer) }
3147
+ }, [projectedTokens])
3148
+
3149
+ const tokens = projectedTokens || {
3150
+ promptTokens: stats?.promptTokens || 0,
3151
+ completionTokens: stats?.completionTokens || 0,
3152
+ reasoningTokens: stats?.reasoningTokens || 0,
3041
3153
  }
3042
3154
 
3043
- const handleOpenSettings = () => {
3044
- const layout = ctx?.get?.('layout')
3045
- if (typeof layout?.selectPanel === 'function') {
3046
- layout.selectPanel('clinebot-dashboard')
3047
- } else if (typeof props?.openSettings === 'function') {
3048
- props.openSettings()
3049
- }
3155
+ const modelId = projectedModel?.id || ''
3156
+ const { cost, pTok, cTok, rTok, total } = calculateCost(tokens, modelId)
3157
+
3158
+ if (total === 0 && !stats) {
3159
+ return null
3050
3160
  }
3051
3161
 
3162
+ const costDisplay = cost < 0.001 && cost > 0 ? '< $0.001' : `$${cost.toFixed(3)}`
3163
+
3052
3164
  return React.createElement(
3053
3165
  'div',
3054
- { className: 'cb-providerCard', 'data-clinebot-provider-card': 'true' },
3055
- // Header
3166
+ {
3167
+ className: 'cb-composer-cost-dock',
3168
+ 'data-composer-session-cost': 'true',
3169
+ style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3170
+ },
3056
3171
  React.createElement(
3057
- 'div',
3058
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3059
- React.createElement(
3060
- 'div',
3061
- { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
3062
- React.createElement('span', { style: { fontSize: '18px' } }, '🤖'),
3063
- React.createElement('span', { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t('card.title'))
3064
- ),
3065
- React.createElement(
3066
- 'div',
3067
- { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
3068
- React.createElement(
3069
- 'span',
3070
- { className: keyConfigured ? 'cb-badge cb-badge-ok' : 'cb-badge cb-badge-warn' },
3071
- keyConfigured ? `✓ ${t('card.status_connected')}` : `⚠️ ${t('card.status_no_key')}`
3072
- ),
3073
- modelsCount > 0
3074
- ? React.createElement(
3075
- 'span',
3076
- { className: 'cb-badge cb-badge-ok' },
3077
- t('card.models_active', { count: modelsCount })
3078
- )
3079
- : null
3080
- )
3172
+ 'button',
3173
+ {
3174
+ type: 'button',
3175
+ className: 'cb-btn',
3176
+ style: {
3177
+ padding: '2px 8px',
3178
+ fontSize: '11px',
3179
+ height: '24px',
3180
+ borderRadius: '999px',
3181
+ display: 'inline-flex',
3182
+ alignItems: 'center',
3183
+ gap: '4px',
3184
+ color: 'var(--dsw-alias-label-secondary)',
3185
+ background: 'var(--dsw-alias-bg-layer-2)',
3186
+ border: '1px solid var(--dsw-alias-border-l2)',
3187
+ cursor: 'pointer',
3188
+ },
3189
+ title: `${t('composer.cost_title')}: ${costDisplay} (${formatTokens(total)} tokens)`,
3190
+ onClick: () => setShowPopover(!showPopover),
3191
+ },
3192
+ React.createElement('span', { style: { fontSize: '12px' } }, '🪙'),
3193
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, `≈ ${costDisplay}`),
3194
+ React.createElement('span', { style: { opacity: 0.7 } }, `· ${formatTokens(total)} tok`)
3081
3195
  ),
3082
-
3083
- // Quick Key Input Form
3084
- React.createElement(
3085
- 'div',
3086
- { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
3087
- React.createElement(
3088
- 'div',
3089
- { className: 'cb-input-group' },
3090
- React.createElement('input', {
3091
- type: showKey ? 'text' : 'password',
3092
- className: 'cb-input',
3093
- style: { flex: 1 },
3094
- value: apiKey,
3095
- placeholder: keyConfigured ? t('key.placeholder_has') : t('key.placeholder_empty'),
3096
- onChange: (e) => setApiKey(e.target.value),
3097
- onKeyDown: (e) => { if (e.key === 'Enter') handleSaveKey() },
3098
- }),
3099
- React.createElement(
3100
- 'button',
3101
- {
3102
- type: 'button',
3103
- className: 'cb-btn',
3104
- onClick: () => setShowKey(!showKey),
3105
- },
3106
- showKey ? t('key.hide') : t('key.show')
3107
- ),
3108
- React.createElement(
3109
- 'button',
3196
+ showPopover
3197
+ ? React.createElement(
3198
+ 'div',
3110
3199
  {
3111
- type: 'button',
3112
- className: 'cb-btn cb-btn-primary',
3113
- disabled: saving || !apiKey.trim(),
3114
- onClick: handleSaveKey,
3200
+ className: 'cb-popover',
3201
+ style: {
3202
+ position: 'absolute',
3203
+ bottom: '100%',
3204
+ left: 0,
3205
+ marginBottom: '6px',
3206
+ minWidth: '200px',
3207
+ padding: '10px 12px',
3208
+ background: 'var(--dsw-alias-bg-layer-3)',
3209
+ border: '1px solid var(--dsw-alias-border-l2)',
3210
+ borderRadius: '8px',
3211
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
3212
+ zIndex: 100,
3213
+ fontSize: '12px',
3214
+ color: 'var(--dsw-alias-label-primary)',
3215
+ display: 'flex',
3216
+ flexDirection: 'column',
3217
+ gap: '6px',
3218
+ },
3115
3219
  },
3116
- saving ? t('key.saving') : t('card.save_key')
3220
+ React.createElement(
3221
+ 'div',
3222
+ { style: { fontWeight: 600, borderBottom: '1px solid var(--dsw-alias-border-l2)', paddingBottom: '4px' } },
3223
+ t('composer.cost_breakdown')
3224
+ ),
3225
+ React.createElement(
3226
+ 'div',
3227
+ { style: { display: 'flex', justifyContent: 'space-between' } },
3228
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.prompt_tok')),
3229
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(pTok))
3230
+ ),
3231
+ React.createElement(
3232
+ 'div',
3233
+ { style: { display: 'flex', justifyContent: 'space-between' } },
3234
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.completion_tok')),
3235
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(cTok))
3236
+ ),
3237
+ rTok > 0
3238
+ ? React.createElement(
3239
+ 'div',
3240
+ { style: { display: 'flex', justifyContent: 'space-between' } },
3241
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.reasoning_tok')),
3242
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(rTok))
3243
+ )
3244
+ : null,
3245
+ React.createElement(
3246
+ 'div',
3247
+ { style: { display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '4px', fontWeight: 600 } },
3248
+ React.createElement('span', null, t('composer.est_cost')),
3249
+ React.createElement('span', { style: { color: 'var(--dsw-alias-brand-primary)' } }, `≈ ${costDisplay}`)
3250
+ )
3117
3251
  )
3118
- ),
3119
- msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
3120
- err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null
3121
- ),
3122
-
3123
- // Footer with navigation to full settings
3124
- React.createElement(
3125
- 'div',
3126
- { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
3127
- React.createElement(
3128
- 'button',
3129
- {
3130
- type: 'button',
3131
- className: 'cb-btn',
3132
- onClick: handleOpenSettings,
3133
- },
3134
- `⚙️ ${t('card.open_settings')}`
3135
- )
3136
- )
3252
+ : null
3137
3253
  )
3138
3254
  }
3139
3255
 
@@ -3141,7 +3257,7 @@ function ModelsProviderCard(props) {
3141
3257
  function SettingsPage(props) {
3142
3258
  const ctx = props?.ctx
3143
3259
  const t = props?.t || makeT(en, en)
3144
- const snapshotStatus = useConfigFormsSnapshot(ctx)
3260
+ const configSnapshot = useConfigFormsSnapshot(ctx)
3145
3261
 
3146
3262
  const [status, setStatus] = React.useState(null)
3147
3263
  const [draft, setDraft] = React.useState(null)
@@ -3526,7 +3642,7 @@ function SettingsPage(props) {
3526
3642
  React.createElement(
3527
3643
  'div',
3528
3644
  { className: 'cb-page-title' },
3529
- `🤖 ${t('header.title')}`,
3645
+ t('header.title'),
3530
3646
  React.createElement(
3531
3647
  'span',
3532
3648
  { className: `cb-badge ${healthOk ? 'cb-badge-ok' : 'cb-badge-bad'}` },
@@ -3550,7 +3666,7 @@ function SettingsPage(props) {
3550
3666
  React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, handleCheckUpdate: checkUpdate, t }),
3551
3667
 
3552
3668
  // Settings host status warning
3553
- snapshotStatus === 'unavailable'
3669
+ configSnapshot?.status === 'unavailable'
3554
3670
  ? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, t('settings.unavailable'))
3555
3671
  : null,
3556
3672
 
@@ -3632,65 +3748,6 @@ function SettingsPage(props) {
3632
3748
  )
3633
3749
  }
3634
3750
 
3635
- // --- src/client/plugin-card.js ---
3636
- function PluginCard(props) {
3637
- const page = !!(props && props.view === 'page')
3638
- const [open, setOpen] = React.useState(!!page)
3639
- const t = props?.t || makeT(en, en)
3640
- React.useEffect(() => {
3641
- ensureCss()
3642
- }, [])
3643
-
3644
- // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
3645
- // padding, so the summary is a one-liner and the page drops our card chrome.
3646
- if (props && props.view === 'summary') {
3647
- return React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
3648
- }
3649
-
3650
- return React.createElement(
3651
- page ? 'div' : 'li',
3652
- { className: page ? 'cb-page' : 'cb-section-card', style: page ? undefined : { listStyle: 'none', marginBottom: '12px' } },
3653
- React.createElement(
3654
- 'button',
3655
- {
3656
- type: 'button',
3657
- style: {
3658
- background: 'none',
3659
- border: 'none',
3660
- cursor: 'pointer',
3661
- display: page ? 'none' : 'flex',
3662
- alignItems: 'center',
3663
- width: '100%',
3664
- padding: 0,
3665
- textAlign: 'left',
3666
- },
3667
- 'aria-expanded': page ? true : open,
3668
- onClick: () => setOpen((v) => !v),
3669
- },
3670
- React.createElement(
3671
- 'div',
3672
- { style: { flex: 1 } },
3673
- React.createElement('div', { style: { fontWeight: 600, fontSize: '15px' } }, t('title')),
3674
- React.createElement('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
3675
- ),
3676
- React.createElement('span', { className: 'cb-chevron' + (open ? ' cb-chevron-open' : '') },
3677
- React.createElement(Chevron)
3678
- )
3679
- ),
3680
- (page || open)
3681
- ? React.createElement(
3682
- 'div',
3683
- { style: { marginTop: '16px' } },
3684
- React.createElement(
3685
- ErrorBoundary,
3686
- { t },
3687
- React.createElement(SettingsPage, { ...props, ctx: props && props.ctx })
3688
- )
3689
- )
3690
- : null
3691
- )
3692
- }
3693
-
3694
3751
  // --- src/client/entry.js ---
3695
3752
  function ensureSettingsForm(ctx) {
3696
3753
  const svc = readConfigForms(ctx)
@@ -3703,6 +3760,12 @@ function ensureSettingsForm(ctx) {
3703
3760
  }
3704
3761
 
3705
3762
  function apply(ctx) {
3763
+ // The stylesheet has to be in the document before any of this plugin's slots
3764
+ // render. `sidebar.footer.action` and `main` mount on boot, so leaving the
3765
+ // injection to the settings page's mount effect renders those seats unstyled
3766
+ // until the user opens the plugin page.
3767
+ ensureCss()
3768
+
3706
3769
  const addLocale = (locale, dictionary) => {
3707
3770
  try {
3708
3771
  return ctx.locale.register(NS, locale, dictionary)
@@ -3755,50 +3818,31 @@ function apply(ctx) {
3755
3818
  }
3756
3819
  }
3757
3820
 
3758
- // The seat the Plugins page actually renders as the plugin's own page with the
3759
- // settings form: a card in the plugin list (`plugins.item`), exactly how
3760
- // @goodandready-private/dsh-agentrouter does it. `view: 'summary'` is the card's
3761
- // one-liner, `view: 'page'` is the body of that page.
3762
- registerSlotWhenReady('plugins.item', () =>
3821
+ // The plugin's configuration is a section of DSH Settings (`settings.section`),
3822
+ // a peer of General/Models/Plugins — not a row in the Plugins list and not a tab
3823
+ // inside the Plugins settings section. The shell owns the modal, the section nav
3824
+ // and the scrolling body, and hands a section `close` as its one affordance; the
3825
+ // section draws its own content. An unregistered id gets the shell's default
3826
+ // settings glyph in the nav.
3827
+ registerSlotWhenReady('settings.section', () =>
3763
3828
  ctx.slots.register(
3764
3829
  {
3765
- name: 'plugins.item',
3830
+ name: 'settings.section',
3766
3831
  id: NS,
3767
- order: 60,
3768
- label: () => 'clinebot',
3769
- locale: NS,
3770
- inject: () => ({ ctx }),
3771
- },
3772
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
3773
- )
3774
- )
3775
-
3776
- // Row seat first (the seat the current core renders), legacy seat after it.
3777
- // The core keys the seat as `<bundle name>#<row id>`; `bundle.name` may be the
3778
- // package name or the short bundle name depending on the manager's view, so both
3779
- // spellings are registered — the unused one is inert.
3780
- // Row seat: key = ROW_CONFIG_KEY ('@goodandready/dsh-clinebot#dsh-clinebot').
3781
- registerSlotWhenReady('plugins.row.config', () =>
3782
- ctx.slots.register(
3783
- {
3784
- name: 'plugins.row.config',
3785
- key: ROW_CONFIG_KEY,
3786
- locale: NS,
3787
- inject: () => ({ ctx }),
3788
- },
3789
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
3790
- )
3791
- )
3792
-
3793
- registerSlotWhenReady('settings.plugin.item', () =>
3794
- ctx.slots.register(
3795
- {
3796
- name: 'settings.plugin.item',
3797
- key: NS,
3832
+ order: 30,
3833
+ label: () => {
3834
+ try {
3835
+ const t = ctx.locale && typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
3836
+ const label = t ? t('nav') : ''
3837
+ return label && label !== 'nav' ? label : 'ClineBot'
3838
+ } catch (_) {
3839
+ return 'ClineBot'
3840
+ }
3841
+ },
3798
3842
  locale: NS,
3799
3843
  inject: () => ({ ctx }),
3800
3844
  },
3801
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
3845
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx }))
3802
3846
  )
3803
3847
  )
3804
3848
 
@@ -3816,33 +3860,32 @@ function apply(ctx) {
3816
3860
  )
3817
3861
  )
3818
3862
 
3819
- // Central Overlay Dashboard (main slot)
3820
- registerSlotWhenReady('main', () =>
3863
+ // The quota & status read-out is a window opened from the sidebar meter, not a
3864
+ // `main`-slot panel: occupying the centre column meant clicking the meter threw
3865
+ // the conversation out of the way to show a status read-out.
3866
+
3867
+ // `settings.models.provider-card` is deliberately not registered. The seat is
3868
+ // KEYED and dispatched with `entryKey = <provider settingsNs>` on every provider
3869
+ // row, unconditionally: the models page renders it before the row's own
3870
+ // `open ? editor : null`, and its owner props carry no open state. Anything
3871
+ // registered there therefore shows on the collapsed row and reads as an editor
3872
+ // that opened by itself. The row's 编辑 affordance already opens a provider
3873
+ // editor with the same API-key field, and this plugin's configuration lives in
3874
+ // its own Settings section, so the seat is left free.
3875
+
3876
+ // Composer Session Cost Dock (conversation.composer.dock slot)
3877
+ registerSlotWhenReady('conversation.composer.dock', () =>
3821
3878
  ctx.slots.register(
3822
3879
  {
3823
- name: 'main',
3824
- key: 'clinebot-dashboard',
3880
+ name: 'conversation.composer.dock',
3881
+ id: 'clinebot-cost',
3882
+ order: 10,
3825
3883
  locale: NS,
3826
3884
  inject: () => ({ ctx }),
3827
3885
  },
3828
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(DashboardOverlay, { ...props, ctx: (props && props.ctx) || ctx }))
3886
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(ComposerCostDock, { ...props, ctx: (props && props.ctx) || ctx }))
3829
3887
  )
3830
3888
  )
3831
-
3832
- // Settings Models Provider Card (settings.models.provider-card slot)
3833
- for (const pKey of ['clinebot', 'llm-clinebot']) {
3834
- registerSlotWhenReady('settings.models.provider-card', () =>
3835
- ctx.slots.register(
3836
- {
3837
- name: 'settings.models.provider-card',
3838
- key: pKey,
3839
- locale: NS,
3840
- inject: () => ({ ctx }),
3841
- },
3842
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(ModelsProviderCard, { ...props, ctx: (props && props.ctx) || ctx }))
3843
- )
3844
- )
3845
- }
3846
3889
  }
3847
3890
 
3848
3891
  module.exports = { apply, inject: ['slots', 'locale', 'configForms'] }