@goodandready/dsh-clinebot 0.5.33 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -51,7 +51,7 @@ const en = {
51
51
  'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
52
52
  'badge.online': 'Host online ({latency} ms)',
53
53
  'badge.offline': 'Host unreachable',
54
- 'badge.key_ok': 'Key ✓ ({source})',
54
+ 'badge.key_ok': 'Key set ({source})',
55
55
  'badge.key_missing': 'Key missing',
56
56
  'badge.registered': 'DSH Registered ({count} models)',
57
57
  'badge.not_registered': 'Not registered',
@@ -65,8 +65,8 @@ const en = {
65
65
  'key.saving': 'Saving…',
66
66
  'key.login_fast': 'Open API Keys ↗',
67
67
  'key.verifying': 'Validating key with Cline…',
68
- 'key.verified': '✓ Valid key for {email} ({plan})',
69
- 'key.invalid': '✗ Key validation failed: {error}',
68
+ 'key.verified': 'Valid key for {email} ({plan})',
69
+ 'key.invalid': 'Key validation failed: {error}',
70
70
  'key.env_label': 'Credential environment name: ',
71
71
  'key.get_key': 'Get key in app.cline.bot console ↗',
72
72
  'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
@@ -93,6 +93,11 @@ const en = {
93
93
  'accounts.route_model_placeholder': 'Model ID (e.g. cline-pass/deepseek-v4-pro)',
94
94
  'accounts.route_account_placeholder': 'Target Account',
95
95
  'accounts.route_btn_add': 'Add Route',
96
+ 'accounts.actions': 'Account actions', 'accounts.configured': 'Configured', 'accounts.missing_key': 'Missing key', 'accounts.source': 'Source: {source}',
97
+ 'accounts.quota_5h': '5h {pct}%', 'accounts.rename': 'Rename', 'accounts.rename_save': 'Save name', 'accounts.rename_placeholder': 'Account label',
98
+ 'accounts.clear_key': 'Clear stored key', 'accounts.clear_confirm': 'Clear the stored key for {name}? The account row stays.',
99
+ 'accounts.remove_confirm': 'Remove {name} from the pool?', 'accounts.confirm': 'Confirm', 'accounts.cancel': 'Cancel', 'accounts.unpin': 'Unpin',
100
+ 'accounts.no_accounts': 'No accounts configured yet.', 'accounts.route_remove': 'Remove route',
96
101
  'quota.title': 'ClinePass Quota & Rate Limits',
97
102
  'quota.desc': 'Official rolling window request limits from ClinePass',
98
103
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -119,9 +124,9 @@ const en = {
119
124
  'models.caps_vision': 'Vision',
120
125
  'models.caps_general': 'General',
121
126
  'models.caps_coding': 'Coding',
122
- 'diag.smoke_result': '✅ Latency: {latency} ms | Model: {model}\nResponse: {preview}',
127
+ 'diag.smoke_result': 'Latency: {latency} ms | Model: {model}\nResponse: {preview}',
123
128
  'diag.empty': '(empty)',
124
- 'ui.error_title': '⚠️ ClineBot UI Error: ',
129
+ 'ui.error_title': 'ClineBot UI Error: ',
125
130
  'ui.retry': 'Retry',
126
131
  'update.check_failed': 'Failed to check for updates',
127
132
  'update.retry': 'Retry check',
@@ -236,19 +241,94 @@ const en = {
236
241
  'dashboard.account_pool': 'Active Routing Account',
237
242
  'dashboard.credits': 'Credit Balance',
238
243
  'dashboard.updated_at': 'Updated at',
239
- 'composer.cost_title': 'Estimated Session Cost',
244
+ 'composer.cost_title': 'ClineBot Session Cost',
245
+ 'composer.last_request': 'Last request',
246
+ 'composer.free': 'Free',
240
247
  'composer.cost_breakdown': 'Token & Cost Breakdown',
241
248
  'composer.prompt_tok': 'Prompt Tokens',
242
249
  'composer.completion_tok': 'Completion Tokens',
243
250
  'composer.reasoning_tok': 'Reasoning Tokens',
244
251
  'composer.est_cost': 'Estimated Cost',
252
+ 'composer.unpriced': 'unpriced', 'composer.cache_read': 'Cache Read', 'composer.cache_write': 'Cache Write',
253
+ 'composer.price_source': 'Price', 'composer.price_estimate': 'estimate', 'composer.price_override': 'override',
245
254
  'discovery.title': 'Discovered Local Keys',
246
255
  'discovery.import_btn': 'Import from {source}',
247
256
  'discovery.imported': 'Imported to accounts pool',
248
257
  'discovery.scanning': 'Scanning for local Cline keys…',
258
+ 'settings.unsaved': '{count} unsaved change(s)', 'settings.saving': 'Saving…', 'settings.saved': 'Settings saved', 'settings.save_failed': 'Could not save settings', 'settings.save': 'Save', 'settings.discard': 'Discard',
259
+ 'settings.save_invalid_base_url': 'Base URL must be https:// or a localhost address.', 'settings.save_invalid_number': 'Timeouts and payload limits must be positive numbers.',
260
+ 'settings.models_group': 'Models & context', 'settings.models_group_hint': 'Plan filter, visible models, context windows, output limits and reasoning defaults.',
261
+ 'settings.integrations': 'Integrations & display', 'settings.integrations_desc': 'Surfaces outside the chat request that reuse this provider.',
262
+ 'settings.web_search': 'Serve web search with ClineBot', 'settings.web_search_hint': 'Use the ClinePass endpoint for the DSH web_search tool.',
263
+ 'settings.sidebar_quota': 'Show quota meter in the sidebar', 'settings.sidebar_quota_hint': 'Display the compact plan and rolling-window meter above Settings.',
264
+ 'settings.composer_cost': 'Show session cost in the composer', 'settings.composer_cost_hint': 'Display the estimated token cost beside the composer.',
265
+ 'settings.proxy_mode': 'Transparent loopback proxy', 'settings.proxy_mode_hint': 'Legacy transport only: route requests through the local failover proxy.',
266
+ 'settings.advanced': 'Advanced', 'settings.advanced_desc': 'Connection, timeouts, storage paths and the temporary transport switch.',
267
+ 'settings.base_url': 'API base URL', 'settings.base_url_hint': 'ClinePass API root. Remote endpoints must use https://.',
268
+ 'settings.timeout': 'Connect timeout (ms)', 'settings.timeout_hint': 'Maximum wait for the upstream response headers.', 'settings.smoke_timeout': 'Smoke-test timeout (ms)', 'settings.smoke_timeout_hint': 'Maximum wait for the diagnostic chat completion.',
269
+ 'settings.stream_idle': 'Stream idle timeout (ms)', 'settings.stream_idle_hint': 'Maximum gap between SSE chunks before the stream is treated as dead.', 'settings.max_payload': 'Max multimodal payload (MB)', 'settings.max_payload_hint': 'Oldest images are evicted once the request exceeds this budget.',
270
+ 'settings.stats_path': 'Stats file path', 'settings.stats_path_hint': 'Persistent JSON telemetry path (supports ~/).', 'settings.models_cache_path': 'Models cache path', 'settings.models_cache_path_hint': 'Offline model catalogue cache (supports ~/).',
271
+ 'settings.transport': 'Transport', 'settings.transport_hint': 'Native ctx.llm adapter (recommended) or the legacy llm-pi-ai + loopback proxy.',
272
+ 'providerCard.title': 'ClineBot (ClinePass)',
273
+ 'providerCard.hint': 'Paste a ClinePass API key or manage the full account pool in Settings → ClineBot.',
274
+ 'providerCard.keyPresent': 'Key configured',
275
+ 'providerCard.keyMissing': 'Key missing',
276
+ 'providerCard.routeActive': 'Route active',
277
+ 'providerCard.placeholder': 'Paste API key',
278
+ 'providerCard.replace': 'Replace API key',
279
+ 'providerCard.save': 'Save key',
280
+ 'providerCard.saving': 'Saving…',
281
+ 'providerCard.saved': 'Key saved and validated',
282
+ 'providerCard.failed': 'Could not save the key',
283
+ 'providerCard.show': 'Show',
284
+ 'providerCard.hide': 'Hide',
285
+ 'providerCard.openSettings': 'Open Settings → ClineBot for accounts, models and quotas.',
286
+ 'dashboard.tab_quota': 'Quota', 'dashboard.tab_analytics': 'Analytics', 'dashboard.auto_account': 'Automatic (least used)',
287
+ 'analytics.title': 'Usage analytics', 'analytics.requests': 'Requests', 'analytics.tokens': 'Tokens', 'analytics.cost': 'Estimated cost', 'analytics.success': 'Success rate', 'analytics.ttft': 'Avg TTFT',
288
+ 'analytics.loading': 'Loading analytics…', 'analytics.no_data': 'No analytics yet. Send a few messages first.',
289
+ 'analytics.unpriced': '{count} request(s) have no price estimate and are excluded from cost totals.',
290
+ 'analytics.group_model': 'Models', 'analytics.group_day': 'Days', 'analytics.group_session': 'Sessions',
291
+ 'analytics.heatmap': 'Request heatmap', 'analytics.heatmap_hint': 'Requests per hour over the last 7 days.',
292
+ 'analytics.heatmap_less': 'Less', 'analytics.heatmap_more': 'More',
293
+ 'analytics.heatmap_empty': 'No hourly data yet. Send a message to start the heatmap.',
294
+ 'analytics.input': 'Input', 'analytics.output': 'Output', 'analytics.reasoning': 'Reasoning',
295
+ 'analytics.cache_read': 'Cache read', 'analytics.cache_write': 'Cache write',
296
+ 'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency', 'analytics.errors': 'Errors',
297
+ 'analytics.no_key': 'ClinePass key is not configured.',
298
+ 'analytics.no_key_hint': 'Open Settings → ClineBot and paste a key, or import a discovered local key.',
299
+ 'analytics.health_error': 'ClinePass endpoint is unreachable.',
300
+ 'analytics.not_registered': 'ClineBot provider is not registered with DSH.',
301
+ 'analytics.not_registered_hint': 'Open Settings → ClineBot and check Diagnostics.',
302
+ 'analytics.stale': 'Showing the last successful snapshot.',
303
+ 'analytics.range': 'Range', 'analytics.range_7': '7 days', 'analytics.range_30': '30 days', 'analytics.range_90': '90 days',
304
+ 'analytics.heatmap_all_models': 'All models', 'analytics.heatmap_peak': 'Peak',
305
+ 'analytics.heatmap_by_hour': 'By hour', 'analytics.heatmap_model': 'Heatmap model',
306
+ 'quota.no_windows': 'No quota windows reported.',
307
+ 'prices.title': 'Price overrides', 'prices.desc': 'USD per 1M tokens. Blank uses the local estimate.',
308
+ 'prices.show': 'Show prices', 'prices.hide': 'Hide prices', 'prices.model': 'Model',
309
+ 'prices.input': 'Input', 'prices.output': 'Output', 'prices.cache_read': 'Cache read', 'prices.cache_write': 'Cache write',
310
+ 'prices.auto': 'auto', 'prices.empty': 'No models available.',
311
+ 'prices.hint': 'These rates affect the composer and local analytics only; they are not an invoice.',
249
312
  }
250
313
 
251
- const zh = {
314
+ function makeT(dict, fallback) {
315
+ return function t(key, vars) {
316
+ if (key === 'quota.countdown' && vars?.time === 'resetting') {
317
+ return (dict && dict['quota.resetting']) || (fallback && fallback['quota.resetting']) || '正在重置'
318
+ }
319
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key
320
+ if (vars && typeof val === 'string') {
321
+ for (const k of Object.keys(vars)) {
322
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
323
+ }
324
+ }
325
+ return val
326
+ }
327
+ }
328
+
329
+ // --- src/client/locales-zh.js ---
330
+ // Chinese locale dictionary.
331
+ const zh = {
252
332
  nav: 'ClineBot',
253
333
  title: 'ClineBot',
254
334
  subtitle: 'ClinePass 订阅服务,包含动态套餐模型。',
@@ -274,8 +354,8 @@ const en = {
274
354
  'key.saving': '正在保存…',
275
355
  'key.login_fast': '打开 API 密钥页 ↗',
276
356
  'key.verifying': '正在连接 Cline 验证密钥…',
277
- 'key.verified': '✓ 密钥有效:{email} ({plan})',
278
- 'key.invalid': '✗ 密钥验证未通过:{error}',
357
+ 'key.verified': '密钥有效:{email} ({plan})',
358
+ 'key.invalid': '密钥验证未通过:{error}',
279
359
  'key.env_label': '凭据环境变量名称:',
280
360
  'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
281
361
  'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
@@ -302,6 +382,11 @@ const en = {
302
382
  'accounts.route_model_placeholder': '模型 ID (例如 cline-pass/deepseek-v4-pro)',
303
383
  'accounts.route_account_placeholder': '目标账号',
304
384
  'accounts.route_btn_add': '添加路由',
385
+ 'accounts.actions': '账户操作', 'accounts.configured': '已配置', 'accounts.missing_key': '缺少密钥', 'accounts.source': '来源:{source}',
386
+ 'accounts.quota_5h': '5小时 {pct}%', 'accounts.rename': '重命名', 'accounts.rename_save': '保存名称', 'accounts.rename_placeholder': '账户名称',
387
+ 'accounts.clear_key': '清除已存密钥', 'accounts.clear_confirm': '清除 {name} 的已存密钥?账户行会保留。',
388
+ 'accounts.remove_confirm': '从池中移除 {name}?', 'accounts.confirm': '确认', 'accounts.cancel': '取消', 'accounts.unpin': '取消固定',
389
+ 'accounts.no_accounts': '尚未配置账户。', 'accounts.route_remove': '移除路由',
305
390
  'quota.title': 'ClinePass 用量配额与限额监控',
306
391
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
307
392
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -328,9 +413,9 @@ const en = {
328
413
  'models.caps_vision': '视觉',
329
414
  'models.caps_general': '通用',
330
415
  'models.caps_coding': '编程',
331
- 'diag.smoke_result': '✅ 延迟:{latency} ms | 模型:{model}\n响应:{preview}',
416
+ 'diag.smoke_result': '延迟:{latency} ms | 模型:{model}\n响应:{preview}',
332
417
  'diag.empty': '(空)',
333
- 'ui.error_title': '⚠️ ClineBot 界面错误:',
418
+ 'ui.error_title': 'ClineBot 界面错误:',
334
419
  'ui.retry': '重试',
335
420
  'update.check_failed': '检查更新失败',
336
421
  'update.retry': '重试检查',
@@ -445,31 +530,74 @@ const en = {
445
530
  'dashboard.account_pool': '当前承载账户',
446
531
  'dashboard.credits': '点数余额',
447
532
  'dashboard.updated_at': '更新于',
448
- 'composer.cost_title': '预估会话成本',
533
+ 'composer.cost_title': 'ClineBot 会话成本',
534
+ 'composer.last_request': '最近一次请求',
535
+ 'composer.free': '免费',
449
536
  'composer.cost_breakdown': 'Token 与费用明细',
450
537
  'composer.prompt_tok': '提示词 Token',
451
538
  'composer.completion_tok': '补全 Token',
452
539
  'composer.reasoning_tok': '思考推理 Token',
453
540
  'composer.est_cost': '预估花费',
541
+ 'composer.unpriced': '无价格', 'composer.cache_read': '缓存读取', 'composer.cache_write': '缓存写入',
542
+ 'composer.price_source': '价格来源', 'composer.price_estimate': '估算', 'composer.price_override': '覆盖',
454
543
  'discovery.title': '发现本地密钥',
455
544
  'discovery.import_btn': '从 {source} 导入',
456
545
  'discovery.imported': '已成功导入账户池',
457
546
  'discovery.scanning': '正在扫描本地 Cline 密钥…',
458
- }
459
-
460
- function makeT(dict, fallback) {
461
- return function t(key, vars) {
462
- if (key === 'quota.countdown' && vars?.time === 'resetting') {
463
- return (dict && dict['quota.resetting']) || (fallback && fallback['quota.resetting']) || '正在重置'
464
- }
465
- let val = (dict && dict[key]) || (fallback && fallback[key]) || key
466
- if (vars && typeof val === 'string') {
467
- for (const k of Object.keys(vars)) {
468
- val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
469
- }
470
- }
471
- return val
472
- }
547
+ 'settings.unsaved': '{count} 项未保存更改', 'settings.saving': '正在保存…', 'settings.saved': '设置已保存', 'settings.save_failed': '无法保存设置', 'settings.save': '保存', 'settings.discard': '放弃',
548
+ 'settings.save_invalid_base_url': 'Base URL 必须使用 https:// 或 localhost。', 'settings.save_invalid_number': '超时和负载限制必须是正数。',
549
+ 'settings.models_group': '模型与上下文', 'settings.models_group_hint': '套餐过滤、可见模型、上下文窗口、输出上限和推理默认值。',
550
+ 'settings.integrations': '集成与显示', 'settings.integrations_desc': '聊天请求之外复用此服务商的界面入口。',
551
+ 'settings.web_search': '使用 ClineBot 提供网页搜索', 'settings.web_search_hint': '为 DSH web_search 工具使用 ClinePass 端点。',
552
+ 'settings.sidebar_quota': '在侧边栏显示配额', 'settings.sidebar_quota_hint': '在设置上方显示紧凑的套餐与滚动窗口配额。',
553
+ 'settings.composer_cost': '在输入框显示会话成本', 'settings.composer_cost_hint': '在输入框旁显示预估 token 成本。',
554
+ 'settings.proxy_mode': '透明回环代理', 'settings.proxy_mode_hint': '仅旧传输:通过本地故障转移代理发送请求。',
555
+ 'settings.advanced': '高级', 'settings.advanced_desc': '连接、超时、存储路径和临时传输开关。',
556
+ 'settings.base_url': 'API 基础地址', 'settings.base_url_hint': 'ClinePass API 根地址。远程端点必须使用 https://。',
557
+ 'settings.timeout': '连接超时(毫秒)', 'settings.timeout_hint': '等待上游响应头的最大时间。', 'settings.smoke_timeout': '烟雾测试超时(毫秒)', 'settings.smoke_timeout_hint': '等待诊断聊天补全的最大时间。',
558
+ 'settings.stream_idle': '流空闲超时(毫秒)', 'settings.stream_idle_hint': 'SSE 数据块之间的最大间隔,超过后视为断流。', 'settings.max_payload': '多模态负载上限(MB)', 'settings.max_payload_hint': '请求超过预算时会淘汰最旧的图片。',
559
+ 'settings.stats_path': '统计文件路径', 'settings.stats_path_hint': '持久化 JSON 遥测路径(支持 ~/)。', 'settings.models_cache_path': '模型缓存路径', 'settings.models_cache_path_hint': '离线模型目录缓存(支持 ~/)。',
560
+ 'settings.transport': '传输方式', 'settings.transport_hint': '原生 ctx.llm 适配器(推荐)或旧版 llm-pi-ai + 回环代理。',
561
+ 'providerCard.title': 'ClineBot(ClinePass)',
562
+ 'providerCard.hint': '粘贴 ClinePass API 密钥,或前往“设置 → ClineBot”管理完整账户池。',
563
+ 'providerCard.keyPresent': '密钥已配置',
564
+ 'providerCard.keyMissing': '缺少密钥',
565
+ 'providerCard.routeActive': '路由已启用',
566
+ 'providerCard.placeholder': '粘贴 API 密钥',
567
+ 'providerCard.replace': '替换 API 密钥',
568
+ 'providerCard.save': '保存密钥',
569
+ 'providerCard.saving': '正在保存…',
570
+ 'providerCard.saved': '密钥已保存并验证',
571
+ 'providerCard.failed': '无法保存密钥',
572
+ 'providerCard.show': '显示',
573
+ 'providerCard.hide': '隐藏',
574
+ 'providerCard.openSettings': '前往“设置 → ClineBot”管理账户、模型和配额。',
575
+ 'dashboard.tab_quota': '配额', 'dashboard.tab_analytics': '分析', 'dashboard.auto_account': '自动(最少使用)',
576
+ 'analytics.title': '用量分析', 'analytics.requests': '请求数', 'analytics.tokens': 'Token', 'analytics.cost': '预估成本', 'analytics.success': '成功率', 'analytics.ttft': '平均首字延迟',
577
+ 'analytics.loading': '正在加载分析…', 'analytics.no_data': '暂无分析数据,请先发送几条消息。',
578
+ 'analytics.unpriced': '{count} 个请求没有价格估算,未计入成本合计。',
579
+ 'analytics.group_model': '模型', 'analytics.group_day': '日期', 'analytics.group_session': '会话',
580
+ 'analytics.heatmap': '请求热力图', 'analytics.heatmap_hint': '最近 7 天每小时的请求数。',
581
+ 'analytics.heatmap_less': '少', 'analytics.heatmap_more': '多',
582
+ 'analytics.heatmap_empty': '暂无按小时数据。发送一条消息后开始记录。',
583
+ 'analytics.input': '输入', 'analytics.output': '输出', 'analytics.reasoning': '推理',
584
+ 'analytics.cache_read': '缓存读取', 'analytics.cache_write': '缓存写入',
585
+ 'analytics.avg_ttft': '平均首字延迟', 'analytics.avg_latency': '平均延迟', 'analytics.errors': '错误',
586
+ 'analytics.no_key': '尚未配置 ClinePass 密钥。',
587
+ 'analytics.no_key_hint': '打开“设置 → ClineBot”粘贴密钥,或导入已发现的本地密钥。',
588
+ 'analytics.health_error': 'ClinePass 端点不可达。',
589
+ 'analytics.not_registered': 'ClineBot 提供商未在 DSH 中注册。',
590
+ 'analytics.not_registered_hint': '打开“设置 → ClineBot”检查诊断。',
591
+ 'analytics.stale': '正在显示上次成功获取的数据。',
592
+ 'analytics.range': '范围', 'analytics.range_7': '7 天', 'analytics.range_30': '30 天', 'analytics.range_90': '90 天',
593
+ 'analytics.heatmap_all_models': '所有模型', 'analytics.heatmap_peak': '峰值',
594
+ 'analytics.heatmap_by_hour': '按小时', 'analytics.heatmap_model': '热力图模型',
595
+ 'quota.no_windows': '未返回配额窗口。',
596
+ 'prices.title': '价格覆盖', 'prices.desc': '每 100 万 token 的美元价格。留空使用本地估算。',
597
+ 'prices.show': '显示价格', 'prices.hide': '隐藏价格', 'prices.model': '模型',
598
+ 'prices.input': '输入', 'prices.output': '输出', 'prices.cache_read': '缓存读取', 'prices.cache_write': '缓存写入',
599
+ 'prices.auto': '自动', 'prices.empty': '暂无模型。',
600
+ 'prices.hint': '这些价格只影响输入框成本和本地分析,不是账单。',
473
601
  }
474
602
 
475
603
  // --- src/client/primitives.js ---
@@ -517,6 +645,121 @@ function BooleanControl(props) {
517
645
  })
518
646
  }
519
647
 
648
+ // --- src/client/icons.js ---
649
+ /**
650
+ * Calm 16px stroke icons. They inherit `currentColor` and never carry emoji or
651
+ * hardcoded palette values, so they follow the host theme in both modes.
652
+ */
653
+ function CbIcon({ name, size = 16, className }) {
654
+ const common = {
655
+ width: size,
656
+ height: size,
657
+ viewBox: '0 0 16 16',
658
+ fill: 'none',
659
+ stroke: 'currentColor',
660
+ strokeWidth: 1.5,
661
+ strokeLinecap: 'round',
662
+ strokeLinejoin: 'round',
663
+ className: className || 'cb-icon',
664
+ 'aria-hidden': 'true',
665
+ focusable: 'false',
666
+ }
667
+ const children = []
668
+ switch (name) {
669
+ case 'search':
670
+ children.push(
671
+ React.createElement('circle', { key: 'c', cx: 7, cy: 7, r: 4.5 }),
672
+ React.createElement('path', { key: 'l', d: 'M10.5 10.5 14 14' })
673
+ )
674
+ break
675
+ case 'plus':
676
+ children.push(
677
+ React.createElement('path', { key: 'v', d: 'M8 3v10' }),
678
+ React.createElement('path', { key: 'h', d: 'M3 8h10' })
679
+ )
680
+ break
681
+ case 'close':
682
+ children.push(React.createElement('path', { key: 'x', d: 'm4 4 8 8M12 4l-8 8' }))
683
+ break
684
+ case 'check':
685
+ children.push(React.createElement('path', { key: 'p', d: 'm3.5 8.5 3 3 6-7' }))
686
+ break
687
+ case 'warning':
688
+ children.push(
689
+ React.createElement('path', { key: 't', d: 'M8 2 14 13H2Z' }),
690
+ React.createElement('path', { key: 'l', d: 'M8 6v3' }),
691
+ React.createElement('circle', { key: 'd', cx: 8, cy: 11.2, r: 0.6, fill: 'currentColor', stroke: 'none' })
692
+ )
693
+ break
694
+ case 'error':
695
+ children.push(
696
+ React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 6 }),
697
+ React.createElement('path', { key: 'l', d: 'M8 4.5v4' }),
698
+ React.createElement('circle', { key: 'd', cx: 8, cy: 11.4, r: 0.6, fill: 'currentColor', stroke: 'none' })
699
+ )
700
+ break
701
+ case 'download':
702
+ children.push(
703
+ React.createElement('path', { key: 'a', d: 'M8 2v7' }),
704
+ React.createElement('path', { key: 'b', d: 'm5 6.5 3 3 3-3' }),
705
+ React.createElement('path', { key: 'c', d: 'M3 12.5h10' })
706
+ )
707
+ break
708
+ case 'chevron':
709
+ children.push(React.createElement('path', { key: 'p', d: 'm6 4 4 4-4 4' }))
710
+ break
711
+ case 'kebab':
712
+ children.push(
713
+ React.createElement('circle', { key: 'a', cx: 8, cy: 3.5, r: 1, fill: 'currentColor', stroke: 'none' }),
714
+ React.createElement('circle', { key: 'b', cx: 8, cy: 8, r: 1, fill: 'currentColor', stroke: 'none' }),
715
+ React.createElement('circle', { key: 'c', cx: 8, cy: 12.5, r: 1, fill: 'currentColor', stroke: 'none' })
716
+ )
717
+ break
718
+ case 'key':
719
+ children.push(
720
+ React.createElement('circle', { key: 'c', cx: 5, cy: 8, r: 2.5 }),
721
+ React.createElement('path', { key: 'a', d: 'M7.5 8H14' }),
722
+ React.createElement('path', { key: 'b', d: 'M11.5 8v2' }),
723
+ React.createElement('path', { key: 'd', d: 'M13.5 8v1.5' })
724
+ )
725
+ break
726
+ case 'edit':
727
+ children.push(
728
+ React.createElement('path', { key: 'a', d: 'M3 13l.5-3L11 2.5 13.5 5 6 12.5Z' }),
729
+ React.createElement('path', { key: 'b', d: 'm9.5 4 2.5 2.5' })
730
+ )
731
+ break
732
+ case 'trash':
733
+ children.push(
734
+ React.createElement('path', { key: 'a', d: 'M3 5h10' }),
735
+ React.createElement('path', { key: 'b', d: 'M6 5V3.5h4V5' }),
736
+ React.createElement('path', { key: 'c', d: 'M4.5 5 5 13h6l.5-8' })
737
+ )
738
+ break
739
+ case 'route':
740
+ children.push(
741
+ React.createElement('path', { key: 'a', d: 'M3 5h6a2 2 0 0 1 0 4H5a2 2 0 0 0 0 4h8' }),
742
+ React.createElement('path', { key: 'b', d: 'm11 10 2 3-2 3' })
743
+ )
744
+ break
745
+ case 'refresh':
746
+ children.push(
747
+ React.createElement('path', { key: 'a', d: 'M13 8a5 5 0 1 1-1.5-3.5' }),
748
+ React.createElement('path', { key: 'b', d: 'M13 2.5V6h-3.5' })
749
+ )
750
+ break
751
+ case 'account':
752
+ children.push(
753
+ React.createElement('circle', { key: 'a', cx: 8, cy: 5.5, r: 2.5 }),
754
+ React.createElement('path', { key: 'b', d: 'M3 13c.6-2.5 2.6-4 5-4s4.4 1.5 5 4' })
755
+ )
756
+ break
757
+ default:
758
+ children.push(React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 6 }))
759
+ }
760
+ return React.createElement('svg', common, children)
761
+ }
762
+
520
763
  // --- src/client/styles.js ---
521
764
  function ensureCss() {
522
765
  if (typeof document === 'undefined') return
@@ -532,7 +775,7 @@ function ensureCss() {
532
775
 
533
776
  .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
777
  .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}
778
+ .cb-section-desc{margin-top:-6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
536
779
 
537
780
  .cb-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
538
781
  .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:12px}
@@ -559,10 +802,13 @@ function ensureCss() {
559
802
  .cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}
560
803
  .cb-btn-disabled{opacity:.5;cursor:not-allowed}
561
804
 
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}
564
- .cb-progress-label{font-weight:500;white-space:nowrap}
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}
805
+ .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;min-width:0;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
806
+ .cb-progress-card-bad{border-color:var(--dsw-alias-state-error-primary)}
807
+ .cb-progress-head, .cb-bar-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:4px 12px;width:100%;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
808
+ .cb-progress-label{flex:0 0 auto;font-weight:500;white-space:nowrap}
809
+ .cb-progress-meta, .cb-bar-meta{flex:1 1 140px;min-width:0;margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;text-align:right;white-space:normal;overflow-wrap:anywhere}
810
+ .cb-progress-meta-bad{color:var(--dsw-alias-state-error-primary)}
811
+ .cb-progress-low{display:inline-flex;align-items:center;gap:4px;margin-left:6px;font-size:11px;color:var(--dsw-alias-state-error-primary)}
566
812
  .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
813
  .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
814
  .cb-progress-bar-warn{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-state-warn-primary);transition:width .3s ease}
@@ -594,15 +840,19 @@ function ensureCss() {
594
840
  .cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3)}
595
841
  .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
842
  .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}
843
+ .cb-footName{flex:1 1 auto;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
844
  .cb-footRow{display:flex;flex-direction:column;gap:4px;min-width:0}
599
845
  .cb-footHead{display:flex;align-items:baseline;gap:8px;min-width:0}
600
- .cb-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
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}
846
+ .cb-footLabel{flex:1;min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px;white-space:nowrap;text-overflow:ellipsis}
847
+ .cb-footAmount{flex:0 1 auto;max-width:68%;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap;text-overflow:ellipsis}
848
+ .cb-footRowMissing .cb-footLabel{opacity:.65}
849
+ .cb-footAmountMissing{color:var(--dsw-alias-label-tertiary)}
850
+ .cb-footMessage{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
602
851
  .cb-footBar{display:block;height:4px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
603
852
  .cb-footFill{display:block;height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
604
853
  .cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
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}
854
+ .cb-footPct{margin-left:auto;flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;font-weight:600;line-height:16px;font-variant-numeric:tabular-nums}
855
+ .cb-footPctWarn{color:var(--dsw-alias-state-error-primary)}
606
856
  .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}
607
857
  .cb-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
608
858
  .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}
@@ -611,11 +861,261 @@ function ensureCss() {
611
861
  /* The window chrome and its width belong to the shell's Modal, so the body only
612
862
  fills whatever it is given — a fixed width here overflows the panel. */
613
863
  .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)}
864
+ .cb-panel{display:flex;flex-direction:column;gap:14px;width:100%;max-width:780px;margin:0 auto;padding:18px;height:100%;max-height:calc(100vh - 56px);min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;color:var(--dsw-alias-label-primary)}
865
+ .cb-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
866
+ .cb-panel-identity{display:flex;flex-direction:column;gap:4px;min-width:0}
867
+ .cb-panel-title{color:var(--dsw-alias-label-primary);font-size:18px;font-weight:650;line-height:24px}
868
+ .cb-panel-sub{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
869
+ .cb-panel-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;flex:none}
870
+ .cb-panel-updated{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
871
+ .cb-panel-close{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:18px;line-height:1;cursor:pointer}
872
+ .cb-panel-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
873
+ .cb-notice{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}
874
+ .cb-notice-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px}
875
+ .cb-notice-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
876
+ .cb-notice-error{border-color:var(--dsw-alias-state-error-primary)}
877
+ .cb-notice-error .cb-notice-title{color:var(--dsw-alias-state-error-primary)}
878
+ @media(max-width:720px){.cb-panel{padding:12px;max-height:none}}
614
879
  .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
880
  .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
881
  .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
882
  .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)}
618
883
 
884
+ /* Provider card inside Settings -> Models. */
885
+ .cb-provider-card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
886
+ .cb-provider-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
887
+ .cb-provider-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:20px}
888
+ .cb-provider-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
889
+
890
+ /* Grouped settings, disclosure rows and the staged-save bar. */
891
+ .cb-settings-group{display:flex;flex-direction:column;gap:10px;padding:4px 0}
892
+ .cb-settings-group-divided{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:16px;margin-top:4px}
893
+ .cb-settings-group-head{display:flex;align-items:center;gap:8px;margin:0;padding:0}
894
+ .cb-settings-disclosure{width:100%;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:2px 0}
895
+ .cb-settings-group-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px;font-weight:600}
896
+ .cb-settings-group-action{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px}
897
+ .cb-settings-group-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
898
+ .cb-settings-group-body{display:flex;flex-direction:column;gap:2px}
899
+ .cb-settings-spacer{flex:1 1 auto}
900
+ .cb-settings-chevron{display:inline-flex;justify-content:center;align-items:center;width:18px;height:18px;color:var(--dsw-alias-label-tertiary);font-size:18px;line-height:1;transition:transform .16s ease}
901
+ .cb-settings-chevron-open{transform:rotate(90deg)}
902
+ .cb-settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:12px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
903
+ .cb-settings-row:last-child{border-bottom:0}
904
+ .cb-settings-row-text{display:flex;flex-direction:column;gap:4px;min-width:0}
905
+ .cb-settings-row-title{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;font-weight:500}
906
+ .cb-settings-row-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
907
+ .cb-settings-row-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
908
+ .cb-settings-row-control{display:flex;align-items:center;gap:8px;flex:none}
909
+ .cb-settings-input{width:220px;max-width:46vw}
910
+ .cb-savebar{position:sticky;bottom:0;z-index:30;margin:18px -4px -4px;padding:10px 4px 4px;background:linear-gradient(to top,var(--dsw-alias-bg-layer-1) 70%,transparent)}
911
+ .cb-savebar-inner{display:flex;align-items:center;gap:10px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
912
+ .cb-savebar-text{min-width:0;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:nowrap;text-overflow:ellipsis}
913
+
914
+ /* Calm icon and account-pool primitives. */
915
+ .cb-icon{flex:none;display:inline-block;vertical-align:-2px}
916
+ .cb-icon-chevron-open{transform:rotate(90deg)}
917
+ .cb-banner-icon{display:inline-flex;flex:none;align-items:center;justify-content:center;color:inherit}
918
+ .cb-inline-alert{display:flex;align-items:center;gap:6px}
919
+ .cb-mono{font-family:var(--ds-font-family-code,monospace);font-size:11px;color:var(--dsw-alias-label-secondary)}
920
+ .cb-muted{color:var(--dsw-alias-label-secondary);font-size:12px}
921
+ .cb-empty{padding:16px 0;color:var(--dsw-alias-label-secondary);font-size:12px;text-align:center}
922
+ .cb-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
923
+ .cb-accounts .cb-section-desc{margin-bottom:8px}
924
+ .cb-discovered{display:flex;flex-direction:column;gap:6px;margin:8px 0;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
925
+ .cb-discovered-head{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500}
926
+ .cb-discovered-row{display:flex;align-items:center;gap:8px;font-size:12px}
927
+ .cb-discovered-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
928
+ .cb-account-mode{display:flex;align-items:center;gap:8px;margin:8px 0 4px;font-size:12px}
929
+ .cb-seg{display:inline-flex;overflow:hidden;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md)}
930
+ .cb-seg-item{padding:4px 10px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
931
+ .cb-seg-item+.cb-seg-item{border-left:.5px solid var(--dsw-alias-border-l2)}
932
+ .cb-seg-item-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}
933
+ .cb-account-add{display:flex;flex-direction:column;gap:8px;margin:8px 0 12px;padding:12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
934
+ .cb-account-add-row{display:flex;gap:8px;flex-wrap:wrap}
935
+ .cb-account-add-row .cb-input{flex:1 1 180px}
936
+ .cb-account-add-actions{display:flex;justify-content:flex-end;gap:8px}
937
+ .cb-account-list{display:flex;flex-direction:column;margin-top:4px}
938
+ .cb-account-item{position:relative;border-bottom:.5px solid var(--dsw-alias-border-l2)}
939
+ .cb-account-item:last-child{border-bottom:0}
940
+ .cb-account-row{display:flex;align-items:center;gap:10px;padding:10px 0}
941
+ .cb-status-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-border-l3)}
942
+ .cb-status-dot-ok{background:var(--dsw-alias-state-success-primary)}
943
+ .cb-status-dot-warn{background:var(--dsw-alias-state-warn-primary)}
944
+ .cb-status-dot-bad{background:var(--dsw-alias-state-error-primary)}
945
+ .cb-status-dot-idle{background:var(--dsw-alias-border-l3)}
946
+ .cb-account-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
947
+ .cb-account-name{display:flex;align-items:baseline;gap:8px;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:18px}
948
+ .cb-account-env{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
949
+ .cb-account-meta{min-width:0;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;white-space:nowrap;text-overflow:ellipsis}
950
+ .cb-account-badges{display:flex;flex:none;flex-wrap:wrap;justify-content:flex-end;gap:4px;max-width:240px}
951
+ .cb-account-quota{display:flex;flex:none;align-items:center;justify-content:flex-end;gap:6px;min-width:112px}
952
+ .cb-meter{display:block;width:64px;height:4px;overflow:hidden;border-radius:2px;background:var(--dsw-alias-bg-layer-2)}
953
+ .cb-meter-fill{display:block;height:100%;background:var(--dsw-alias-brand-primary);transition:width .3s ease}
954
+ .cb-meter-fill-warn{background:var(--dsw-alias-state-error-primary)}
955
+ .cb-icon-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
956
+ .cb-icon-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
957
+ .cb-icon-btn:disabled{opacity:.5;cursor:not-allowed}
958
+ .cb-account-menu{display:flex;flex-direction:column;min-width:180px;margin:0 0 8px 18px;padding:4px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
959
+ .cb-menu-item{padding:6px 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;text-align:left;cursor:pointer}
960
+ .cb-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
961
+ .cb-menu-item-danger{color:var(--dsw-alias-state-error-primary)}
962
+ .cb-account-inline{display:flex;align-items:center;gap:8px;padding:0 0 10px 18px}
963
+ .cb-account-inline .cb-input{flex:1;min-width:0}
964
+ .cb-account-danger{padding:8px 10px;margin:0 0 8px 18px;border-radius:var(--dsw-radius-md);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}
965
+ .cb-account-danger-text{flex:1;min-width:0;color:var(--dsw-alias-state-error-primary);font-size:12px}
966
+ .cb-account-routes{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}
967
+ .cb-account-routes-head{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500}
968
+ .cb-account-route-add{display:flex;gap:8px;flex-wrap:wrap}
969
+ .cb-account-route-add .cb-input{flex:2 1 160px}
970
+ .cb-account-route-add .cb-select{flex:1 1 140px}
971
+ .cb-account-route-list{display:flex;flex-direction:column;gap:4px}
972
+ .cb-account-route-row{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px}
973
+ .cb-account-route-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
974
+ @media(max-width:720px){
975
+ .cb-account-row{flex-wrap:wrap}
976
+ .cb-account-badges{justify-content:flex-start;max-width:none}
977
+ .cb-account-quota{min-width:0}
978
+ }
979
+
980
+ /* Calm section toolbars, stats and banners. */
981
+ .cb-section-actions{display:flex;align-items:center;gap:6px;flex:none}
982
+ .cb-btn-link{display:inline-flex;align-items:center;gap:4px;text-decoration:none}
983
+ .cb-quota-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:10px;margin-top:4px}
984
+ @media(max-width:640px){.cb-quota-grid{grid-template-columns:1fr}}
985
+ .cb-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:4px}
986
+ .cb-model-list{display:flex;flex-direction:column;gap:4px;margin-top:12px}
987
+ .cb-model-list-title{margin-bottom:2px;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500}
988
+ .cb-model-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px}
989
+ .cb-model-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
990
+ .cb-banner-body{flex:1;min-width:0}
991
+ .cb-banner-sub{margin-top:2px;font-size:11px;opacity:.9}
992
+ .cb-state-ok{color:var(--dsw-alias-state-success-primary);font-size:12px}
993
+ .cb-hint{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
994
+ .cb-key-status{margin-top:8px;font-size:12px}
995
+ .cb-key-meta{display:flex;align-items:center;gap:6px;margin-top:12px;color:var(--dsw-alias-label-secondary);font-size:12px}
996
+ .cb-link{color:var(--dsw-alias-brand-primary);text-decoration:none}
997
+ .cb-link:hover{text-decoration:underline}
998
+ .cb-update-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}
999
+ .cb-update-info{display:flex;align-items:center;gap:8px;min-width:0;font-size:12px}
1000
+ .cb-update-version{color:var(--dsw-alias-label-secondary);font-weight:500}
1001
+ .cb-row-actions{display:flex;align-items:center;gap:8px;margin-top:4px}
1002
+
1003
+ /* Composer cost and analytics dialog. */
1004
+ .cb-composer-cost{position:relative;display:inline-flex;align-items:center}
1005
+ .cb-composer-cost-button{display:inline-flex;align-items:center;gap:4px;height:24px;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer}
1006
+ .cb-composer-cost-button:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
1007
+ .cb-composer-cost-value{color:var(--dsw-alias-label-primary);font-weight:600}
1008
+ .cb-composer-popover{position:absolute;bottom:100%;left:0;z-index:100;display:flex;flex-direction:column;gap:6px;min-width:200px;margin-bottom:6px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:12px}
1009
+ .cb-composer-title{padding-bottom:4px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:600}
1010
+ .cb-composer-row,.cb-composer-total{display:flex;align-items:center;justify-content:space-between;gap:12px}
1011
+ .cb-composer-total{padding-top:4px;border-top:.5px solid var(--dsw-alias-border-l2);font-weight:600}
1012
+ .cb-composer-value{font-variant-numeric:tabular-nums}
1013
+ .cb-tabs{display:flex;gap:4px;border-bottom:.5px solid var(--dsw-alias-border-l2)}
1014
+ .cb-tab{padding:6px 10px;border:0;border-bottom:1.5px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
1015
+ .cb-tab-active{border-bottom-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:500}
1016
+ .cb-dialogRow{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
1017
+ .cb-dialogBadges{display:flex;align-items:center;justify-content:flex-end;gap:4px;flex-wrap:wrap}
1018
+ .cb-analytics-controls{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}
1019
+ .cb-account-tabs{display:flex;flex-wrap:wrap;gap:6px}
1020
+ .cb-account-tab{display:inline-flex;align-items:center;padding:2px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}
1021
+ .cb-account-tab:hover:not(.cb-account-tab-active){color:var(--dsw-alias-label-primary)}
1022
+ .cb-account-tab-active{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
1023
+ .cb-price-section{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}
1024
+ .cb-price-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
1025
+ .cb-price-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
1026
+ .cb-price-table{display:flex;flex-direction:column;gap:4px;min-width:0;overflow-x:auto}
1027
+ .cb-price-head-row,.cb-price-row{display:grid;grid-template-columns:minmax(160px,1.6fr) repeat(4,minmax(72px,1fr));gap:6px;align-items:center;min-width:520px}
1028
+ .cb-price-head-row{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
1029
+ .cb-price-row .cb-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-size:12px}
1030
+ .cb-price-input{width:100%;min-width:0;padding:4px 6px;font-size:12px}
1031
+ .cb-dialogIdentity{display:flex;flex-direction:column;gap:2px;min-width:0}
1032
+ .cb-dialogTiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px}
1033
+ .cb-dialogWindows{display:flex;flex-direction:column;gap:12px}
1034
+ .cb-analytics-list{display:flex;flex-direction:column;gap:2px;margin-top:8px}
1035
+ .cb-analytics-head,.cb-analytics-row{display:grid;grid-template-columns:minmax(120px,1fr) 70px 70px 80px 70px;gap:8px;align-items:center;padding:4px 0;font-size:11px}
1036
+ .cb-analytics-head{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}
1037
+ .cb-analytics-row{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
1038
+ .cb-analytics-row .cb-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1039
+ .cb-heat-wrap{display:flex;flex-direction:column;gap:8px;min-width:0}
1040
+ .cb-heat-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1041
+ .cb-heat-toolbar .cb-hint{margin:0}
1042
+ .cb-heat-select{max-width:260px;padding:3px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
1043
+ .cb-heat-scroll{overflow-x:auto;padding-bottom:4px}
1044
+ .cb-heat{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;align-items:center;min-width:620px}
1045
+ .cb-heat-hour{color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center}
1046
+ .cb-heat-day{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px;white-space:nowrap}
1047
+ .cb-heat-cell,.cb-heat-swatch{display:block;border-radius:3px;background:var(--dsw-alias-bg-layer-2)}
1048
+ .cb-heat-cell{height:14px}
1049
+ .cb-heat-swatch{width:12px;height:12px;border-radius:2px}
1050
+ .cb-heat-l0{background:var(--dsw-alias-bg-layer-2)}
1051
+ .cb-heat-l1{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-bg-layer-2))}
1052
+ .cb-heat-l2{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 36%,var(--dsw-alias-bg-layer-2))}
1053
+ .cb-heat-l3{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 58%,var(--dsw-alias-bg-layer-2))}
1054
+ .cb-heat-l4{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 80%,var(--dsw-alias-bg-layer-2))}
1055
+ .cb-heat-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
1056
+ .cb-hour-strip-head{margin:10px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
1057
+ .cb-hour-strip{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;align-items:end;height:30px;min-width:620px}
1058
+ .cb-hour-col{display:flex;align-items:flex-end;height:100%}
1059
+ .cb-hour-bar{display:block;width:100%;min-height:2px;border-radius:2px 2px 0 0}
1060
+ .cb-hour-labels{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center;min-width:620px}
1061
+ @media(max-width:720px){
1062
+ .cb-analytics-head,.cb-analytics-row{grid-template-columns:1fr 56px 64px}
1063
+ .cb-analytics-head span:nth-child(4),.cb-analytics-head span:nth-child(5),.cb-analytics-row span:nth-child(4),.cb-analytics-row span:nth-child(5){display:none}
1064
+ }
1065
+
1066
+ /* Opencode-inspired visual language: neutral rgba surfaces, 7-10px radii and
1067
+ the same green/amber/red accents. Text still follows the harness theme. */
1068
+ .cb-page{gap:14px;padding:4px 2px 24px}
1069
+ .cb-page-title{font-size:18px;font-weight:650;line-height:1.4}
1070
+ .cb-panel{max-width:760px;padding:18px}
1071
+ .cb-section-card,.cb-dialogCard,.cb-provider-card,.cb-progress-card,.cb-stat-box,.cb-account-add,.cb-discovered,.cb-update-banner,.cb-notice,.cb-savebar-inner,.cb-account-menu{border:1px solid rgba(127,127,127,.25);border-radius:10px;background:rgba(127,127,127,.06)}
1072
+ .cb-section-card,.cb-dialogCard{padding:12px 14px;gap:10px}
1073
+ .cb-section-title,.cb-settings-group-title{font-size:13px;font-weight:650;line-height:1.5}
1074
+ .cb-section-desc,.cb-settings-group-desc,.cb-settings-row-desc,.cb-hint{opacity:.72;line-height:1.5}
1075
+ .cb-input,.cb-select{height:auto;min-height:32px;padding:6px 8px;border:1px solid rgba(127,127,127,.35);border-radius:7px;background:rgba(127,127,127,.08);font-size:13px;line-height:1.4}
1076
+ .cb-input:focus,.cb-select:focus{border-color:rgba(80,140,255,.6);outline:2px solid rgba(80,140,255,.25);outline-offset:1px}
1077
+ .cb-btn{height:auto;min-height:30px;padding:6px 11px;border:1px solid rgba(127,127,127,.35);border-radius:7px;background:rgba(127,127,127,.1);font-size:13px;line-height:1.4}
1078
+ .cb-btn:hover:not(:disabled){background:rgba(127,127,127,.18)}
1079
+ .cb-btn-primary{border-color:rgba(80,140,255,.6);background:rgba(80,140,255,.18);color:inherit}
1080
+ .cb-btn-primary:hover:not(:disabled){background:rgba(80,140,255,.26)}
1081
+ .cb-btn-danger{border-color:rgba(220,80,80,.5);color:#e07a7a}
1082
+ .cb-badge{padding:2px 7px;border:1px solid rgba(127,127,127,.3);border-radius:999px;background:rgba(127,127,127,.1);font-size:11px;line-height:1.4}
1083
+ .cb-badge-ok{color:#4fbf86;border-color:rgba(60,180,110,.5);background:rgba(60,180,110,.10)}
1084
+ .cb-badge-warn{color:#d9a24a;border-color:rgba(220,160,60,.5);background:rgba(220,160,60,.10)}
1085
+ .cb-badge-bad{color:#e07a7a;border-color:rgba(220,80,80,.5);background:rgba(220,80,80,.10)}
1086
+ .cb-stat-box{padding:8px 10px;border-radius:8px}
1087
+ .cb-stat-val{font-size:15px;font-weight:600;line-height:1.4}
1088
+ .cb-stat-lbl{font-size:11px;line-height:1.5}
1089
+ .cb-table{font-size:12px}
1090
+ .cb-table th{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:11px;font-weight:600;opacity:.8}
1091
+ .cb-table td{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:12px}
1092
+ .cb-progress-track,.cb-bar-track{height:8px;border-radius:999px;background:rgba(127,127,127,.2)}
1093
+ .cb-progress-bar-fill,.cb-bar-fill,.cb-footFill,.cb-meter-fill{background:#4fbf86}
1094
+ .cb-progress-bar-warn{background:#d9a24a}
1095
+ .cb-progress-bar-bad,.cb-footFillWarn,.cb-meter-fill-warn{background:#e07a7a}
1096
+ .cb-progress-meta-bad,.cb-progress-low,.cb-footPctWarn{color:#e07a7a}
1097
+ .cb-footBar,.cb-meter{background:rgba(127,127,127,.2)}
1098
+ .cb-account-tab,.cb-seg{border:1px solid rgba(127,127,127,.3);border-radius:999px;background:rgba(127,127,127,.1)}
1099
+ .cb-seg{border-radius:7px;overflow:hidden}
1100
+ .cb-seg-item{border:0;border-left:1px solid rgba(127,127,127,.2);border-radius:0;background:transparent}
1101
+ .cb-seg-item:first-child{border-left:0}
1102
+ .cb-seg-item-active,.cb-account-tab-active{border-color:rgba(80,140,255,.6);background:rgba(80,140,255,.18);color:inherit}
1103
+ .cb-tab{border:0;border-radius:0;background:transparent;border-bottom:1.5px solid transparent}
1104
+ .cb-tab-active{border-bottom-color:#4fbf86;background:transparent}
1105
+ .cb-heat-l0{background:rgba(127,127,127,.10)}
1106
+ .cb-heat-l1{background:rgba(79,191,134,.234)}
1107
+ .cb-heat-l2{background:rgba(79,191,134,.382)}
1108
+ .cb-heat-l3{background:rgba(79,191,134,.530)}
1109
+ .cb-heat-l4{background:rgba(79,191,134,.826)}
1110
+ .cb-heat-cell,.cb-heat-swatch{border-radius:3px}
1111
+ .cb-analytics-head,.cb-analytics-row{padding:5px 0;border-bottom:1px solid rgba(127,127,127,.18)}
1112
+ .cb-settings-row,.cb-price-section{border-color:rgba(127,127,127,.18)}
1113
+ .cb-alert-ok{background:rgba(60,180,110,.10);color:#4fbf86}
1114
+ .cb-alert-bad,.cb-alert-err{background:rgba(220,80,80,.10);color:#e07a7a}
1115
+ .cb-banner-warning{background:rgba(220,160,60,.10);border-color:rgba(220,160,60,.5);color:#d9a24a}
1116
+ .cb-banner-exhausted{background:rgba(220,80,80,.10);border-color:rgba(220,80,80,.5);color:#e07a7a}
1117
+ .cb-account-route-row,.cb-model-row,.cb-preview,.cb-account-add{background:rgba(127,127,127,.06)}
1118
+
619
1119
  `
620
1120
  document.head.appendChild(style)
621
1121
  }
@@ -643,6 +1143,257 @@ async function fetchWithTimeout(url, opts = {}, ms = 30000) {
643
1143
  }
644
1144
  }
645
1145
 
1146
+ // --- src/client/settings-draft.js ---
1147
+ /**
1148
+ * Staged configuration draft for the ClineBot settings page.
1149
+ *
1150
+ * Fields edited in the form are merged into `pending` and written with one
1151
+ * `PUT /config` when the user presses Save. Account operations remain
1152
+ * immediate and are handled by the page's existing handlers.
1153
+ */
1154
+ function useStagedConfig({ status, draft, setDraft, load, t, pendingRef }) {
1155
+ const [pending, setPending] = React.useState({})
1156
+ const [saving, setSaving] = React.useState(false)
1157
+ const [saveState, setSaveState] = React.useState('idle')
1158
+ const [saveError, setSaveError] = React.useState('')
1159
+ const savedTimer = React.useRef(null)
1160
+
1161
+ React.useEffect(() => () => {
1162
+ if (savedTimer.current) clearTimeout(savedTimer.current)
1163
+ }, [])
1164
+
1165
+ const validationError = React.useMemo(() => {
1166
+ if (Object.hasOwn(pending, 'baseUrl')) {
1167
+ const raw = String(pending.baseUrl || '').trim()
1168
+ try {
1169
+ const url = new URL(raw)
1170
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
1171
+ if (url.protocol !== 'https:' && !local) return t('settings.save_invalid_base_url')
1172
+ } catch {
1173
+ return t('settings.save_invalid_base_url')
1174
+ }
1175
+ }
1176
+ for (const key of ['timeoutMs', 'smokeTimeoutMs', 'streamIdleTimeoutMs', 'maxPayloadMb']) {
1177
+ if (!Object.hasOwn(pending, key)) continue
1178
+ const value = Number(pending[key])
1179
+ if (!Number.isFinite(value) || value <= 0) return t('settings.save_invalid_number')
1180
+ }
1181
+ return ''
1182
+ }, [pending, t])
1183
+
1184
+ const stage = React.useCallback((patch) => {
1185
+ if (!patch || typeof patch !== 'object') return
1186
+ const next = { ...(pendingRef.current || {}), ...patch }
1187
+ pendingRef.current = next
1188
+ setPending(next)
1189
+ setDraft((current) => ({ ...(current || {}), ...patch }))
1190
+ setSaveState('unsaved')
1191
+ setSaveError('')
1192
+ }, [pendingRef, setDraft])
1193
+
1194
+ const discard = React.useCallback(() => {
1195
+ pendingRef.current = {}
1196
+ setPending({})
1197
+ setDraft(status?.config || {})
1198
+ setSaveState('idle')
1199
+ setSaveError('')
1200
+ }, [pendingRef, setDraft, status])
1201
+
1202
+ const save = React.useCallback(async () => {
1203
+ const patch = pendingRef.current || {}
1204
+ if (Object.keys(patch).length === 0 || saving) return
1205
+ if (validationError) {
1206
+ setSaveState('error')
1207
+ setSaveError(validationError)
1208
+ return
1209
+ }
1210
+ setSaving(true)
1211
+ setSaveState('saving')
1212
+ setSaveError('')
1213
+ try {
1214
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/config`, {
1215
+ method: 'PUT',
1216
+ headers: { 'Content-Type': 'application/json' },
1217
+ body: JSON.stringify({ config: patch }),
1218
+ })
1219
+ const data = await res.json().catch(() => ({}))
1220
+ if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`)
1221
+ pendingRef.current = {}
1222
+ setPending({})
1223
+ await load()
1224
+ setSaveState('saved')
1225
+ if (savedTimer.current) clearTimeout(savedTimer.current)
1226
+ savedTimer.current = setTimeout(() => {
1227
+ setSaveState((current) => (current === 'saved' ? 'idle' : current))
1228
+ }, 2500)
1229
+ } catch (error) {
1230
+ setSaveState('error')
1231
+ setSaveError(String(error?.message || error))
1232
+ } finally {
1233
+ setSaving(false)
1234
+ }
1235
+ }, [load, pendingRef, saving, validationError])
1236
+
1237
+ return {
1238
+ pending,
1239
+ pendingCount: Object.keys(pending).length,
1240
+ saving,
1241
+ saveState,
1242
+ saveError,
1243
+ validationError,
1244
+ invalid: Boolean(validationError),
1245
+ stage,
1246
+ discard,
1247
+ save,
1248
+ }
1249
+ }
1250
+
1251
+ // --- src/client/settings-models.js ---
1252
+ /**
1253
+ * Model-picker selection state and debounced persistence.
1254
+ *
1255
+ * Model toggles are immediate by design: the picker is a direct inventory
1256
+ * control, not part of the staged settings form.
1257
+ */
1258
+ function useModelSelection({ status, draft, setDraft, load, setErr }) {
1259
+ const debounceTimerRef = React.useRef(null)
1260
+
1261
+ React.useEffect(() => () => {
1262
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1263
+ }, [])
1264
+
1265
+ function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
1266
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1267
+ debounceTimerRef.current = setTimeout(async () => {
1268
+ try {
1269
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
1270
+ method: 'POST',
1271
+ headers: { 'Content-Type': 'application/json' },
1272
+ body: JSON.stringify({ disabledModels: nextDisabled }),
1273
+ })
1274
+ const data = await res.json().catch(() => ({}))
1275
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1276
+ await load()
1277
+ } catch (error) {
1278
+ console.warn('[dsh-clinebot] Failed saving disabled models:', error)
1279
+ setErr(String(error?.message || error))
1280
+ setDraft((previous) => {
1281
+ if (!previous) return previous
1282
+ const all = status?.availableModels || []
1283
+ const previousEnabled = all.map((model) => model.id).filter((id) => !previousDisabled.includes(id))
1284
+ return { ...previous, disabledModels: previousDisabled, enabledModels: previousEnabled }
1285
+ })
1286
+ }
1287
+ }, 400)
1288
+ }
1289
+
1290
+ function handleToggleModel(id) {
1291
+ const current = draft?.disabledModels || []
1292
+ const next = current.includes(id)
1293
+ ? current.filter((entry) => entry !== id)
1294
+ : [...current, id]
1295
+ const all = status?.availableModels || []
1296
+ const enabled = all.map((model) => model.id).filter((modelId) => !next.includes(modelId))
1297
+ setDraft({ ...(draft || {}), disabledModels: next, enabledModels: enabled })
1298
+ debounceSaveDisabledModels(next, current)
1299
+ }
1300
+
1301
+ function handleSetModelsFilter(type) {
1302
+ const all = status?.availableModels || []
1303
+ let allowed = new Set()
1304
+ if (type === 'all') allowed = new Set(all.map((model) => model.id))
1305
+ else if (type === 'vision') allowed = new Set(all.filter((model) => model.input?.includes('image') || model.input?.includes('vision')).map((model) => model.id))
1306
+ else if (type === 'coding') allowed = new Set(all.filter((model) => model.category === 'coding').map((model) => model.id))
1307
+ else if (type === 'recommended') allowed = new Set(all.filter((model) => model.recommended).map((model) => model.id))
1308
+
1309
+ const current = draft?.disabledModels || []
1310
+ const nextDisabled = all.map((model) => model.id).filter((id) => !allowed.has(id))
1311
+ const nextEnabled = Array.from(allowed)
1312
+ setDraft({ ...(draft || {}), disabledModels: nextDisabled, enabledModels: nextEnabled })
1313
+ debounceSaveDisabledModels(nextDisabled, current)
1314
+ }
1315
+
1316
+ return { handleToggleModel, handleSetModelsFilter }
1317
+ }
1318
+
1319
+ // --- src/client/settings-accounts.js ---
1320
+ /**
1321
+ * Immediate account-pool operations. These are intentionally outside the
1322
+ * staged settings form: adding, pinning, renaming, clearing and deleting an
1323
+ * account commit on their own.
1324
+ */
1325
+ function createAccountActions({ performAction, load, t, setMsg }) {
1326
+ async function handlePinAccount(accountEnv) {
1327
+ await performAction('pin-account', async () => {
1328
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
1329
+ method: 'POST',
1330
+ headers: { 'Content-Type': 'application/json' },
1331
+ body: JSON.stringify({ account: accountEnv }),
1332
+ })
1333
+ const data = await res.json().catch(() => ({}))
1334
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1335
+ await load()
1336
+ })
1337
+ }
1338
+
1339
+ async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
1340
+ await performAction('add-account', async () => {
1341
+ const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
1342
+ const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
1343
+ const res = await fetchWithTimeout(endpoint, {
1344
+ method: 'POST',
1345
+ headers: { 'Content-Type': 'application/json' },
1346
+ body: JSON.stringify(payload),
1347
+ })
1348
+ const data = await res.json().catch(() => ({}))
1349
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1350
+ setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
1351
+ await load()
1352
+ })
1353
+ }
1354
+
1355
+ async function handleDeleteAccount(accountEnv, deleteSecret = true) {
1356
+ await performAction('delete-account', async () => {
1357
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
1358
+ method: 'POST',
1359
+ headers: { 'Content-Type': 'application/json' },
1360
+ body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
1361
+ })
1362
+ const data = await res.json().catch(() => ({}))
1363
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1364
+ await load()
1365
+ })
1366
+ }
1367
+
1368
+ async function handleRenameAccount(accountEnv, label) {
1369
+ await performAction('rename-account', async () => {
1370
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/rename`, {
1371
+ method: 'POST',
1372
+ headers: { 'Content-Type': 'application/json' },
1373
+ body: JSON.stringify({ apiKeyEnv: accountEnv, label }),
1374
+ })
1375
+ const data = await res.json().catch(() => ({}))
1376
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1377
+ await load()
1378
+ })
1379
+ }
1380
+
1381
+ async function handleClearKey(accountEnv) {
1382
+ await performAction('clear-key', async () => {
1383
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/key/clear`, {
1384
+ method: 'POST',
1385
+ headers: { 'Content-Type': 'application/json' },
1386
+ body: JSON.stringify({ apiKeyEnv: accountEnv }),
1387
+ })
1388
+ const data = await res.json().catch(() => ({}))
1389
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1390
+ await load()
1391
+ })
1392
+ }
1393
+
1394
+ return { handlePinAccount, handleAddAccount, handleDeleteAccount, handleRenameAccount, handleClearKey }
1395
+ }
1396
+
646
1397
  // --- src/client/error-boundary.js ---
647
1398
  function createErrorBoundary() {
648
1399
  try {
@@ -663,7 +1414,7 @@ function createErrorBoundary() {
663
1414
  render() {
664
1415
  if (this.state.hasError) {
665
1416
  const t = this.props && typeof this.props.t === 'function' ? this.props.t : null
666
- const errorTitle = t ? t('ui.error_title') : '⚠️ ClineBot UI Error: '
1417
+ const errorTitle = t ? t('ui.error_title') : 'ClineBot UI Error: '
667
1418
  const retryLabel = t ? t('ui.retry') : 'Retry'
668
1419
  return React.createElement(
669
1420
  'div',
@@ -736,128 +1487,343 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
736
1487
 
737
1488
  return React.createElement(
738
1489
  'div',
739
- {
740
- className: 'cb-progress-card cb-bar-container',
741
- style: {
742
- display: 'flex',
743
- flexDirection: 'column',
744
- gap: '8px',
745
- padding: '12px 14px',
746
- borderRadius: '8px',
747
- border: isBad ? '1px solid var(--dsw-alias-state-error-primary)' : '1px solid var(--dsw-alias-border-l2)',
748
- background: 'var(--dsw-alias-bg-layer-2)',
749
- },
750
- },
1490
+ { className: isBad ? 'cb-progress-card cb-progress-card-bad' : 'cb-progress-card' },
751
1491
  React.createElement(
752
1492
  'div',
753
- {
754
- className: 'cb-progress-head cb-bar-head',
755
- style: {
756
- display: 'flex',
757
- justifyContent: 'space-between',
758
- alignItems: 'baseline',
759
- gap: '16px',
760
- width: '100%',
761
- },
762
- },
1493
+ { className: 'cb-progress-head' },
763
1494
  React.createElement(
764
1495
  'span',
765
- { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
1496
+ { className: 'cb-progress-label' },
766
1497
  label,
767
- isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' } }, '⚠️ <10%') : null
1498
+ isBad
1499
+ ? React.createElement(
1500
+ 'span',
1501
+ { className: 'cb-progress-low' },
1502
+ React.createElement(CbIcon, { name: 'warning', size: 12 }),
1503
+ '<10%'
1504
+ )
1505
+ : null
768
1506
  ),
769
1507
  React.createElement(
770
1508
  'span',
771
- {
772
- className: 'cb-progress-meta cb-bar-meta',
773
- style: {
774
- fontSize: '12px',
775
- color: isBad ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
776
- whiteSpace: 'nowrap',
777
- marginLeft: 'auto',
778
- },
779
- },
1509
+ { className: isBad ? 'cb-progress-meta cb-progress-meta-bad' : 'cb-progress-meta' },
780
1510
  t('quota.used', { pct }),
781
- resetCountdown ? ` · ${resetCountdown === 'resetting' ? (t('quota.resetting') || '正在重置') : t('quota.countdown', { time: resetCountdown })}` : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
1511
+ resetCountdown
1512
+ ? ` · ${resetCountdown === 'resetting' ? (t('quota.resetting') || '正在重置') : t('quota.countdown', { time: resetCountdown })}`
1513
+ : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
782
1514
  )
783
1515
  ),
784
1516
  React.createElement(
785
1517
  'div',
786
- {
787
- className: 'cb-progress-track cb-bar-track',
788
- style: {
789
- width: '100%',
790
- height: '8px',
791
- borderRadius: '999px',
792
- background: 'var(--dsw-alias-bg-layer-1)',
793
- overflow: 'hidden',
794
- border: '1px solid var(--dsw-alias-border-l2)',
795
- },
796
- },
797
- React.createElement('div', {
798
- className: barClass,
799
- style: { width: `${pct}%`, height: '100%', borderRadius: '999px' },
800
- })
1518
+ { className: 'cb-progress-track' },
1519
+ React.createElement('div', { className: barClass, style: { width: `${pct}%` } })
801
1520
  )
802
1521
  )
803
1522
  }
804
1523
 
805
- // --- src/client/components/update-banner.js ---
806
- function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
807
- const status = updateState.status || (
808
- updateState.checking
809
- ? 'checking'
810
- : (updateState.latestCheckFailed
811
- ? 'error'
812
- : (updateState.updateAvailable
813
- ? 'available'
814
- : (updateState.lastCheckedAt ? 'current' : 'idle')))
1524
+ // --- src/client/components/settings-layout.js ---
1525
+ /**
1526
+ * Shared settings layout primitives: a titled group, a foldable group, a
1527
+ * title/description/control row, and the floating staged-save bar.
1528
+ */
1529
+ function SettingsGroup({ title, description, divided = false, action, children }) {
1530
+ return React.createElement(
1531
+ 'section',
1532
+ { className: divided ? 'cb-settings-group cb-settings-group-divided' : 'cb-settings-group', 'aria-label': title },
1533
+ React.createElement(
1534
+ 'div',
1535
+ { className: 'cb-settings-group-head' },
1536
+ React.createElement('h3', { className: 'cb-settings-group-title' }, title),
1537
+ action ? React.createElement('span', { className: 'cb-settings-group-action' }, action) : null
1538
+ ),
1539
+ description ? React.createElement('div', { className: 'cb-settings-group-desc' }, description) : null,
1540
+ React.createElement('div', { className: 'cb-settings-group-body' }, children)
815
1541
  )
1542
+ }
816
1543
 
817
- const isChecking = status === 'checking' || updateState.checking
818
- const isAvailable = status === 'available' || updateState.updateAvailable
819
- const isCurrent = status === 'current'
820
- const isError = status === 'error' || updateState.latestCheckFailed
821
-
1544
+ function CollapsibleGroup({ title, description, defaultOpen = false, divided = false, badge, children }) {
1545
+ const [open, setOpen] = React.useState(Boolean(defaultOpen))
822
1546
  return React.createElement(
823
- React.Fragment,
824
- null,
1547
+ 'section',
1548
+ { className: divided ? 'cb-settings-group cb-settings-group-divided' : 'cb-settings-group', 'aria-label': title },
825
1549
  React.createElement(
826
- 'div',
1550
+ 'button',
827
1551
  {
828
- className: 'cb-row',
829
- style: {
830
- padding: '10px 14px',
831
- borderRadius: '8px',
832
- border: '1px solid var(--dsw-alias-border-l2)',
833
- background: 'var(--dsw-alias-bg-layer-2)',
834
- justifyContent: 'space-between',
835
- }
1552
+ type: 'button',
1553
+ className: 'cb-settings-group-head cb-settings-disclosure',
1554
+ 'aria-expanded': open,
1555
+ onClick: () => setOpen((value) => !value),
836
1556
  },
1557
+ React.createElement('span', { className: 'cb-settings-group-title' }, title),
1558
+ badge !== undefined && badge !== '' ? React.createElement('span', { className: 'cb-badge' }, badge) : null,
1559
+ React.createElement('span', { className: 'cb-settings-spacer' }),
1560
+ React.createElement('span', { className: open ? 'cb-settings-chevron cb-settings-chevron-open' : 'cb-settings-chevron', 'aria-hidden': 'true' }, '›')
1561
+ ),
1562
+ open && description ? React.createElement('div', { className: 'cb-settings-group-desc' }, description) : null,
1563
+ open ? React.createElement('div', { className: 'cb-settings-group-body' }, children) : null
1564
+ )
1565
+ }
1566
+
1567
+ function SettingRow({ title, description, control, error, htmlFor }) {
1568
+ return React.createElement(
1569
+ 'div',
1570
+ { className: 'cb-settings-row' },
1571
+ React.createElement(
1572
+ 'div',
1573
+ { className: 'cb-settings-row-text' },
1574
+ htmlFor
1575
+ ? React.createElement('label', { className: 'cb-settings-row-title', htmlFor }, title)
1576
+ : React.createElement('span', { className: 'cb-settings-row-title' }, title),
1577
+ error
1578
+ ? React.createElement('div', { className: 'cb-settings-row-error' }, error)
1579
+ : (description ? React.createElement('div', { className: 'cb-settings-row-desc' }, description) : null)
1580
+ ),
1581
+ control ? React.createElement('div', { className: 'cb-settings-row-control' }, control) : null
1582
+ )
1583
+ }
1584
+
1585
+ function SaveBar({ pendingCount, saving, saveState, saveError, invalid, validationError, onSave, onDiscard, t }) {
1586
+ const visible = pendingCount > 0 || saving || saveState === 'saved' || saveState === 'error'
1587
+ if (!visible) return null
1588
+ const message = saveError
1589
+ || validationError
1590
+ || (saving
1591
+ ? t('settings.saving')
1592
+ : (saveState === 'saved' ? t('settings.saved') : t('settings.unsaved', { count: pendingCount })))
1593
+ return React.createElement(
1594
+ 'div',
1595
+ { className: 'cb-savebar', role: 'status' },
1596
+ React.createElement(
1597
+ 'div',
1598
+ { className: 'cb-savebar-inner' },
1599
+ React.createElement('span', { className: 'cb-savebar-text' }, message),
1600
+ React.createElement('span', { className: 'cb-settings-spacer' }),
837
1601
  React.createElement(
838
- 'div',
839
- { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
840
- updateState.currentVersion
841
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
842
- `v${updateState.currentVersion}`
843
- )
1602
+ 'button',
1603
+ {
1604
+ type: 'button',
1605
+ className: 'cb-btn',
1606
+ disabled: saving || pendingCount === 0,
1607
+ onClick: onDiscard,
1608
+ },
1609
+ t('settings.discard')
1610
+ ),
1611
+ React.createElement(
1612
+ 'button',
1613
+ {
1614
+ type: 'button',
1615
+ className: 'cb-btn cb-btn-primary',
1616
+ disabled: saving || pendingCount === 0 || invalid,
1617
+ onClick: onSave,
1618
+ },
1619
+ saving ? t('settings.saving') : t('settings.save')
1620
+ )
1621
+ )
1622
+ )
1623
+ }
1624
+
1625
+ // --- src/client/components/integrations-section.js ---
1626
+ /** Integrations & display settings: surfaces outside the chat request itself. */
1627
+ function IntegrationToggle({ id, label, hint, checked, onChange }) {
1628
+ return React.createElement(SettingRow, {
1629
+ title: label,
1630
+ description: hint,
1631
+ htmlFor: id,
1632
+ control: React.createElement(BooleanControl, {
1633
+ checked: !!checked,
1634
+ label,
1635
+ onChange: (next) => onChange(next),
1636
+ }),
1637
+ })
1638
+ }
1639
+
1640
+ function IntegrationsSection({ draft, onStage, transport, t }) {
1641
+ const current = draft || {}
1642
+ return React.createElement(
1643
+ SettingsGroup,
1644
+ {
1645
+ title: t('settings.integrations'),
1646
+ description: t('settings.integrations_desc'),
1647
+ divided: true,
1648
+ },
1649
+ React.createElement(IntegrationToggle, {
1650
+ id: 'cb-web-search',
1651
+ label: t('settings.web_search'),
1652
+ hint: t('settings.web_search_hint'),
1653
+ checked: current.webSearch !== false,
1654
+ onChange: (value) => onStage({ webSearch: value }),
1655
+ }),
1656
+ React.createElement(IntegrationToggle, {
1657
+ id: 'cb-sidebar-quota',
1658
+ label: t('settings.sidebar_quota'),
1659
+ hint: t('settings.sidebar_quota_hint'),
1660
+ checked: current.showSidebarQuota !== false,
1661
+ onChange: (value) => onStage({ showSidebarQuota: value }),
1662
+ }),
1663
+ React.createElement(IntegrationToggle, {
1664
+ id: 'cb-composer-cost',
1665
+ label: t('settings.composer_cost'),
1666
+ hint: t('settings.composer_cost_hint'),
1667
+ checked: current.showComposerCost !== false,
1668
+ onChange: (value) => onStage({ showComposerCost: value }),
1669
+ }),
1670
+ transport === 'pi-ai'
1671
+ ? React.createElement(IntegrationToggle, {
1672
+ id: 'cb-proxy-mode',
1673
+ label: t('settings.proxy_mode'),
1674
+ hint: t('settings.proxy_mode_hint'),
1675
+ checked: current.proxyMode !== false,
1676
+ onChange: (value) => onStage({ proxyMode: value }),
1677
+ })
1678
+ : null
1679
+ )
1680
+ }
1681
+
1682
+ // --- src/client/components/advanced-section.js ---
1683
+ /** Advanced connection, timeout and storage settings. */
1684
+ function AdvancedField({ id, label, hint, value, type, onChange }) {
1685
+ return React.createElement(SettingRow, {
1686
+ title: label,
1687
+ description: hint,
1688
+ htmlFor: id,
1689
+ control: React.createElement('input', {
1690
+ id,
1691
+ className: 'cb-input cb-settings-input',
1692
+ type: type || 'text',
1693
+ value: value === undefined || value === null ? '' : String(value),
1694
+ onChange: (event) => onChange(event.target.value),
1695
+ }),
1696
+ })
1697
+ }
1698
+
1699
+ function AdvancedSection({ draft, onStage, transport, t }) {
1700
+ const current = draft || {}
1701
+ const numeric = (key, minimum) => (raw) => {
1702
+ if (raw === '') return
1703
+ const value = Number(raw)
1704
+ if (Number.isFinite(value) && value > minimum) onStage({ [key]: value })
1705
+ }
1706
+ return React.createElement(
1707
+ CollapsibleGroup,
1708
+ {
1709
+ title: t('settings.advanced'),
1710
+ description: t('settings.advanced_desc'),
1711
+ divided: true,
1712
+ defaultOpen: false,
1713
+ },
1714
+ React.createElement(AdvancedField, {
1715
+ id: 'cb-base-url',
1716
+ label: t('settings.base_url'),
1717
+ hint: t('settings.base_url_hint'),
1718
+ value: current.baseUrl,
1719
+ onChange: (value) => onStage({ baseUrl: value }),
1720
+ }),
1721
+ React.createElement(AdvancedField, {
1722
+ id: 'cb-timeout',
1723
+ label: t('settings.timeout'),
1724
+ hint: t('settings.timeout_hint'),
1725
+ value: current.timeoutMs,
1726
+ type: 'number',
1727
+ onChange: numeric('timeoutMs', 0),
1728
+ }),
1729
+ React.createElement(AdvancedField, {
1730
+ id: 'cb-smoke-timeout',
1731
+ label: t('settings.smoke_timeout'),
1732
+ hint: t('settings.smoke_timeout_hint'),
1733
+ value: current.smokeTimeoutMs,
1734
+ type: 'number',
1735
+ onChange: numeric('smokeTimeoutMs', 0),
1736
+ }),
1737
+ React.createElement(AdvancedField, {
1738
+ id: 'cb-stream-idle',
1739
+ label: t('settings.stream_idle'),
1740
+ hint: t('settings.stream_idle_hint'),
1741
+ value: current.streamIdleTimeoutMs,
1742
+ type: 'number',
1743
+ onChange: numeric('streamIdleTimeoutMs', 0),
1744
+ }),
1745
+ React.createElement(AdvancedField, {
1746
+ id: 'cb-max-payload',
1747
+ label: t('settings.max_payload'),
1748
+ hint: t('settings.max_payload_hint'),
1749
+ value: current.maxPayloadMb,
1750
+ type: 'number',
1751
+ onChange: numeric('maxPayloadMb', 0),
1752
+ }),
1753
+ React.createElement(AdvancedField, {
1754
+ id: 'cb-stats-path',
1755
+ label: t('settings.stats_path'),
1756
+ hint: t('settings.stats_path_hint'),
1757
+ value: current.statsPath,
1758
+ onChange: (value) => onStage({ statsPath: value }),
1759
+ }),
1760
+ React.createElement(AdvancedField, {
1761
+ id: 'cb-models-cache-path',
1762
+ label: t('settings.models_cache_path'),
1763
+ hint: t('settings.models_cache_path_hint'),
1764
+ value: current.modelsCachePath,
1765
+ onChange: (value) => onStage({ modelsCachePath: value }),
1766
+ }),
1767
+ React.createElement(SettingRow, {
1768
+ title: t('settings.transport'),
1769
+ description: t('settings.transport_hint'),
1770
+ htmlFor: 'cb-transport',
1771
+ control: React.createElement(
1772
+ 'select',
1773
+ {
1774
+ id: 'cb-transport',
1775
+ className: 'cb-select',
1776
+ value: current.transport === 'pi-ai' ? 'pi-ai' : 'adapter',
1777
+ onChange: (event) => onStage({ transport: event.target.value }),
1778
+ },
1779
+ React.createElement('option', { value: 'adapter' }, 'Native adapter'),
1780
+ React.createElement('option', { value: 'pi-ai' }, 'Legacy llm-pi-ai + proxy')
1781
+ ),
1782
+ })
1783
+ )
1784
+ }
1785
+
1786
+ // --- src/client/components/update-banner.js ---
1787
+ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
1788
+ const status = updateState.status || (
1789
+ updateState.checking
1790
+ ? 'checking'
1791
+ : (updateState.latestCheckFailed
1792
+ ? 'error'
1793
+ : (updateState.updateAvailable
1794
+ ? 'available'
1795
+ : (updateState.lastCheckedAt ? 'current' : 'idle')))
1796
+ )
1797
+
1798
+ const isChecking = status === 'checking' || updateState.checking
1799
+ const isAvailable = status === 'available' || updateState.updateAvailable
1800
+ const isCurrent = status === 'current'
1801
+ const isError = status === 'error' || updateState.latestCheckFailed
1802
+
1803
+ return React.createElement(
1804
+ React.Fragment,
1805
+ null,
1806
+ React.createElement(
1807
+ 'div',
1808
+ { className: 'cb-update-banner' },
1809
+ React.createElement(
1810
+ 'div',
1811
+ { className: 'cb-update-info' },
1812
+ updateState.currentVersion
1813
+ ? React.createElement('span', { className: 'cb-update-version' }, `v${updateState.currentVersion}`)
844
1814
  : null,
845
1815
  isChecking
846
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
847
- t('update.checking')
848
- )
1816
+ ? React.createElement('span', { className: 'cb-muted' }, t('update.checking'))
849
1817
  : isAvailable
850
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
1818
+ ? React.createElement(
1819
+ 'span',
1820
+ { className: 'cb-badge cb-badge-warn' },
851
1821
  t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
852
1822
  )
853
1823
  : isCurrent
854
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' },
855
- '✓ ' + t('update.up_to_date')
856
- )
1824
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('update.up_to_date'))
857
1825
  : isError
858
- ? React.createElement('span', { className: 'cb-badge cb-badge-bad' },
859
- t('update.check_failed')
860
- )
1826
+ ? React.createElement('span', { className: 'cb-badge cb-badge-bad' }, t('update.check_failed'))
861
1827
  : null
862
1828
  ),
863
1829
  isAvailable
@@ -868,7 +1834,6 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
868
1834
  className: 'cb-btn cb-btn-primary',
869
1835
  disabled: updateState.updating,
870
1836
  onClick: handleTriggerUpdate,
871
- style: { padding: '5px 12px', fontSize: '12px' }
872
1837
  },
873
1838
  updateState.updating ? t('update.updating') : t('update.btn')
874
1839
  )
@@ -880,13 +1845,12 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
880
1845
  className: 'cb-btn',
881
1846
  disabled: isChecking,
882
1847
  onClick: handleCheckUpdate,
883
- style: { padding: '5px 12px', fontSize: '12px' }
884
1848
  },
885
1849
  t('update.retry')
886
1850
  )
887
1851
  : null
888
1852
  ),
889
- updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, '✓ ' + updateState.notice) : null,
1853
+ updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, updateState.notice) : null,
890
1854
  updateState.error ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error) : null
891
1855
  )
892
1856
  }
@@ -894,7 +1858,7 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
894
1858
  // --- src/client/components/key-section.js ---
895
1859
  function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, verifyStatus, t }) {
896
1860
  return React.createElement(
897
- 'div',
1861
+ 'section',
898
1862
  { className: 'cb-section-card' },
899
1863
  React.createElement('div', { className: 'cb-section-title' }, t('key.title')),
900
1864
  React.createElement('div', { className: 'cb-section-desc' }, t('key.desc')),
@@ -906,15 +1870,11 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
906
1870
  type: showKey ? 'text' : 'password',
907
1871
  placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
908
1872
  value: apiKeyInput,
909
- onChange: (e) => setApiKeyInput(e.target.value),
1873
+ onChange: (event) => setApiKeyInput(event.target.value),
910
1874
  }),
911
1875
  React.createElement(
912
1876
  'button',
913
- {
914
- type: 'button',
915
- className: 'cb-btn',
916
- onClick: () => setShowKey((v) => !v),
917
- },
1877
+ { type: 'button', className: 'cb-btn', onClick: () => setShowKey((value) => !value) },
918
1878
  showKey ? t('key.hide') : t('key.show')
919
1879
  ),
920
1880
  React.createElement(
@@ -929,37 +1889,32 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
929
1889
  ),
930
1890
  React.createElement(
931
1891
  'button',
932
- {
933
- type: 'button',
934
- className: 'cb-btn',
935
- disabled: !!busy,
936
- onClick: handleFastLogin,
937
- },
1892
+ { type: 'button', className: 'cb-btn', disabled: !!busy, onClick: handleFastLogin },
938
1893
  t('key.login_fast')
939
1894
  )
940
1895
  ),
941
1896
  verifyStatus && verifyStatus.state === 'verifying'
942
- ? React.createElement('div', { style: { marginTop: '8px', fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('key.verifying'))
1897
+ ? React.createElement('div', { className: 'cb-hint' }, t('key.verifying'))
943
1898
  : null,
944
1899
  verifyStatus && verifyStatus.state === 'valid'
945
- ? React.createElement('div', { className: 'cb-alert-ok', style: { marginTop: '8px', fontSize: '13px' } }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
1900
+ ? React.createElement('div', { className: 'cb-alert-ok cb-key-status' }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
946
1901
  : null,
947
1902
  verifyStatus && verifyStatus.state === 'invalid'
948
- ? React.createElement('div', { className: 'cb-alert-bad', style: { marginTop: '8px', fontSize: '13px' } }, t('key.invalid', { error: verifyStatus.error }))
1903
+ ? React.createElement('div', { className: 'cb-alert-bad cb-key-status' }, t('key.invalid', { error: verifyStatus.error }))
949
1904
  : null,
950
1905
  React.createElement(
951
1906
  'div',
952
- { className: 'cb-row', style: { fontSize: '13px', marginTop: '12px', color: 'var(--dsw-alias-label-secondary)' } },
1907
+ { className: 'cb-key-meta' },
953
1908
  React.createElement('span', null, t('key.env_label')),
954
- React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
955
- React.createElement('span', null, ' · '),
1909
+ React.createElement('code', { className: 'cb-mono' }, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
1910
+ React.createElement('span', { className: 'cb-muted' }, '·'),
956
1911
  React.createElement(
957
1912
  'a',
958
1913
  {
959
1914
  href: 'https://app.cline.bot/settings/api-keys',
960
1915
  target: '_blank',
961
1916
  rel: 'noreferrer',
962
- style: { color: 'var(--dsw-alias-brand-primary)' },
1917
+ className: 'cb-link',
963
1918
  },
964
1919
  t('key.get_key')
965
1920
  )
@@ -968,188 +1923,201 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
968
1923
  }
969
1924
 
970
1925
  // --- src/client/components/accounts-section.js ---
1926
+ /**
1927
+ * Accounts pool: one calm row per account, with live quota, status dot and a
1928
+ * compact action menu. Account mutations commit immediately; the rest of the
1929
+ * settings page uses the staged save bar.
1930
+ */
971
1931
  function AccountsSection({
972
1932
  status,
973
1933
  busy,
974
1934
  handlePinAccount,
975
1935
  handleAddAccount,
976
1936
  handleDeleteAccount,
1937
+ handleRenameAccount,
1938
+ handleClearKey,
977
1939
  accountMode,
978
1940
  handleUpdateAccountMode,
979
1941
  modelRoutes = {},
980
1942
  onUpdateModelRoutes,
981
1943
  t,
982
1944
  }) {
983
- if (!status.accounts || !status.accounts.length) return null
984
-
985
1945
  const [showAdd, setShowAdd] = React.useState(false)
986
1946
  const [newLabel, setNewLabel] = React.useState('')
987
1947
  const [newEnv, setNewEnv] = React.useState('')
988
1948
  const [newKey, setNewKey] = React.useState('')
989
1949
  const [showKey, setShowKey] = React.useState(false)
990
- const [deleteSecret, setDeleteSecret] = React.useState(true)
1950
+ const [discoveredKeys, setDiscoveredKeys] = React.useState([])
1951
+ const [usageByAccount, setUsageByAccount] = React.useState({})
1952
+ const [menuFor, setMenuFor] = React.useState('')
1953
+ const [renameFor, setRenameFor] = React.useState('')
1954
+ const [renameValue, setRenameValue] = React.useState('')
1955
+ const [confirmAction, setConfirmAction] = React.useState(null)
991
1956
  const [routeModel, setRouteModel] = React.useState('')
992
1957
  const [routeAccount, setRouteAccount] = React.useState('')
993
- const [discoveredKeys, setDiscoveredKeys] = React.useState([])
994
1958
 
995
1959
  React.useEffect(() => {
996
1960
  let active = true
997
1961
  fetchWithTimeout(`${ROUTE_PREFIX}/keys/discovered`, { cache: 'no-store' })
998
- .then((r) => r.json())
1962
+ .then((response) => response.json())
999
1963
  .then((data) => {
1000
- if (active && data?.ok && Array.isArray(data.discovered)) {
1001
- setDiscoveredKeys(data.discovered)
1002
- }
1964
+ if (active && data?.ok && Array.isArray(data.discovered)) setDiscoveredKeys(data.discovered)
1003
1965
  })
1004
1966
  .catch(() => {})
1005
1967
  return () => { active = false }
1006
1968
  }, [])
1007
1969
 
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
- id: item.id,
1970
+ const accountSignature = (status?.accounts || []).map((account) => account.apiKeyEnv || account.id).join('|')
1971
+ React.useEffect(() => {
1972
+ let active = true
1973
+ fetchWithTimeout(`${ROUTE_PREFIX}/accounts/usage`, { cache: 'no-store' })
1974
+ .then((response) => response.json())
1975
+ .then((data) => {
1976
+ if (!active || !data?.ok || !Array.isArray(data.accounts)) return
1977
+ const next = {}
1978
+ for (const entry of data.accounts) next[entry.apiKeyEnv] = entry
1979
+ setUsageByAccount(next)
1023
1980
  })
1024
- setDiscoveredKeys((prev) => prev.filter((k) => k.id !== item.id))
1025
- }
1026
- }
1981
+ .catch(() => {})
1982
+ return () => { active = false }
1983
+ }, [accountSignature, status?.activeAccount, busy])
1027
1984
 
1028
- function handleOpenAdd() {
1985
+ function nextEnvName() {
1029
1986
  let max = 1
1030
- for (const a of (status.accounts || [])) {
1031
- const m = String(a.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
1032
- if (m) {
1033
- const n = parseInt(m[1], 10)
1034
- if (n >= max) max = n + 1
1035
- }
1987
+ for (const account of status.accounts || []) {
1988
+ const match = String(account.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
1989
+ if (match) max = Math.max(max, Number(match[1]) + 1)
1036
1990
  }
1037
- setNewEnv(`CLINEBOT_API_KEY_${max}`)
1991
+ return `CLINEBOT_API_KEY_${max}`
1992
+ }
1993
+
1994
+ function openAdd() {
1995
+ setNewEnv(nextEnvName())
1038
1996
  setShowAdd(true)
1039
1997
  }
1040
1998
 
1041
- async function onSubmitAdd(e) {
1042
- if (e && e.preventDefault) e.preventDefault()
1043
- if (!newKey.trim()) return
1044
- if (typeof handleAddAccount === 'function') {
1045
- await handleAddAccount({
1046
- label: newLabel.trim() || newEnv.trim(),
1047
- apiKeyEnv: newEnv.trim(),
1048
- apiKey: newKey.trim(),
1049
- })
1050
- }
1999
+ async function submitAdd(event) {
2000
+ if (event?.preventDefault) event.preventDefault()
2001
+ if (!newKey.trim() || typeof handleAddAccount !== 'function') return
2002
+ await handleAddAccount({
2003
+ label: newLabel.trim() || newEnv.trim(),
2004
+ apiKeyEnv: newEnv.trim(),
2005
+ apiKey: newKey.trim(),
2006
+ })
1051
2007
  setNewLabel('')
1052
2008
  setNewEnv('')
1053
2009
  setNewKey('')
1054
2010
  setShowAdd(false)
1055
2011
  }
1056
2012
 
1057
- async function onDeleteAccount(envName) {
1058
- if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
1059
- if (!window.confirm(t('accounts.delete_confirm', { label: envName }))) return
2013
+ async function importDiscovered(item) {
2014
+ if (typeof handleAddAccount !== 'function') return
2015
+ await handleAddAccount({
2016
+ label: item.source || nextEnvName(),
2017
+ apiKeyEnv: nextEnvName(),
2018
+ id: item.id,
2019
+ })
2020
+ setDiscoveredKeys((previous) => previous.filter((entry) => entry.id !== item.id))
2021
+ }
2022
+
2023
+ function openRename(account) {
2024
+ setMenuFor('')
2025
+ setRenameFor(account.apiKeyEnv)
2026
+ setRenameValue(account.label || '')
2027
+ }
2028
+
2029
+ async function submitRename(account) {
2030
+ if (typeof handleRenameAccount === 'function') {
2031
+ await handleRenameAccount(account.apiKeyEnv, renameValue.trim() || account.apiKeyEnv)
1060
2032
  }
1061
- if (typeof handleDeleteAccount === 'function') {
1062
- await handleDeleteAccount(envName, deleteSecret)
2033
+ setRenameFor('')
2034
+ setRenameValue('')
2035
+ }
2036
+
2037
+ async function runConfirm() {
2038
+ if (!confirmAction) return
2039
+ if (confirmAction.type === 'delete' && typeof handleDeleteAccount === 'function') {
2040
+ await handleDeleteAccount(confirmAction.env, true)
2041
+ }
2042
+ if (confirmAction.type === 'clear' && typeof handleClearKey === 'function') {
2043
+ await handleClearKey(confirmAction.env)
1063
2044
  }
2045
+ setConfirmAction(null)
1064
2046
  }
1065
2047
 
2048
+ function dotClass(account, live) {
2049
+ if (!account.present) return 'cb-status-dot cb-status-dot-warn'
2050
+ if (live?.error) return 'cb-status-dot cb-status-dot-bad'
2051
+ const percent = live?.usage?.windows?.fiveHour?.percentUsed
2052
+ if (typeof percent === 'number' && percent >= 95) return 'cb-status-dot cb-status-dot-bad'
2053
+ if (account.isPinned || status.activeAccount === account.apiKeyEnv) return 'cb-status-dot cb-status-dot-ok'
2054
+ return 'cb-status-dot cb-status-dot-idle'
2055
+ }
2056
+
2057
+ const accounts = status.accounts || []
2058
+
1066
2059
  return React.createElement(
1067
- 'div',
1068
- { className: 'cb-section-card' },
2060
+ 'section',
2061
+ { className: 'cb-section-card cb-accounts' },
1069
2062
  React.createElement(
1070
2063
  'div',
1071
- { className: 'cb-section-title', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1072
- React.createElement('span', null, t('accounts.title')),
2064
+ { className: 'cb-section-head' },
2065
+ React.createElement('div', { className: 'cb-section-title' }, t('accounts.title')),
1073
2066
  !showAdd
1074
2067
  ? React.createElement(
1075
2068
  'button',
1076
- {
1077
- type: 'button',
1078
- className: 'cb-btn',
1079
- style: { fontSize: '11px', padding: '4px 10px' },
1080
- onClick: handleOpenAdd,
1081
- disabled: !!busy,
1082
- },
1083
- t('accounts.add_btn')
2069
+ { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !!busy, onClick: openAdd },
2070
+ React.createElement(CbIcon, { name: 'plus', size: 13 }),
2071
+ ` ${t('accounts.add_btn')}`
1084
2072
  )
1085
2073
  : null
1086
2074
  ),
1087
2075
  React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
2076
+
1088
2077
  discoveredKeys.length > 0
1089
2078
  ? React.createElement(
1090
2079
  '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) =>
2080
+ { className: 'cb-discovered' },
2081
+ React.createElement(
2082
+ 'div',
2083
+ { className: 'cb-discovered-head' },
2084
+ React.createElement(CbIcon, { name: 'key', size: 14 }),
2085
+ ` ${t('discovery.title')}`
2086
+ ),
2087
+ discoveredKeys.map((item) => React.createElement(
2088
+ 'div',
2089
+ { className: 'cb-discovered-row', key: item.id },
2090
+ React.createElement('span', { className: 'cb-mono' }, item.maskedKey || item.source),
2091
+ React.createElement('span', { className: 'cb-muted' }, item.source || ''),
1094
2092
  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
- )
2093
+ 'button',
2094
+ {
2095
+ type: 'button',
2096
+ className: 'cb-btn',
2097
+ disabled: !!busy,
2098
+ onClick: () => importDiscovered(item),
2099
+ },
2100
+ React.createElement(CbIcon, { name: 'download', size: 12 }),
2101
+ ` ${t('discovery.import_btn', { source: item.source || '' })}`
1109
2102
  )
1110
- )
2103
+ ))
1111
2104
  )
1112
2105
  : null,
2106
+
1113
2107
  React.createElement(
1114
2108
  'div',
1115
- {
1116
- style: {
1117
- display: 'flex',
1118
- alignItems: 'center',
1119
- gap: '8px',
1120
- margin: '8px 0 12px 0',
1121
- fontSize: '12px',
1122
- color: 'var(--dsw-alias-label-secondary)',
1123
- },
1124
- },
1125
- React.createElement('span', { style: { fontWeight: 500 } }, t('accounts.mode_label') + ':'),
2109
+ { className: 'cb-account-mode' },
2110
+ React.createElement('span', { className: 'cb-muted' }, t('accounts.mode_label')),
1126
2111
  React.createElement(
1127
2112
  'div',
1128
- {
1129
- style: {
1130
- display: 'inline-flex',
1131
- borderRadius: '6px',
1132
- border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
1133
- overflow: 'hidden',
1134
- },
1135
- },
2113
+ { className: 'cb-seg' },
1136
2114
  React.createElement(
1137
2115
  'button',
1138
2116
  {
1139
2117
  type: 'button',
1140
- className: 'cb-btn',
1141
- style: {
1142
- borderRadius: 0,
1143
- fontSize: '11px',
1144
- padding: '3px 9px',
1145
- border: 'none',
1146
- background: (accountMode || 'least_used') === 'least_used'
1147
- ? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
1148
- : 'transparent',
1149
- fontWeight: (accountMode || 'least_used') === 'least_used' ? 600 : 400,
1150
- },
1151
- onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('least_used'),
2118
+ className: (accountMode || 'least_used') === 'least_used' ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
1152
2119
  disabled: !!busy,
2120
+ onClick: () => handleUpdateAccountMode && handleUpdateAccountMode('least_used'),
1153
2121
  },
1154
2122
  t('accounts.mode_least_used')
1155
2123
  ),
@@ -1157,20 +2125,9 @@ function AccountsSection({
1157
2125
  'button',
1158
2126
  {
1159
2127
  type: 'button',
1160
- className: 'cb-btn',
1161
- style: {
1162
- borderRadius: 0,
1163
- fontSize: '11px',
1164
- padding: '3px 9px',
1165
- border: 'none',
1166
- borderLeft: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
1167
- background: accountMode === 'round_robin'
1168
- ? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
1169
- : 'transparent',
1170
- fontWeight: accountMode === 'round_robin' ? 600 : 400,
1171
- },
1172
- onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('round_robin'),
2128
+ className: accountMode === 'round_robin' ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
1173
2129
  disabled: !!busy,
2130
+ onClick: () => handleUpdateAccountMode && handleUpdateAccountMode('round_robin'),
1174
2131
  },
1175
2132
  t('accounts.mode_round_robin')
1176
2133
  )
@@ -1180,72 +2137,47 @@ function AccountsSection({
1180
2137
  showAdd
1181
2138
  ? React.createElement(
1182
2139
  'form',
1183
- {
1184
- onSubmit: onSubmitAdd,
1185
- style: {
1186
- background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1187
- border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
1188
- borderRadius: '8px',
1189
- padding: '12px',
1190
- marginBottom: '14px',
1191
- display: 'flex',
1192
- flexDirection: 'column',
1193
- gap: '8px',
1194
- },
1195
- },
2140
+ { className: 'cb-account-add', onSubmit: submitAdd },
1196
2141
  React.createElement(
1197
2142
  'div',
1198
- { style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
2143
+ { className: 'cb-account-add-row' },
1199
2144
  React.createElement('input', {
1200
2145
  type: 'text',
1201
2146
  className: 'cb-input',
1202
- style: { flex: '1 1 180px' },
1203
2147
  placeholder: t('accounts.label_placeholder'),
1204
2148
  value: newLabel,
1205
- onChange: (e) => setNewLabel(e.target.value),
2149
+ onChange: (event) => setNewLabel(event.target.value),
1206
2150
  }),
1207
2151
  React.createElement('input', {
1208
2152
  type: 'text',
1209
2153
  className: 'cb-input',
1210
- style: { flex: '1 1 180px' },
1211
2154
  placeholder: t('accounts.env_placeholder'),
1212
2155
  value: newEnv,
1213
- onChange: (e) => setNewEnv(e.target.value),
2156
+ onChange: (event) => setNewEnv(event.target.value),
1214
2157
  })
1215
2158
  ),
1216
2159
  React.createElement(
1217
2160
  'div',
1218
- { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
2161
+ { className: 'cb-account-add-row' },
1219
2162
  React.createElement('input', {
1220
2163
  type: showKey ? 'text' : 'password',
1221
2164
  className: 'cb-input',
1222
- style: { flex: '1' },
1223
2165
  placeholder: t('key.placeholder_empty'),
1224
2166
  value: newKey,
1225
- onChange: (e) => setNewKey(e.target.value),
2167
+ onChange: (event) => setNewKey(event.target.value),
1226
2168
  }),
1227
2169
  React.createElement(
1228
2170
  'button',
1229
- {
1230
- type: 'button',
1231
- className: 'cb-btn',
1232
- style: { padding: '4px 8px', fontSize: '11px' },
1233
- onClick: () => setShowKey(!showKey),
1234
- },
2171
+ { type: 'button', className: 'cb-btn', onClick: () => setShowKey((value) => !value) },
1235
2172
  showKey ? t('key.hide') : t('key.show')
1236
2173
  )
1237
2174
  ),
1238
2175
  React.createElement(
1239
2176
  'div',
1240
- { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '4px' } },
2177
+ { className: 'cb-account-add-actions' },
1241
2178
  React.createElement(
1242
2179
  'button',
1243
- {
1244
- type: 'button',
1245
- className: 'cb-btn',
1246
- style: { padding: '4px 10px', fontSize: '12px' },
1247
- onClick: () => setShowAdd(false),
1248
- },
2180
+ { type: 'button', className: 'cb-btn', onClick: () => setShowAdd(false) },
1249
2181
  t('accounts.cancel_btn')
1250
2182
  ),
1251
2183
  React.createElement(
@@ -1253,7 +2185,6 @@ function AccountsSection({
1253
2185
  {
1254
2186
  type: 'submit',
1255
2187
  className: 'cb-btn cb-btn-primary',
1256
- style: { padding: '4px 12px', fontSize: '12px' },
1257
2188
  disabled: !newKey.trim() || !!busy,
1258
2189
  },
1259
2190
  busy === 'add-account' ? t('key.saving') : t('accounts.save_btn')
@@ -1262,147 +2193,186 @@ function AccountsSection({
1262
2193
  )
1263
2194
  : null,
1264
2195
 
1265
- React.createElement(
1266
- 'table',
1267
- { className: 'cb-table' },
1268
- React.createElement(
1269
- 'thead',
1270
- null,
1271
- React.createElement(
1272
- 'tr',
1273
- null,
1274
- React.createElement('th', null, 'Account'),
1275
- React.createElement('th', null, 'Credential Ref'),
1276
- React.createElement('th', null, 'Status'),
1277
- React.createElement('th', { style: { textAlign: 'right' } }, 'Action')
1278
- )
1279
- ),
1280
- React.createElement(
1281
- 'tbody',
1282
- null,
1283
- status.accounts.map((acc) => {
1284
- const envName = typeof acc.apiKeyEnv === 'string' ? acc.apiKeyEnv : ''
1285
- const label = typeof acc.label === 'string' ? acc.label : ''
1286
- const isActive = status.activeAccount === envName || (!status.activeAccount && acc.id === 'default')
1287
- const isPrimary = acc.id === 'default' || envName === status.config?.apiKeyEnv
1288
- return React.createElement(
1289
- 'tr',
1290
- { key: acc.id || envName },
1291
- React.createElement('td', null, React.createElement('strong', null, label)),
1292
- React.createElement('td', null, React.createElement('code', null, envName)),
1293
- React.createElement(
1294
- 'td',
1295
- null,
1296
- acc.present
1297
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, `Configured (${acc.source})`)
1298
- : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
1299
- isActive
1300
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
1301
- : null,
1302
- acc.isPinned && !isActive
1303
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.85 } }, t('accounts.pinned_badge'))
2196
+ accounts.length === 0
2197
+ ? React.createElement('div', { className: 'cb-empty' }, t('accounts.no_accounts'))
2198
+ : React.createElement(
2199
+ 'div',
2200
+ { className: 'cb-account-list' },
2201
+ accounts.map((account) => {
2202
+ const envName = account.apiKeyEnv || account.id || ''
2203
+ const live = usageByAccount[envName]
2204
+ const isActive = status.activeAccount === envName || (!status.activeAccount && account.id === 'default')
2205
+ const isPrimary = account.id === 'default' || envName === status.config?.apiKeyEnv
2206
+ const five = live?.usage?.windows?.fiveHour
2207
+ const percent = typeof five?.percentUsed === 'number'
2208
+ ? five.percentUsed
2209
+ : (typeof account.percentUsed === 'number' ? account.percentUsed : null)
2210
+ const plan = live?.usage?.plan || ''
2211
+ const email = live?.usage?.user?.email || ''
2212
+ return React.createElement(
2213
+ 'div',
2214
+ { className: 'cb-account-item', key: envName || account.id },
2215
+ React.createElement(
2216
+ 'div',
2217
+ { className: 'cb-account-row' },
2218
+ React.createElement('span', { className: dotClass(account, live), 'aria-hidden': 'true' }),
2219
+ React.createElement(
2220
+ 'div',
2221
+ { className: 'cb-account-main' },
2222
+ React.createElement(
2223
+ 'div',
2224
+ { className: 'cb-account-name' },
2225
+ account.label || envName,
2226
+ React.createElement('span', { className: 'cb-account-env cb-mono' }, envName)
2227
+ ),
2228
+ React.createElement(
2229
+ 'div',
2230
+ { className: 'cb-account-meta' },
2231
+ [plan, email].filter(Boolean).join(' · ') || t('accounts.source', { source: account.source || '—' })
2232
+ )
2233
+ ),
2234
+ React.createElement(
2235
+ 'div',
2236
+ { className: 'cb-account-badges' },
2237
+ isActive ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('accounts.active_badge')) : null,
2238
+ account.isPinned ? React.createElement('span', { className: 'cb-badge' }, t('accounts.pinned_badge')) : null,
2239
+ account.present
2240
+ ? React.createElement('span', { className: 'cb-badge' }, t('accounts.configured'))
2241
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, t('accounts.missing_key'))
2242
+ ),
2243
+ React.createElement(
2244
+ 'div',
2245
+ { className: 'cb-account-quota' },
2246
+ percent === null
2247
+ ? null
2248
+ : React.createElement(
2249
+ React.Fragment,
2250
+ null,
2251
+ React.createElement('span', { className: 'cb-muted' }, t('accounts.quota_5h', { pct: Math.round(percent) })),
2252
+ React.createElement(
2253
+ 'span',
2254
+ { className: 'cb-meter' },
2255
+ React.createElement('span', {
2256
+ className: percent >= 95 ? 'cb-meter-fill cb-meter-fill-warn' : 'cb-meter-fill',
2257
+ style: { width: `${Math.max(0, Math.min(100, percent))}%` },
2258
+ })
2259
+ )
2260
+ )
2261
+ ),
2262
+ React.createElement(
2263
+ 'button',
2264
+ {
2265
+ type: 'button',
2266
+ className: 'cb-icon-btn',
2267
+ 'aria-label': t('accounts.actions'),
2268
+ 'aria-expanded': menuFor === envName,
2269
+ disabled: !!busy,
2270
+ onClick: () => setMenuFor((current) => (current === envName ? '' : envName)),
2271
+ },
2272
+ React.createElement(CbIcon, { name: 'kebab', size: 16 })
2273
+ )
2274
+ ),
2275
+
2276
+ menuFor === envName
2277
+ ? React.createElement(
2278
+ 'div',
2279
+ { className: 'cb-account-menu' },
2280
+ isActive
2281
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); handlePinAccount('') } }, t('accounts.unpin'))
2282
+ : account.present
2283
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); handlePinAccount(envName) } }, t('accounts.pin_btn'))
2284
+ : null,
2285
+ !isPrimary
2286
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => openRename(account) }, t('accounts.rename'))
2287
+ : null,
2288
+ account.present
2289
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); setConfirmAction({ type: 'clear', env: envName }) } }, t('accounts.clear_key'))
2290
+ : null,
2291
+ !isPrimary
2292
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item cb-menu-item-danger', onClick: () => { setMenuFor(''); setConfirmAction({ type: 'delete', env: envName }) } }, t('accounts.delete_btn'))
2293
+ : null
2294
+ )
1304
2295
  : null,
1305
- typeof acc.percentUsed === 'number'
1306
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px' } }, t('accounts.quota_used', { pct: acc.percentUsed }))
1307
- : null
1308
- ),
1309
- React.createElement(
1310
- 'td',
1311
- { style: { textAlign: 'right' } },
1312
- !isActive && acc.present
2296
+
2297
+ renameFor === envName
1313
2298
  ? React.createElement(
1314
- 'button',
1315
- {
1316
- type: 'button',
1317
- className: 'cb-btn',
1318
- style: { padding: '4px 8px', fontSize: '11px' },
1319
- disabled: !!busy,
1320
- onClick: () => handlePinAccount(envName),
1321
- },
1322
- t('accounts.pin_btn')
2299
+ 'div',
2300
+ { className: 'cb-account-inline' },
2301
+ React.createElement('input', {
2302
+ type: 'text',
2303
+ className: 'cb-input',
2304
+ value: renameValue,
2305
+ onChange: (event) => setRenameValue(event.target.value),
2306
+ placeholder: t('accounts.rename_placeholder'),
2307
+ }),
2308
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !!busy, onClick: () => submitRename(account) }, t('accounts.rename_save')),
2309
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => setRenameFor('') }, t('accounts.cancel'))
1323
2310
  )
1324
2311
  : null,
1325
- !isPrimary
2312
+
2313
+ confirmAction && confirmAction.env === envName
1326
2314
  ? React.createElement(
1327
- 'button',
1328
- {
1329
- type: 'button',
1330
- className: 'cb-btn cb-btn-danger',
1331
- style: { padding: '4px 8px', fontSize: '11px', marginLeft: '6px' },
1332
- disabled: !!busy,
1333
- onClick: () => onDeleteAccount(envName),
1334
- },
1335
- t('accounts.delete_btn')
2315
+ 'div',
2316
+ { className: 'cb-account-inline cb-account-danger' },
2317
+ React.createElement(
2318
+ 'span',
2319
+ { className: 'cb-account-danger-text' },
2320
+ confirmAction.type === 'delete'
2321
+ ? t('accounts.remove_confirm', { name: account.label || envName })
2322
+ : t('accounts.clear_confirm', { name: account.label || envName })
2323
+ ),
2324
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-danger', disabled: !!busy, onClick: runConfirm }, t('accounts.confirm')),
2325
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => setConfirmAction(null) }, t('accounts.cancel'))
1336
2326
  )
1337
2327
  : null
1338
2328
  )
1339
- )
1340
- })
1341
- )
1342
- ),
1343
- status.lastRotation
1344
- ? React.createElement(
1345
- 'div',
1346
- { className: 'cb-rotation-info', style: { marginTop: '10px', fontSize: '12px', opacity: 0.85 } },
1347
- t('accounts.last_failover', {
1348
- from: status.lastRotation.from,
1349
- to: status.lastRotation.to,
1350
- reason: status.lastRotation.reason,
1351
2329
  })
1352
- )
1353
- : null,
2330
+ ),
2331
+
1354
2332
  React.createElement(
1355
2333
  'div',
1356
- { style: { marginTop: '14px', paddingTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
2334
+ { className: 'cb-account-routes' },
1357
2335
  React.createElement(
1358
2336
  'div',
1359
- { style: { fontSize: '12px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)', marginBottom: '4px' } },
1360
- t('accounts.routes_title')
2337
+ { className: 'cb-account-routes-head' },
2338
+ React.createElement(CbIcon, { name: 'route', size: 14 }),
2339
+ ` ${t('accounts.routes_title')}`
1361
2340
  ),
2341
+ React.createElement('div', { className: 'cb-section-desc' }, t('accounts.routes_desc')),
1362
2342
  React.createElement(
1363
2343
  'div',
1364
- { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '8px' } },
1365
- t('accounts.routes_desc')
1366
- ),
1367
- React.createElement(
1368
- 'div',
1369
- { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } },
2344
+ { className: 'cb-account-route-add' },
1370
2345
  React.createElement('input', {
1371
2346
  type: 'text',
1372
2347
  className: 'cb-input',
1373
- style: { flex: '2 1 140px', height: '26px', fontSize: '11px', padding: '0 6px' },
1374
2348
  placeholder: t('accounts.route_model_placeholder'),
1375
2349
  value: routeModel,
1376
- onChange: (e) => setRouteModel(e.target.value),
2350
+ onChange: (event) => setRouteModel(event.target.value),
1377
2351
  }),
1378
2352
  React.createElement(
1379
2353
  'select',
1380
2354
  {
1381
2355
  className: 'cb-select',
1382
- style: { flex: '1 1 120px', height: '26px', fontSize: '11px', padding: '0 4px' },
1383
2356
  value: routeAccount,
1384
- onChange: (e) => setRouteAccount(e.target.value),
2357
+ onChange: (event) => setRouteAccount(event.target.value),
1385
2358
  },
1386
2359
  React.createElement('option', { value: '' }, t('accounts.route_account_placeholder')),
1387
- (status.accounts || []).map((acc) => {
1388
- const keyName = acc.apiKeyEnv || acc.id
1389
- return React.createElement('option', { key: keyName, value: keyName }, `${acc.label || keyName} (${keyName})`)
2360
+ accounts.map((account) => {
2361
+ const key = account.apiKeyEnv || account.id
2362
+ return React.createElement('option', { key, value: key }, `${account.label || key} (${key})`)
1390
2363
  })
1391
2364
  ),
1392
2365
  React.createElement(
1393
2366
  'button',
1394
2367
  {
1395
2368
  type: 'button',
1396
- className: 'cb-btn cb-btn-primary',
1397
- style: { padding: '3px 8px', fontSize: '11px' },
2369
+ className: 'cb-btn',
1398
2370
  disabled: !routeModel.trim() || !routeAccount.trim() || !!busy,
1399
2371
  onClick: () => {
1400
- if (typeof onUpdateModelRoutes === 'function') {
1401
- const next = { ...(modelRoutes || {}), [routeModel.trim()]: routeAccount.trim() }
1402
- onUpdateModelRoutes(next)
1403
- setRouteModel('')
1404
- setRouteAccount('')
1405
- }
2372
+ if (typeof onUpdateModelRoutes !== 'function') return
2373
+ onUpdateModelRoutes({ ...(modelRoutes || {}), [routeModel.trim()]: routeAccount.trim() })
2374
+ setRouteModel('')
2375
+ setRouteAccount('')
1406
2376
  },
1407
2377
  },
1408
2378
  t('accounts.route_btn_add')
@@ -1411,48 +2381,30 @@ function AccountsSection({
1411
2381
  Object.keys(modelRoutes || {}).length > 0
1412
2382
  ? React.createElement(
1413
2383
  'div',
1414
- { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' } },
1415
- Object.entries(modelRoutes || {}).map(([mId, accKey]) =>
2384
+ { className: 'cb-account-route-list' },
2385
+ Object.entries(modelRoutes || {}).map(([modelId, accountKey]) => React.createElement(
2386
+ 'div',
2387
+ { className: 'cb-account-route-row', key: modelId },
2388
+ React.createElement('span', { className: 'cb-mono' }, modelId),
2389
+ React.createElement(CbIcon, { name: 'chevron', size: 12 }),
2390
+ React.createElement('strong', null, accountKey),
1416
2391
  React.createElement(
1417
- 'div',
2392
+ 'button',
1418
2393
  {
1419
- key: mId,
1420
- style: {
1421
- display: 'flex',
1422
- justifyContent: 'space-between',
1423
- alignItems: 'center',
1424
- padding: '3px 8px',
1425
- fontSize: '11px',
1426
- background: 'var(--dsw-alias-bg-layer-2)',
1427
- borderRadius: '4px',
2394
+ type: 'button',
2395
+ className: 'cb-icon-btn',
2396
+ 'aria-label': t('accounts.route_remove'),
2397
+ disabled: !!busy,
2398
+ onClick: () => {
2399
+ if (typeof onUpdateModelRoutes !== 'function') return
2400
+ const next = { ...(modelRoutes || {}) }
2401
+ delete next[modelId]
2402
+ onUpdateModelRoutes(next)
1428
2403
  },
1429
2404
  },
1430
- React.createElement(
1431
- 'span',
1432
- null,
1433
- React.createElement('code', null, mId),
1434
- ' ➔ ',
1435
- React.createElement('strong', null, accKey)
1436
- ),
1437
- React.createElement(
1438
- 'button',
1439
- {
1440
- type: 'button',
1441
- className: 'cb-btn cb-btn-danger',
1442
- style: { padding: '1px 6px', fontSize: '10px' },
1443
- disabled: !!busy,
1444
- onClick: () => {
1445
- if (typeof onUpdateModelRoutes === 'function') {
1446
- const next = { ...(modelRoutes || {}) }
1447
- delete next[mId]
1448
- onUpdateModelRoutes(next)
1449
- }
1450
- },
1451
- },
1452
- '✕'
1453
- )
2405
+ React.createElement(CbIcon, { name: 'close', size: 13 })
1454
2406
  )
1455
- )
2407
+ ))
1456
2408
  )
1457
2409
  : null
1458
2410
  )
@@ -1474,15 +2426,26 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1474
2426
  ? React.createElement(
1475
2427
  'div',
1476
2428
  { className: status.quotaWarning.level === 'exhausted' ? 'cb-banner-exhausted' : 'cb-banner-warning' },
1477
- React.createElement('span', { style: { fontSize: '16px' } }, status.quotaWarning.level === 'exhausted' ? '🚨' : '⚠️'),
2429
+ React.createElement(
2430
+ 'span',
2431
+ { className: 'cb-banner-icon' },
2432
+ React.createElement(CbIcon, {
2433
+ name: status.quotaWarning.level === 'exhausted' ? 'error' : 'warning',
2434
+ size: 16,
2435
+ })
2436
+ ),
1478
2437
  React.createElement(
1479
2438
  'div',
1480
- { style: { flex: 1 } },
1481
- React.createElement('div', null, (status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message),
2439
+ { className: 'cb-banner-body' },
2440
+ React.createElement(
2441
+ 'div',
2442
+ null,
2443
+ (status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message
2444
+ ),
1482
2445
  status.quotaWarning.resetsAt
1483
2446
  ? React.createElement(
1484
2447
  'div',
1485
- { style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
2448
+ { className: 'cb-banner-sub' },
1486
2449
  t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
1487
2450
  )
1488
2451
  : null
@@ -1492,26 +2455,25 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1492
2455
  ? React.createElement(
1493
2456
  'div',
1494
2457
  { className: 'cb-banner-warning' },
1495
- React.createElement('span', { style: { fontSize: '16px' } }, '⚠️'),
1496
- React.createElement('div', { style: { flex: 1 } }, t('quota.warning_low'))
2458
+ React.createElement(
2459
+ 'span',
2460
+ { className: 'cb-banner-icon' },
2461
+ React.createElement(CbIcon, { name: 'warning', size: 16 })
2462
+ ),
2463
+ React.createElement('div', { className: 'cb-banner-body' }, t('quota.warning_low'))
1497
2464
  )
1498
2465
  : null),
1499
2466
  keyPresent
1500
2467
  ? React.createElement(
1501
- 'div',
2468
+ 'section',
1502
2469
  { className: 'cb-section-card' },
1503
2470
  React.createElement(
1504
2471
  'div',
1505
- { className: 'cb-section-title' },
1506
- t('quota.title'),
2472
+ { className: 'cb-section-head' },
2473
+ React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
1507
2474
  React.createElement(
1508
2475
  'button',
1509
- {
1510
- type: 'button',
1511
- className: 'cb-btn',
1512
- disabled: !!busy,
1513
- onClick: handleRefreshQuota,
1514
- },
2476
+ { type: 'button', className: 'cb-btn', disabled: !!busy, onClick: handleRefreshQuota },
1515
2477
  busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
1516
2478
  )
1517
2479
  ),
@@ -1524,7 +2486,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1524
2486
  ),
1525
2487
  React.createElement(
1526
2488
  'div',
1527
- { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '10px' } },
2489
+ { className: 'cb-quota-grid' },
1528
2490
  usage?.windows?.fiveHour
1529
2491
  ? React.createElement(ProgressBar, {
1530
2492
  label: t('quota.window_5h'),
@@ -1551,21 +2513,15 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1551
2513
  })
1552
2514
  : null
1553
2515
  ),
1554
- React.createElement(
1555
- 'div',
1556
- { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', marginTop: '4px', paddingTop: '16px', borderTop: '.5px solid var(--dsw-alias-border-l2)' } },
1557
- React.createElement(
1558
- 'div',
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'))
1562
- ),
1563
- React.createElement(BooleanControl, {
2516
+ React.createElement(SettingRow, {
2517
+ title: t('sidebar.show_quota'),
2518
+ description: t('sidebar.show_quota_hint'),
2519
+ control: React.createElement(BooleanControl, {
1564
2520
  checked: showSidebarQuota !== false,
1565
2521
  label: t('sidebar.show_quota'),
1566
2522
  onChange: (next) => onToggleSidebarQuota && onToggleSidebarQuota(next),
1567
- })
1568
- )
2523
+ }),
2524
+ })
1569
2525
  )
1570
2526
  : null
1571
2527
  )
@@ -1632,12 +2588,17 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1632
2588
  React.createElement(
1633
2589
  'span',
1634
2590
  { style: { fontWeight: 500, fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } },
1635
- `➕ ${t('models.custom_add_title')} (${customModels.length})`
2591
+ React.createElement(CbIcon, { name: 'plus', size: 13 }),
2592
+ ` ${t('models.custom_add_title')} (${customModels.length})`
1636
2593
  ),
1637
2594
  React.createElement(
1638
2595
  'span',
1639
2596
  { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
1640
- isExpanded ? '▲' : '▼'
2597
+ React.createElement(CbIcon, {
2598
+ name: 'chevron',
2599
+ size: 13,
2600
+ className: isExpanded ? 'cb-icon cb-icon-chevron-open' : 'cb-icon',
2601
+ })
1641
2602
  )
1642
2603
  ),
1643
2604
  isExpanded
@@ -1897,7 +2858,12 @@ function VisibleModelsSub({
1897
2858
  style: { padding: '4px 10px', fontSize: '12px' },
1898
2859
  onClick: () => setOpen(!open),
1899
2860
  },
1900
- open ? '▲ ' + t('models.models_selected', { selected: count, total: modelsList.length }) : '▼ ' + t('models.models_selected', { selected: count, total: modelsList.length })
2861
+ React.createElement(CbIcon, {
2862
+ name: 'chevron',
2863
+ size: 12,
2864
+ className: open ? 'cb-icon cb-icon-chevron-open' : 'cb-icon',
2865
+ }),
2866
+ ` ${t('models.models_selected', { selected: count, total: modelsList.length })}`
1901
2867
  )
1902
2868
  )
1903
2869
  ),
@@ -1984,7 +2950,7 @@ function VisibleModelsSub({
1984
2950
  ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 6px' } }, t('models.in_plan'))
1985
2951
  : React.createElement('span', { className: 'cb-badge', style: { fontSize: '10px', padding: '1px 6px', color: 'var(--dsw-alias-label-secondary)' } }, t('models.out_of_plan')),
1986
2952
  !m.isContextOverridden
1987
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 5px' } }, '原厂')
2953
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 5px' } }, t('models.ctx_tag_original'))
1988
2954
  : null
1989
2955
  )
1990
2956
  )
@@ -1995,6 +2961,23 @@ function VisibleModelsSub({
1995
2961
  )
1996
2962
  }
1997
2963
 
2964
+ // --- src/client/components/provider-card.js ---
2965
+ /**
2966
+ * Provider card inside DSH Settings -> Models.
2967
+ *
2968
+ * The host owns the row chrome: provider name, status dot and the Edit action.
2969
+ * Accounts, the API key, models and quotas live on the full Settings -> ClineBot
2970
+ * page, so this slot intentionally renders nothing instead of duplicating that
2971
+ * form inside the Models page.
2972
+ */
2973
+ function ProviderCard(props) {
2974
+ const row = props?.provider || {}
2975
+ const providerId = typeof row === 'string' ? row : (row.provider || props?.providerId || '')
2976
+ const settingsNs = row && typeof row === 'object' ? (row.settingsNs || '') : ''
2977
+ if (providerId !== 'clinebot' && settingsNs !== NS) return null
2978
+ return null
2979
+ }
2980
+
1998
2981
  // --- src/client/components/models-section.js ---
1999
2982
  function ModelsSection({
2000
2983
  modelsList,
@@ -2061,9 +3044,7 @@ function ModelsSection({
2061
3044
  return React.createElement(
2062
3045
  'div',
2063
3046
  { className: 'cb-section-card' },
2064
- defaultModelWarning
2065
- ? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, `⚠️ ${defaultModelWarning}`)
2066
- : null,
3047
+ defaultModelWarning ? React.createElement('div', { className: 'cb-alert-bad cb-inline-alert', style: { marginBottom: '12px' } }, React.createElement(CbIcon, { name: 'warning', size: 14 }), ` ${defaultModelWarning}`) : null,
2067
3048
  React.createElement(VisibleModelsSub, {
2068
3049
  modelsList,
2069
3050
  visibleModels,
@@ -2424,7 +3405,7 @@ function ModelsSection({
2424
3405
  setEditingModelId(m.id)
2425
3406
  },
2426
3407
  },
2427
- '✎'
3408
+ React.createElement(CbIcon, { name: 'edit', size: 13 })
2428
3409
  )
2429
3410
  )
2430
3411
  ),
@@ -2503,6 +3484,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2503
3484
  const requests = typeof totals.requests === 'number' ? totals.requests : (typeof s.requests === 'number' ? s.requests : 0)
2504
3485
  const totalTokens = typeof totals.totalTokens === 'number' ? totals.totalTokens : (typeof s.totalTokens === 'number' ? s.totalTokens : 0)
2505
3486
  const errors429 = typeof totals.rateLimited429 === 'number' ? totals.rateLimited429 : (typeof s.errors429 === 'number' ? s.errors429 : (typeof totals.failed === 'number' ? totals.failed : 0))
3487
+ const reasoning = Number(totals.reasoningTokens || sess.reasoningTokensEst || 0)
2506
3488
 
2507
3489
  const topModels = Object.entries(s.byModel || {})
2508
3490
  .sort((a, b) => {
@@ -2513,24 +3495,24 @@ function StatsSection({ status, onResetStats, busy, t }) {
2513
3495
  .slice(0, 5)
2514
3496
 
2515
3497
  return React.createElement(
2516
- 'div',
3498
+ 'section',
2517
3499
  { className: 'cb-section-card' },
2518
3500
  React.createElement(
2519
3501
  'div',
2520
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
3502
+ { className: 'cb-section-head' },
2521
3503
  React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
2522
3504
  React.createElement(
2523
3505
  'div',
2524
- { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
3506
+ { className: 'cb-section-actions' },
2525
3507
  React.createElement(
2526
3508
  'a',
2527
3509
  {
2528
3510
  href: '/dsh-clinebot/stats/export',
2529
3511
  download: 'clinebot-stats.csv',
2530
- className: 'cb-btn',
2531
- style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
3512
+ className: 'cb-btn cb-btn-link',
2532
3513
  },
2533
- `📥 ${t('stats.export_csv')}`
3514
+ React.createElement(CbIcon, { name: 'download', size: 13 }),
3515
+ ` ${t('stats.export_csv')}`
2534
3516
  ),
2535
3517
  confirmReset
2536
3518
  ? React.createElement(
@@ -2541,7 +3523,6 @@ function StatsSection({ status, onResetStats, busy, t }) {
2541
3523
  {
2542
3524
  type: 'button',
2543
3525
  className: 'cb-btn cb-btn-warn',
2544
- style: { padding: '2px 8px', fontSize: '11px', marginRight: '6px' },
2545
3526
  disabled: !!busy,
2546
3527
  onClick: () => {
2547
3528
  setConfirmReset(false)
@@ -2552,12 +3533,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2552
3533
  ),
2553
3534
  React.createElement(
2554
3535
  'button',
2555
- {
2556
- type: 'button',
2557
- className: 'cb-btn',
2558
- style: { padding: '2px 8px', fontSize: '11px' },
2559
- onClick: () => setConfirmReset(false),
2560
- },
3536
+ { type: 'button', className: 'cb-btn', onClick: () => setConfirmReset(false) },
2561
3537
  t('stats.cancel_reset')
2562
3538
  )
2563
3539
  )
@@ -2566,7 +3542,6 @@ function StatsSection({ status, onResetStats, busy, t }) {
2566
3542
  {
2567
3543
  type: 'button',
2568
3544
  className: 'cb-btn',
2569
- style: { padding: '2px 8px', fontSize: '11px' },
2570
3545
  disabled: !!busy,
2571
3546
  onClick: () => setConfirmReset(true),
2572
3547
  },
@@ -2577,7 +3552,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2577
3552
  React.createElement('div', { className: 'cb-section-desc' }, t('stats.desc')),
2578
3553
  React.createElement(
2579
3554
  'div',
2580
- { className: 'cb-grid-2', style: { marginTop: '12px' } },
3555
+ { className: 'cb-stats-grid' },
2581
3556
  React.createElement(
2582
3557
  'div',
2583
3558
  { className: 'cb-stat-box' },
@@ -2587,16 +3562,12 @@ function StatsSection({ status, onResetStats, busy, t }) {
2587
3562
  React.createElement(
2588
3563
  'div',
2589
3564
  { className: 'cb-stat-box' },
2590
- React.createElement(
2591
- 'div',
2592
- { className: 'cb-stat-val' },
2593
- `${Number(totalTokens).toLocaleString()}`
2594
- ),
3565
+ React.createElement('div', { className: 'cb-stat-val' }, Number(totalTokens).toLocaleString()),
2595
3566
  React.createElement(
2596
3567
  'div',
2597
3568
  { className: 'cb-stat-lbl' },
2598
- totals.reasoningTokens || sess.reasoningTokensEst
2599
- ? `${t('stats.tokens')} (${Number(totals.reasoningTokens || sess.reasoningTokensEst).toLocaleString()} reasoning)`
3569
+ reasoning > 0
3570
+ ? `${t('stats.tokens')} (${reasoning.toLocaleString()} reasoning)`
2600
3571
  : t('stats.tokens')
2601
3572
  )
2602
3573
  ),
@@ -2616,30 +3587,18 @@ function StatsSection({ status, onResetStats, busy, t }) {
2616
3587
  topModels.length > 0
2617
3588
  ? React.createElement(
2618
3589
  'div',
2619
- { style: { marginTop: '14px' } },
2620
- React.createElement('div', { style: { fontSize: '12px', fontWeight: 500, marginBottom: '6px', color: 'var(--dsw-alias-label-secondary)' } }, t('stats.top_models')),
2621
- React.createElement(
3590
+ { className: 'cb-model-list' },
3591
+ React.createElement('div', { className: 'cb-model-list-title' }, t('stats.top_models')),
3592
+ topModels.map(([modelId, data]) => React.createElement(
2622
3593
  'div',
2623
- { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
2624
- topModels.map(([modId, data]) =>
2625
- React.createElement(
2626
- 'div',
2627
- {
2628
- key: modId,
2629
- style: {
2630
- display: 'flex',
2631
- justifyContent: 'space-between',
2632
- fontSize: '11px',
2633
- padding: '4px 8px',
2634
- background: 'var(--dsw-alias-bg-layer-2)',
2635
- borderRadius: '4px',
2636
- },
2637
- },
2638
- React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
2639
- React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} thk)` : ''}`)
2640
- )
3594
+ { className: 'cb-model-row', key: modelId },
3595
+ React.createElement('span', { className: 'cb-mono' }, modelId),
3596
+ React.createElement(
3597
+ 'span',
3598
+ { className: 'cb-muted' },
3599
+ `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} reasoning)` : ''}`
2641
3600
  )
2642
- )
3601
+ ))
2643
3602
  )
2644
3603
  : null
2645
3604
  )
@@ -2659,49 +3618,19 @@ function DiagSection({
2659
3618
  t,
2660
3619
  }) {
2661
3620
  return React.createElement(
2662
- 'div',
3621
+ 'section',
2663
3622
  { className: 'cb-section-card' },
2664
3623
  React.createElement('div', { className: 'cb-section-title' }, t('diag.title')),
2665
3624
  React.createElement('div', { className: 'cb-section-desc' }, t('diag.desc')),
2666
- React.createElement(
2667
- 'div',
2668
- {
2669
- style: {
2670
- display: 'flex',
2671
- alignItems: 'center',
2672
- justifyContent: 'space-between',
2673
- padding: '10px 14px',
2674
- background: 'var(--dsw-alias-bg-layer-2)',
2675
- borderRadius: '8px',
2676
- border: '1px solid var(--dsw-alias-border-l2)',
2677
- },
2678
- },
2679
- React.createElement(
2680
- 'div',
2681
- { style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
2682
- React.createElement(
2683
- 'span',
2684
- { style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
2685
- t('diag.proxy_title')
2686
- ),
2687
- React.createElement(
2688
- 'span',
2689
- { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
2690
- t('diag.proxy_desc')
2691
- )
2692
- ),
2693
- React.createElement(
2694
- 'div',
2695
- { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px' } },
3625
+ React.createElement(SettingRow, {
3626
+ title: t('diag.proxy_title'),
3627
+ description: t('diag.proxy_desc'),
3628
+ control: React.createElement(
3629
+ React.Fragment,
3630
+ null,
2696
3631
  React.createElement(
2697
3632
  'span',
2698
- {
2699
- style: {
2700
- fontSize: '14px',
2701
- lineHeight: '20px',
2702
- color: proxyMode ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
2703
- },
2704
- },
3633
+ { className: proxyMode ? 'cb-state-ok' : 'cb-muted' },
2705
3634
  proxyMode ? t('diag.proxy_enabled') : t('diag.proxy_disabled')
2706
3635
  ),
2707
3636
  React.createElement(BooleanControl, {
@@ -2710,11 +3639,11 @@ function DiagSection({
2710
3639
  label: t('diag.proxy_title'),
2711
3640
  onChange: (next) => onToggleProxy && onToggleProxy(next),
2712
3641
  })
2713
- )
2714
- ),
3642
+ ),
3643
+ }),
2715
3644
  React.createElement(
2716
3645
  'div',
2717
- { className: 'cb-row' },
3646
+ { className: 'cb-row cb-row-actions' },
2718
3647
  React.createElement(
2719
3648
  'button',
2720
3649
  {
@@ -2737,12 +3666,12 @@ function DiagSection({
2737
3666
  busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
2738
3667
  )
2739
3668
  : null,
3669
+ React.createElement('span', { className: 'cb-settings-spacer' }),
2740
3670
  React.createElement(
2741
3671
  'button',
2742
3672
  {
2743
3673
  type: 'button',
2744
3674
  className: 'cb-btn cb-btn-primary',
2745
- style: { marginLeft: 'auto' },
2746
3675
  disabled: !!busy,
2747
3676
  onClick: handleRegister,
2748
3677
  },
@@ -2802,7 +3731,7 @@ function MiniRing({ percent, size = 18, warn = false }) {
2802
3731
  )
2803
3732
  }
2804
3733
 
2805
- function SidebarQuota({ ctx, wide, t: propT }) {
3734
+ function SidebarQuota({ ctx, wide, t: propT, open: openPanel }) {
2806
3735
  const t = propT || makeT(en, en)
2807
3736
  const [data, setData] = React.useState(null)
2808
3737
  const [open, setOpen] = React.useState(false)
@@ -2839,18 +3768,48 @@ function SidebarQuota({ ctx, wide, t: propT }) {
2839
3768
 
2840
3769
  if (!show) return null
2841
3770
 
2842
- const fiveHour = data?.windows?.fiveHour
2843
- const weekly = data?.windows?.weekly
2844
- const activeWindow = fiveHour || weekly
2845
- const pct = Math.round(activeWindow?.percentUsed || 0)
2846
- const isWarn = pct >= 85
2847
- const planName = data?.plan || t('title')
3771
+ const windowEntries = [
3772
+ { key: 'fiveHour', label: t('quota.window_5h'), value: data?.windows?.fiveHour },
3773
+ { key: 'weekly', label: t('quota.window_weekly'), value: data?.windows?.weekly },
3774
+ { key: 'monthly', label: t('quota.window_monthly'), value: data?.windows?.monthly },
3775
+ ]
3776
+
3777
+ const windowPct = (window) => {
3778
+ const value = Number(window.value?.percentUsed)
3779
+ return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : null
3780
+ }
3781
+
3782
+ const measured = windowEntries
3783
+ .map((window) => ({ ...window, pct: windowPct(window) }))
3784
+ .filter((window) => window.pct !== null)
3785
+
3786
+ const pct = measured.length ? Math.max(...measured.map((window) => window.pct)) : 0
3787
+ const isWarn = measured.some((window) => window.pct >= 85)
3788
+ const planName = String(data?.plan || t('title')).replace(/\s*\(\$[^)]*\)\s*$/, '')
3789
+ const accountLabel = data?.account ? ` · ${data.account}` : ''
3790
+ const tooltip = `${planName}${accountLabel} · ${windowEntries.map((window) => {
3791
+ const value = windowPct(window)
3792
+ return `${window.label} ${value === null ? '—' : `${value}% ${t('dashboard.used')}`}`
3793
+ }).join(' · ')}`
2848
3794
 
2849
3795
  // The window is local to the meter: nothing is selected or navigated, so the
2850
3796
  // conversation stays where it is and closing simply drops the window.
2851
- const handleOpen = () => setOpen(true)
3797
+ const handleOpen = () => {
3798
+ if (typeof openPanel === 'function') {
3799
+ try {
3800
+ if (openPanel() !== false) return
3801
+ } catch {
3802
+ /* fall through to the local window */
3803
+ }
3804
+ }
3805
+ setOpen(true)
3806
+ }
3807
+
3808
+ // Keep all three rows visible even when the endpoint omits a window for the
3809
+ // current plan; the missing row shows an explicit dash instead of silently
3810
+ // relabelling another window.
3811
+ const rows = windowEntries
2852
3812
 
2853
- const tooltip = `${t('sidebar.quota_title')}: ${pct}% ${t('dashboard.used')} (${planName})`
2854
3813
  const dialog = React.createElement(DashboardDialog, { t, open, onClose: () => setOpen(false) })
2855
3814
 
2856
3815
  if (!wide) {
@@ -2891,68 +3850,403 @@ function SidebarQuota({ ctx, wide, t: propT }) {
2891
3850
  { className: 'cb-footTop' },
2892
3851
  React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
2893
3852
  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}%`)
3853
+ React.createElement('span', { className: isWarn ? 'cb-footPct cb-footPctWarn' : 'cb-footPct' }, `${pct}%`)
2895
3854
  ),
3855
+ rows.map((window) => {
3856
+ const value = windowPct(window)
3857
+ const hasData = value !== null
3858
+ const warn = hasData && value >= 85
3859
+ const reset = hasData && typeof formatResetCountdown === 'function'
3860
+ ? formatResetCountdown(window.value?.resetsAt)
3861
+ : ''
3862
+ const resetText = reset
3863
+ ? ` · ${reset === 'resetting' ? t('quota.resetting') : reset}`
3864
+ : ''
3865
+ return React.createElement(
3866
+ 'div',
3867
+ { className: hasData ? 'cb-footRow' : 'cb-footRow cb-footRowMissing', key: window.key },
3868
+ React.createElement(
3869
+ 'div',
3870
+ { className: 'cb-footHead' },
3871
+ React.createElement('span', { className: 'cb-footLabel' }, window.label),
3872
+ React.createElement(
3873
+ 'span',
3874
+ { className: hasData ? 'cb-footAmount' : 'cb-footAmount cb-footAmountMissing' },
3875
+ hasData ? `${100 - value}% ${t('dashboard.remaining')}${resetText}` : '—'
3876
+ )
3877
+ ),
3878
+ React.createElement(
3879
+ 'span',
3880
+ { className: 'cb-footBar' },
3881
+ React.createElement('span', {
3882
+ className: warn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
3883
+ style: { width: hasData ? `${Math.min(100, Math.max(2, value))}%` : '0%' },
3884
+ })
3885
+ )
3886
+ )
3887
+ }),
3888
+ rows.some((window) => windowPct(window) !== null)
3889
+ ? null
3890
+ : React.createElement('span', { className: 'cb-footMessage' }, data?.error || t('quota.no_windows'))
3891
+ ),
3892
+ dialog
3893
+ )
3894
+ }
3895
+
3896
+ // --- src/client/components/price-section.js ---
3897
+ /**
3898
+ * Per-model price overrides. A blank field falls back to the built-in local
3899
+ * estimate; the values are USD per 1M tokens.
3900
+ */
3901
+ function PriceSection({ modelsList = [], priceOverrides = {}, onConfigUpdate, t }) {
3902
+ const [expanded, setExpanded] = React.useState(false)
3903
+ const models = Array.isArray(modelsList) ? modelsList : []
3904
+
3905
+ const update = (modelId, field, raw) => {
3906
+ const next = { ...(priceOverrides || {}) }
3907
+ const current = { ...(next[modelId] || {}) }
3908
+ const value = String(raw ?? '').trim()
3909
+ const numeric = Number(value)
3910
+ if (value === '' || !Number.isFinite(numeric) || numeric < 0) delete current[field]
3911
+ else current[field] = numeric
3912
+ if (Object.keys(current).length === 0) delete next[modelId]
3913
+ else next[modelId] = current
3914
+ onConfigUpdate({ priceOverrides: next })
3915
+ }
3916
+
3917
+ return React.createElement(
3918
+ 'div',
3919
+ { className: 'cb-price-section' },
3920
+ React.createElement(
3921
+ 'div',
3922
+ { className: 'cb-price-head' },
2896
3923
  React.createElement(
2897
3924
  'div',
2898
- { className: 'cb-footRow' },
2899
- React.createElement(
3925
+ { className: 'cb-price-copy' },
3926
+ React.createElement('div', { className: 'cb-section-title' }, t('prices.title')),
3927
+ React.createElement('div', { className: 'cb-section-desc' }, t('prices.desc'))
3928
+ ),
3929
+ React.createElement(
3930
+ 'button',
3931
+ { type: 'button', className: 'cb-btn', onClick: () => setExpanded((value) => !value) },
3932
+ expanded ? t('prices.hide') : t('prices.show')
3933
+ )
3934
+ ),
3935
+ expanded
3936
+ ? React.createElement(
2900
3937
  '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(
3938
+ { className: 'cb-price-table' },
3939
+ React.createElement(
3940
+ 'div',
3941
+ { className: 'cb-price-head-row' },
3942
+ React.createElement('span', null, t('prices.model')),
3943
+ React.createElement('span', null, t('prices.input')),
3944
+ React.createElement('span', null, t('prices.output')),
3945
+ React.createElement('span', null, t('prices.cache_read')),
3946
+ React.createElement('span', null, t('prices.cache_write'))
3947
+ ),
3948
+ models.length === 0
3949
+ ? React.createElement('div', { className: 'cb-hint' }, t('prices.empty'))
3950
+ : models.map((model) => {
3951
+ const override = priceOverrides?.[model.id] || {}
3952
+ return React.createElement(
3953
+ 'div',
3954
+ { className: 'cb-price-row', key: model.id },
3955
+ React.createElement('span', { className: 'cb-mono', title: model.id }, model.name || model.id),
3956
+ ['input', 'output', 'cacheRead', 'cacheWrite'].map((field) => React.createElement('input', {
3957
+ key: field,
3958
+ className: 'cb-input cb-price-input',
3959
+ type: 'number',
3960
+ min: '0',
3961
+ step: '0.001',
3962
+ value: override[field] === undefined ? '' : String(override[field]),
3963
+ placeholder: t('prices.auto'),
3964
+ onChange: (event) => update(model.id, field, event.target.value),
3965
+ }))
3966
+ )
3967
+ }),
3968
+ React.createElement('div', { className: 'cb-hint' }, t('prices.hint'))
3969
+ )
3970
+ : null
3971
+ )
3972
+ }
3973
+
3974
+ // --- src/client/components/analytics-heatmap.js ---
3975
+ /**
3976
+ * Weekday × hour request heatmap, matching the reference providers: seven rows
3977
+ * (Mon–Sun), 24 columns, a peak-hour line and a compact by-hour strip. The
3978
+ * model selector re-fetches the same range for one model.
3979
+ */
3980
+ function heatWeekdayLabels() {
3981
+ return Array.from({ length: 7 }, (_, index) => {
3982
+ const date = new Date(2024, 0, 1 + index)
3983
+ return date.toLocaleDateString(undefined, { weekday: 'short' })
3984
+ })
3985
+ }
3986
+
3987
+ function heatHourLabel(hour) {
3988
+ return hour % 3 === 0 ? String(hour).padStart(2, '0') : ''
3989
+ }
3990
+
3991
+ function HeatmapView({ heatmap, model, onModelChange, t }) {
3992
+ if (!heatmap || !Array.isArray(heatmap.grid)) {
3993
+ return React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
3994
+ }
3995
+
3996
+ const grid = heatmap.grid
3997
+ const hours = Array.isArray(heatmap.hours) ? heatmap.hours : Array(24).fill(0)
3998
+ const total = Number(heatmap.total) || 0
3999
+ const models = Array.isArray(heatmap.models) ? heatmap.models : []
4000
+ const max = Math.max(1, ...grid.flat())
4001
+ const maxHours = Math.max(1, ...hours)
4002
+ const peakHour = hours.reduce((best, value, hour) => (value > (hours[best] || 0) ? hour : best), 0)
4003
+ const peak = hours[peakHour] || 0
4004
+ const level = (value) => {
4005
+ const requests = Number(value) || 0
4006
+ if (requests <= 0) return 0
4007
+ return Math.min(4, Math.max(1, Math.ceil((requests / max) * 4)))
4008
+ }
4009
+ const weekdays = heatWeekdayLabels()
4010
+
4011
+ return React.createElement(
4012
+ 'div',
4013
+ { className: 'cb-heat-wrap' },
4014
+ React.createElement(
4015
+ 'div',
4016
+ { className: 'cb-heat-toolbar' },
4017
+ React.createElement('span', { className: 'cb-badge' }, `${total} ${t('analytics.requests')}`),
4018
+ models.length > 1
4019
+ ? React.createElement(
4020
+ 'select',
4021
+ {
4022
+ className: 'cb-heat-select',
4023
+ value: model || '',
4024
+ title: t('analytics.heatmap_model'),
4025
+ onChange: (event) => onModelChange && onModelChange(event.target.value),
4026
+ },
4027
+ React.createElement('option', { value: '' }, `${t('analytics.heatmap_all_models')} (${total})`),
4028
+ models.map((entry) => React.createElement(
4029
+ 'option',
4030
+ { key: entry.model, value: entry.model },
4031
+ `${entry.model} (${entry.total})`
4032
+ ))
4033
+ )
4034
+ : null,
4035
+ peak > 0
4036
+ ? React.createElement('span', { className: 'cb-hint' }, `${t('analytics.heatmap_peak')}: ${String(peakHour).padStart(2, '0')}:00 · ${peak}`)
4037
+ : null
4038
+ ),
4039
+ React.createElement(
4040
+ 'div',
4041
+ { className: 'cb-heat-scroll' },
4042
+ React.createElement(
4043
+ 'div',
4044
+ { className: 'cb-heat' },
4045
+ React.createElement('span', { className: 'cb-heat-corner' }),
4046
+ hours.map((_, hour) => React.createElement(
4047
+ 'span',
4048
+ { className: 'cb-heat-hour', key: `hour-${hour}` },
4049
+ heatHourLabel(hour)
4050
+ )),
4051
+ weekdays.map((label, day) => React.createElement(
4052
+ React.Fragment,
4053
+ { key: `day-${day}` },
4054
+ React.createElement('span', { className: 'cb-heat-day' }, label),
4055
+ (grid[day] || []).map((value, hour) => React.createElement('span', {
4056
+ key: `cell-${day}-${hour}`,
4057
+ className: `cb-heat-cell cb-heat-l${level(value)}`,
4058
+ title: `${label} ${String(hour).padStart(2, '0')}:00 — ${value} ${t('analytics.requests')}`,
4059
+ }))
4060
+ ))
4061
+ ),
4062
+ React.createElement('p', { className: 'cb-hour-strip-head' }, t('analytics.heatmap_by_hour')),
4063
+ React.createElement(
4064
+ 'div',
4065
+ { className: 'cb-hour-strip' },
4066
+ React.createElement('span'),
4067
+ hours.map((value, hour) => React.createElement(
2906
4068
  'span',
2907
- { className: 'cb-footBar' },
4069
+ { className: 'cb-hour-col', key: `strip-${hour}`, title: `${String(hour).padStart(2, '0')}:00 — ${value}` },
2908
4070
  React.createElement('span', {
2909
- className: isWarn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
2910
- style: { width: `${Math.min(100, Math.max(2, pct))}%` },
4071
+ className: `cb-hour-bar cb-heat-l${level(value)}`,
4072
+ style: { height: `${Math.max(2, Math.round(28 * (Number(value) || 0) / maxHours))}px` },
2911
4073
  })
4074
+ ))
4075
+ ),
4076
+ React.createElement(
4077
+ 'div',
4078
+ { className: 'cb-hour-labels' },
4079
+ React.createElement('span'),
4080
+ hours.map((_, hour) => React.createElement('span', { key: `label-${hour}` }, heatHourLabel(hour)))
4081
+ )
4082
+ ),
4083
+ React.createElement(
4084
+ 'div',
4085
+ { className: 'cb-heat-legend' },
4086
+ React.createElement('span', null, t('analytics.heatmap_less')),
4087
+ [0, 1, 2, 3, 4].map((bucket) => React.createElement('span', {
4088
+ key: bucket,
4089
+ className: `cb-heat-swatch cb-heat-l${bucket}`,
4090
+ })),
4091
+ React.createElement('span', null, t('analytics.heatmap_more'))
4092
+ ),
4093
+ total === 0
4094
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
4095
+ : null
4096
+ )
4097
+ }
4098
+
4099
+ // --- src/client/components/quota-panel-view.js ---
4100
+ /**
4101
+ * The account/quota cards shared by the center-column panel and the modal
4102
+ * fallback. Account tabs and the selected account's report are passed in by
4103
+ * DashboardDialog; this component only renders the selected snapshot.
4104
+ */
4105
+ function QuotaPanelView({
4106
+ accountTabs,
4107
+ selectedUsage,
4108
+ accountName,
4109
+ accountEmail,
4110
+ accountPlan,
4111
+ accountBadges = [],
4112
+ activeAcc,
4113
+ routingMode,
4114
+ enabledCount,
4115
+ t,
4116
+ }) {
4117
+ return React.createElement(
4118
+ React.Fragment,
4119
+ null,
4120
+ accountTabs,
4121
+ selectedUsage?.ok === false
4122
+ ? React.createElement('div', { className: 'cb-alert-bad' }, selectedUsage.error || t('analytics.no_data'))
4123
+ : null,
4124
+ React.createElement(
4125
+ 'div',
4126
+ { className: 'cb-dialogCard' },
4127
+ React.createElement(
4128
+ 'div',
4129
+ { className: 'cb-dialogRow' },
4130
+ React.createElement(
4131
+ 'div',
4132
+ { className: 'cb-dialogIdentity' },
4133
+ React.createElement('div', { className: 'cb-row-title' }, accountName),
4134
+ React.createElement('div', { className: 'cb-row-desc' }, [accountEmail, accountPlan].filter(Boolean).join(' · '))
4135
+ ),
4136
+ React.createElement(
4137
+ 'div',
4138
+ { className: 'cb-dialogBadges' },
4139
+ accountBadges.map((badge, index) => React.createElement('span', { className: badge.className, key: `account-badge-${index}` }, badge.label)),
4140
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
4141
+ )
4142
+ ),
4143
+ React.createElement(
4144
+ 'div',
4145
+ { className: 'cb-dialogTiles' },
4146
+ React.createElement(
4147
+ 'div',
4148
+ { className: 'cb-stat-box' },
4149
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
4150
+ React.createElement('div', { className: 'cb-stat-val' }, activeAcc)
4151
+ ),
4152
+ React.createElement(
4153
+ 'div',
4154
+ { className: 'cb-stat-box' },
4155
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
4156
+ React.createElement('div', { className: 'cb-stat-val' }, routingMode)
2912
4157
  )
2913
4158
  )
2914
4159
  ),
2915
- dialog
4160
+ React.createElement(
4161
+ 'div',
4162
+ { className: 'cb-dialogCard' },
4163
+ React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
4164
+ React.createElement(
4165
+ 'div',
4166
+ { className: 'cb-dialogWindows' },
4167
+ selectedUsage?.windows?.fiveHour
4168
+ ? React.createElement(ProgressBar, {
4169
+ label: t('quota.window_5h'),
4170
+ percentUsed: selectedUsage.windows.fiveHour.percentUsed || 0,
4171
+ remainingPercent: selectedUsage.windows.fiveHour.remainingPercent || 100,
4172
+ resetsAt: selectedUsage.windows.fiveHour.resetsAt,
4173
+ t,
4174
+ })
4175
+ : null,
4176
+ selectedUsage?.windows?.weekly
4177
+ ? React.createElement(ProgressBar, {
4178
+ label: t('quota.window_weekly'),
4179
+ percentUsed: selectedUsage.windows.weekly.percentUsed || 0,
4180
+ remainingPercent: selectedUsage.windows.weekly.remainingPercent || 100,
4181
+ resetsAt: selectedUsage.windows.weekly.resetsAt,
4182
+ t,
4183
+ })
4184
+ : null,
4185
+ selectedUsage?.windows?.monthly
4186
+ ? React.createElement(ProgressBar, {
4187
+ label: t('quota.window_monthly'),
4188
+ percentUsed: selectedUsage.windows.monthly.percentUsed || 0,
4189
+ remainingPercent: selectedUsage.windows.monthly.remainingPercent || 100,
4190
+ resetsAt: selectedUsage.windows.monthly.resetsAt,
4191
+ t,
4192
+ })
4193
+ : null,
4194
+ !selectedUsage?.windows || (!selectedUsage.windows.fiveHour && !selectedUsage.windows.weekly && !selectedUsage.windows.monthly)
4195
+ ? React.createElement('div', { className: 'cb-hint' }, t('quota.no_windows'))
4196
+ : null
4197
+ )
4198
+ )
2916
4199
  )
2917
4200
  }
2918
4201
 
2919
4202
  // --- src/client/components/dashboard-dialog.js ---
2920
4203
  /**
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.
4204
+ * Quota and analytics surfaces. The sidebar card opens this as the layout's
4205
+ * center-column panel (the Command Code / OpenCode shape); when the layout
4206
+ * service is unavailable the same content falls back to the shell's Modal.
2932
4207
  */
2933
4208
  function DashboardDialog(props) {
2934
4209
  const t = props?.t || makeT(en, en)
2935
- const open = props?.open !== false
4210
+ const panel = props?.panel === true
4211
+ const open = panel || props?.open !== false
2936
4212
  const onClose = typeof props?.onClose === 'function' ? props.onClose : () => {}
2937
4213
 
2938
4214
  const [data, setData] = React.useState(null)
2939
4215
  const [status, setStatus] = React.useState(null)
4216
+ const [analytics, setAnalytics] = React.useState(null)
4217
+ const [tab, setTab] = React.useState('quota')
4218
+ const [groupBy, setGroupBy] = React.useState('model')
2940
4219
  const [loading, setLoading] = React.useState(false)
4220
+ const [analyticsLoading, setAnalyticsLoading] = React.useState(false)
2941
4221
  const [updatedAt, setUpdatedAt] = React.useState('')
2942
4222
  const [err, setErr] = React.useState('')
4223
+ const [accountsUsage, setAccountsUsage] = React.useState(null)
4224
+ const [selectedAccount, setSelectedAccount] = React.useState('')
4225
+ const [analyticsDays, setAnalyticsDays] = React.useState(7)
4226
+ const [heatmapModel, setHeatmapModel] = React.useState('')
2943
4227
 
2944
4228
  const loadData = React.useCallback(async () => {
2945
4229
  setLoading(true)
2946
4230
  setErr('')
2947
4231
  try {
2948
- const [uRes, sRes] = await Promise.all([
4232
+ const [uRes, sRes, aRes] = await Promise.all([
2949
4233
  fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' }),
2950
4234
  fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
4235
+ fetchWithTimeout(`${ROUTE_PREFIX}/accounts/usage`, { cache: 'no-store' }),
2951
4236
  ])
2952
4237
  const uJson = await uRes.json().catch(() => ({}))
2953
4238
  const sJson = await sRes.json().catch(() => ({}))
4239
+ const aJson = await aRes.json().catch(() => ({}))
2954
4240
  if (uJson?.ok) setData(uJson)
2955
4241
  if (sJson?.ok) setStatus(sJson)
4242
+ if (aJson?.ok) {
4243
+ setAccountsUsage(aJson)
4244
+ setSelectedAccount((current) => {
4245
+ const accounts = Array.isArray(aJson.accounts) ? aJson.accounts : []
4246
+ if (accounts.some((entry) => entry.apiKeyEnv === current)) return current
4247
+ return aJson.activeAccount || accounts[0]?.apiKeyEnv || ''
4248
+ })
4249
+ }
2956
4250
  setUpdatedAt(new Date().toLocaleTimeString())
2957
4251
  } catch (e) {
2958
4252
  setErr(String(e?.message || e))
@@ -2961,105 +4255,375 @@ function DashboardDialog(props) {
2961
4255
  }
2962
4256
  }, [])
2963
4257
 
4258
+ const loadAnalytics = React.useCallback(async (group = groupBy, days = analyticsDays, model = heatmapModel) => {
4259
+ setAnalyticsLoading(true)
4260
+ setErr('')
4261
+ try {
4262
+ const from = Date.now() - days * 86_400_000
4263
+ const query = [
4264
+ `groupBy=${encodeURIComponent(group)}`,
4265
+ 'limit=20',
4266
+ `from=${from}`,
4267
+ `heatmapDays=${days}`,
4268
+ ...(model ? [`heatmapModel=${encodeURIComponent(model)}`] : []),
4269
+ ].join('&')
4270
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/analytics?${query}`, { cache: 'no-store' })
4271
+ const json = await res.json().catch(() => ({}))
4272
+ if (!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`)
4273
+ setAnalytics(json)
4274
+ } catch (e) {
4275
+ setErr(String(e?.message || e))
4276
+ } finally {
4277
+ setAnalyticsLoading(false)
4278
+ }
4279
+ }, [groupBy, analyticsDays, heatmapModel])
4280
+
2964
4281
  React.useEffect(() => {
2965
4282
  if (open) loadData()
2966
4283
  }, [open, loadData])
2967
4284
 
4285
+ React.useEffect(() => {
4286
+ if (open && (panel || tab === 'analytics')) loadAnalytics(groupBy, analyticsDays, heatmapModel)
4287
+ }, [open, panel, tab, groupBy, analyticsDays, heatmapModel, loadAnalytics])
4288
+
4289
+ React.useEffect(() => {
4290
+ if (!open) return undefined
4291
+ let timer = null
4292
+ const tick = () => {
4293
+ loadData()
4294
+ if (panel || tab === 'analytics') loadAnalytics(groupBy, analyticsDays, heatmapModel)
4295
+ timer = setTimeout(tick, 120000)
4296
+ }
4297
+ timer = setTimeout(tick, 120000)
4298
+ return () => { if (timer) clearTimeout(timer) }
4299
+ }, [open, panel, tab, groupBy, analyticsDays, heatmapModel, loadData, loadAnalytics])
4300
+
2968
4301
  if (!open) return null
2969
4302
 
4303
+ function fmtTokens(value) {
4304
+ const n = Number(value) || 0
4305
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
4306
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
4307
+ return String(n)
4308
+ }
4309
+
4310
+ function fmtCost(value) {
4311
+ const n = Number(value) || 0
4312
+ if (n === 0) return '$0'
4313
+ if (n < 0.0001) return '< $0.0001'
4314
+ return `$${n.toFixed(4)}`
4315
+ }
4316
+
4317
+ function fmtMs(value) {
4318
+ const n = Number(value) || 0
4319
+ return n > 0 ? `${Math.round(n)} ms` : '—'
4320
+ }
4321
+
2970
4322
  const plan = data?.plan || status?.config?.defaultModel || 'ClinePass'
2971
4323
  const email = data?.user?.email || ''
2972
- const activeAcc = status?.config?.activeAccount || 'Auto (Least-used)'
4324
+ const activeAcc = status?.config?.activeAccount || t('dashboard.auto_account')
2973
4325
  const enabledCount = status?.config?.enabledModels?.length || 0
2974
4326
  const routingMode = status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used')
4327
+ const accountEntries = Array.isArray(accountsUsage?.accounts) ? accountsUsage.accounts : []
4328
+ const selectedEntry = accountEntries.find((entry) => entry.apiKeyEnv === selectedAccount)
4329
+ || accountEntries.find((entry) => entry.active)
4330
+ || accountEntries[0]
4331
+ const selectedUsage = selectedEntry?.usage || data
4332
+ const accountPlan = selectedUsage?.plan || plan
4333
+ const accountEmail = selectedUsage?.user?.email || email
4334
+ const accountName = selectedEntry?.label || selectedEntry?.apiKeyEnv || accountPlan
4335
+ const accountBadges = []
4336
+ if (selectedEntry) {
4337
+ accountBadges.push(selectedEntry.configured === false
4338
+ ? { className: 'cb-badge cb-badge-warn', label: t('accounts.missing_key') }
4339
+ : { className: 'cb-badge cb-badge-ok', label: t('accounts.configured') })
4340
+ if (selectedEntry.active) accountBadges.push({ className: 'cb-badge cb-badge-ok', label: t('accounts.active_badge') })
4341
+ if (selectedEntry.isPinned) accountBadges.push({ className: 'cb-badge', label: t('accounts.pinned_badge') })
4342
+ }
4343
+ const accountTabs = accountEntries.length > 1
4344
+ ? React.createElement(
4345
+ 'div',
4346
+ { className: 'cb-account-tabs' },
4347
+ accountEntries.map((entry) => React.createElement(
4348
+ 'button',
4349
+ {
4350
+ type: 'button',
4351
+ key: entry.apiKeyEnv || entry.id,
4352
+ className: entry.apiKeyEnv === selectedEntry?.apiKeyEnv ? 'cb-account-tab cb-account-tab-active' : 'cb-account-tab',
4353
+ onClick: () => setSelectedAccount(entry.apiKeyEnv),
4354
+ },
4355
+ entry.label || entry.apiKeyEnv || entry.id
4356
+ ))
4357
+ )
4358
+ : null
2975
4359
 
2976
- const body = React.createElement(
4360
+ const noticeItems = []
4361
+ if (status?.key && status.key.present === false) {
4362
+ noticeItems.push(React.createElement(
4363
+ 'div',
4364
+ { className: 'cb-notice', key: 'no-key' },
4365
+ React.createElement('div', { className: 'cb-notice-title' }, t('analytics.no_key')),
4366
+ React.createElement('div', { className: 'cb-notice-hint' }, t('analytics.no_key_hint'))
4367
+ ))
4368
+ }
4369
+ if (status?.health && status.health.ok === false) {
4370
+ noticeItems.push(React.createElement(
4371
+ 'div',
4372
+ { className: 'cb-notice cb-notice-error', key: 'health' },
4373
+ React.createElement('div', { className: 'cb-notice-title' }, t('analytics.health_error')),
4374
+ React.createElement('div', { className: 'cb-notice-hint' }, status.health.error || '')
4375
+ ))
4376
+ }
4377
+ if (status && status.isRegistered === false) {
4378
+ noticeItems.push(React.createElement(
4379
+ 'div',
4380
+ { className: 'cb-notice', key: 'not-registered' },
4381
+ React.createElement('div', { className: 'cb-notice-title' }, t('analytics.not_registered')),
4382
+ React.createElement('div', { className: 'cb-notice-hint' }, t('analytics.not_registered_hint'))
4383
+ ))
4384
+ }
4385
+ const notices = noticeItems.length
4386
+ ? React.createElement(React.Fragment, null, ...noticeItems)
4387
+ : null
4388
+ const errorNotice = err
4389
+ ? React.createElement('div', { className: data ? 'cb-hint' : 'cb-alert-bad' }, data ? `${t('analytics.stale')} ${err}` : err)
4390
+ : null
4391
+ const totals = analytics?.totals
4392
+ const secondaryTiles = totals
4393
+ ? [
4394
+ [t('analytics.input'), fmtTokens(totals.promptTokens)],
4395
+ [t('analytics.output'), fmtTokens(totals.completionTokens)],
4396
+ [t('analytics.reasoning'), fmtTokens(totals.reasoningTokens)],
4397
+ [t('analytics.cache_read'), fmtTokens(totals.cacheReadTokens)],
4398
+ [t('analytics.cache_write'), fmtTokens(totals.cacheWriteTokens)],
4399
+ [t('analytics.avg_ttft'), fmtMs(totals.avgTtftMs)],
4400
+ [t('analytics.avg_latency'), fmtMs(totals.avgLatencyMs)],
4401
+ [t('analytics.errors'), String(Math.round(Number(totals.failed) || 0))],
4402
+ ]
4403
+ : []
4404
+
4405
+ const tabs = React.createElement(
2977
4406
  'div',
2978
- { className: 'cb-dialog' },
4407
+ { className: 'cb-tabs' },
4408
+ React.createElement(
4409
+ 'button',
4410
+ { type: 'button', className: tab === 'quota' ? 'cb-tab cb-tab-active' : 'cb-tab', onClick: () => setTab('quota') },
4411
+ t('dashboard.tab_quota')
4412
+ ),
4413
+ React.createElement(
4414
+ 'button',
4415
+ { type: 'button', className: tab === 'analytics' ? 'cb-tab cb-tab-active' : 'cb-tab', onClick: () => setTab('analytics') },
4416
+ t('dashboard.tab_analytics')
4417
+ )
4418
+ )
4419
+
4420
+ const quotaBody = React.createElement(QuotaPanelView, {
4421
+ accountTabs,
4422
+ selectedUsage,
4423
+ accountName,
4424
+ accountEmail,
4425
+ accountPlan,
4426
+ accountBadges,
4427
+ activeAcc,
4428
+ routingMode,
4429
+ enabledCount,
4430
+ t,
4431
+ })
4432
+
4433
+ const analyticsBody = React.createElement(
4434
+ 'div',
4435
+ { className: 'cb-dialogCard' },
2979
4436
  React.createElement(
2980
4437
  'div',
2981
- { className: 'cb-dialogMeta' },
2982
- updatedAt ? React.createElement('span', null, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
2983
- React.createElement('span', { style: { flex: 1 } }),
4438
+ { className: 'cb-dialogRow' },
4439
+ React.createElement('div', { className: 'cb-section-title' }, t('analytics.title')),
2984
4440
  React.createElement(
2985
- 'button',
2986
- { type: 'button', className: 'cb-btn', disabled: loading, onClick: loadData },
2987
- loading ? t('dashboard.refreshing') : t('dashboard.refresh')
4441
+ 'div',
4442
+ { className: 'cb-analytics-controls' },
4443
+ React.createElement(
4444
+ 'select',
4445
+ {
4446
+ className: 'cb-heat-select',
4447
+ value: String(analyticsDays),
4448
+ title: t('analytics.range'),
4449
+ onChange: (event) => setAnalyticsDays(Number(event.target.value)),
4450
+ },
4451
+ React.createElement('option', { value: '7' }, t('analytics.range_7')),
4452
+ React.createElement('option', { value: '30' }, t('analytics.range_30')),
4453
+ React.createElement('option', { value: '90' }, t('analytics.range_90'))
4454
+ ),
4455
+ React.createElement(
4456
+ 'div',
4457
+ { className: 'cb-seg' },
4458
+ ['model', 'day', 'session'].map((group) => React.createElement(
4459
+ 'button',
4460
+ {
4461
+ key: group,
4462
+ type: 'button',
4463
+ className: groupBy === group ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
4464
+ onClick: () => setGroupBy(group),
4465
+ },
4466
+ t(`analytics.group_${group}`)
4467
+ ))
4468
+ )
2988
4469
  )
2989
4470
  ),
4471
+ analytics?.totals
4472
+ ? React.createElement(
4473
+ 'div',
4474
+ { className: 'cb-dialogTiles' },
4475
+ React.createElement(
4476
+ 'div',
4477
+ { className: 'cb-stat-box' },
4478
+ React.createElement('div', { className: 'cb-stat-val' }, String(analytics.totals.requests || 0)),
4479
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.requests'))
4480
+ ),
4481
+ React.createElement(
4482
+ 'div',
4483
+ { className: 'cb-stat-box' },
4484
+ React.createElement('div', { className: 'cb-stat-val' }, fmtTokens(analytics.totals.totalTokens)),
4485
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.tokens'))
4486
+ ),
4487
+ React.createElement(
4488
+ 'div',
4489
+ { className: 'cb-stat-box' },
4490
+ React.createElement('div', { className: 'cb-stat-val' }, `≈ ${fmtCost(analytics.totals.costUsd)}`),
4491
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.cost'))
4492
+ ),
4493
+ React.createElement(
4494
+ 'div',
4495
+ { className: 'cb-stat-box' },
4496
+ React.createElement('div', { className: 'cb-stat-val' }, `${Math.round(analytics.totals.successRate || 0)}%`),
4497
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.success'))
4498
+ )
4499
+ )
4500
+ : null,
4501
+ secondaryTiles.length
4502
+ ? React.createElement(
4503
+ 'div',
4504
+ { className: 'cb-dialogTiles' },
4505
+ secondaryTiles.map(([label, value]) => React.createElement(
4506
+ 'div',
4507
+ { className: 'cb-stat-box', key: label },
4508
+ React.createElement('div', { className: 'cb-stat-val' }, value),
4509
+ React.createElement('div', { className: 'cb-stat-lbl' }, label)
4510
+ ))
4511
+ )
4512
+ : null,
4513
+ analyticsLoading
4514
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.loading'))
4515
+ : analytics?.groups?.length
4516
+ ? React.createElement(
4517
+ 'div',
4518
+ { className: 'cb-analytics-list' },
4519
+ React.createElement(
4520
+ 'div',
4521
+ { className: 'cb-analytics-head' },
4522
+ React.createElement('span', null, analytics.groupBy || groupBy),
4523
+ React.createElement('span', null, t('analytics.requests')),
4524
+ React.createElement('span', null, t('analytics.tokens')),
4525
+ React.createElement('span', null, t('analytics.cost')),
4526
+ React.createElement('span', null, t('analytics.ttft'))
4527
+ ),
4528
+ analytics.groups.map((group) => React.createElement(
4529
+ 'div',
4530
+ { className: 'cb-analytics-row', key: group.key },
4531
+ React.createElement('span', { className: 'cb-mono' }, group.label || group.key),
4532
+ React.createElement('span', null, String(group.requests || 0)),
4533
+ React.createElement('span', null, fmtTokens(group.totalTokens)),
4534
+ React.createElement('span', null, `≈ ${fmtCost(group.costUsd)}`),
4535
+ React.createElement('span', null, group.avgTtftMs ? `${Math.round(group.avgTtftMs)} ms` : '—')
4536
+ ))
4537
+ )
4538
+ : React.createElement('div', { className: 'cb-empty' }, t('analytics.no_data')),
4539
+ analytics?.unpriced > 0
4540
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.unpriced', { count: analytics.unpriced }))
4541
+ : null
4542
+ )
2990
4543
 
2991
- err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
4544
+ const heatmapCard = React.createElement(
4545
+ 'div',
4546
+ { className: 'cb-dialogCard' },
4547
+ React.createElement('div', { className: 'cb-section-title' }, t('analytics.heatmap')),
4548
+ React.createElement('div', { className: 'cb-section-desc' }, t('analytics.heatmap_hint')),
4549
+ React.createElement(HeatmapView, {
4550
+ heatmap: analytics?.heatmap,
4551
+ model: heatmapModel,
4552
+ onModelChange: setHeatmapModel,
4553
+ t,
4554
+ })
4555
+ )
2992
4556
 
2993
- // Account and plan
4557
+ const panelBody = React.createElement(
4558
+ 'div',
4559
+ { className: 'cb-panel' },
2994
4560
  React.createElement(
2995
4561
  'div',
2996
- { className: 'cb-dialogCard' },
4562
+ { className: 'cb-panel-head' },
2997
4563
  React.createElement(
2998
4564
  'div',
2999
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
3000
- React.createElement(
3001
- 'div',
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
3005
- ),
3006
- React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
4565
+ { className: 'cb-panel-identity' },
4566
+ React.createElement('div', { className: 'cb-panel-title' }, t('dashboard.title')),
4567
+ React.createElement('div', { className: 'cb-panel-sub' }, t('dashboard.subtitle'))
3007
4568
  ),
3008
4569
  React.createElement(
3009
4570
  'div',
3010
- { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px' } },
4571
+ { className: 'cb-panel-actions' },
4572
+ updatedAt ? React.createElement('span', { className: 'cb-panel-updated' }, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
3011
4573
  React.createElement(
3012
- 'div',
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)
4574
+ 'button',
4575
+ {
4576
+ type: 'button',
4577
+ className: 'cb-btn',
4578
+ disabled: loading || analyticsLoading,
4579
+ onClick: () => { loadData(); loadAnalytics(groupBy) },
4580
+ },
4581
+ (loading || analyticsLoading) ? t('dashboard.refreshing') : t('dashboard.refresh')
3016
4582
  ),
3017
4583
  React.createElement(
3018
- 'div',
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)
4584
+ 'button',
4585
+ {
4586
+ type: 'button',
4587
+ className: 'cb-panel-close',
4588
+ 'aria-label': t('dashboard.close'),
4589
+ title: t('dashboard.close'),
4590
+ onClick: onClose,
4591
+ },
4592
+ '×'
3022
4593
  )
3023
4594
  )
3024
4595
  ),
4596
+ notices,
4597
+ errorNotice,
4598
+ quotaBody,
4599
+ analyticsBody,
4600
+ heatmapCard
4601
+ )
3025
4602
 
3026
- // Quota windows
4603
+ const body = React.createElement(
4604
+ 'div',
4605
+ { className: 'cb-dialog' },
3027
4606
  React.createElement(
3028
4607
  'div',
3029
- { className: 'cb-dialogCard' },
3030
- React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
4608
+ { className: 'cb-dialogMeta' },
4609
+ updatedAt ? React.createElement('span', null, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
4610
+ React.createElement('span', { className: 'cb-settings-spacer' }),
3031
4611
  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
4612
+ 'button',
4613
+ { type: 'button', className: 'cb-btn', disabled: loading || analyticsLoading, onClick: () => (tab === 'quota' ? loadData() : loadAnalytics(groupBy)) },
4614
+ (loading || analyticsLoading) ? t('dashboard.refreshing') : t('dashboard.refresh')
3059
4615
  )
3060
- )
4616
+ ),
4617
+ tabs,
4618
+ notices,
4619
+ errorNotice,
4620
+ tab === 'quota'
4621
+ ? quotaBody
4622
+ : React.createElement(React.Fragment, null, analyticsBody, heatmapCard)
3061
4623
  )
3062
4624
 
4625
+ if (panel) return panelBody
4626
+
3063
4627
  if (Modal) {
3064
4628
  return React.createElement(Modal, {
3065
4629
  open: true,
@@ -3071,18 +4635,17 @@ function DashboardDialog(props) {
3071
4635
  })
3072
4636
  }
3073
4637
 
3074
- // Older shells without the primitive still get a window, just a plainer one.
3075
4638
  return React.createElement(
3076
4639
  'div',
3077
4640
  { className: 'cb-dialogFallback', role: 'dialog', 'aria-label': t('dashboard.title'), onClick: onClose },
3078
4641
  React.createElement(
3079
4642
  'div',
3080
- { className: 'cb-dialogFallbackPanel', onClick: (e) => e.stopPropagation() },
4643
+ { className: 'cb-dialogFallbackPanel', onClick: (event) => event.stopPropagation() },
3081
4644
  React.createElement(
3082
4645
  'div',
3083
4646
  { className: 'cb-dialogMeta' },
3084
4647
  React.createElement('span', { className: 'cb-row-title' }, t('dashboard.title')),
3085
- React.createElement('span', { style: { flex: 1 } }),
4648
+ React.createElement('span', { className: 'cb-settings-spacer' }),
3086
4649
  React.createElement(
3087
4650
  'button',
3088
4651
  { type: 'button', className: 'cb-btn', 'aria-label': t('dashboard.close'), onClick: onClose },
@@ -3102,155 +4665,275 @@ function formatTokens(count) {
3102
4665
  return String(n)
3103
4666
  }
3104
4667
 
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
4668
+ function findPrice(modelId, prices) {
4669
+ if (!modelId || !Array.isArray(prices)) return null
4670
+ const exact = prices.find((entry) => entry && entry.id === modelId)
4671
+ if (exact) return exact
4672
+ return prices.find((entry) => entry && entry.id && (modelId === entry.id || modelId.endsWith(`/${entry.id}`) || modelId.includes(entry.id))) || null
4673
+ }
3111
4674
 
4675
+ function calculateCost(tokens, modelId, prices) {
3112
4676
  const pTok = Number(tokens?.promptTokens || tokens?.inputTokens || 0)
3113
4677
  const cTok = Number(tokens?.completionTokens || tokens?.outputTokens || 0)
3114
4678
  const rTok = Number(tokens?.reasoningTokens || 0)
3115
-
3116
- const cost = (pTok * promptRate + cTok * completionRate + rTok * reasoningRate) / 1000000
3117
- return { cost, pTok, cTok, rTok, total: pTok + cTok + rTok }
4679
+ const cacheRead = Number(tokens?.cacheReadTokens || tokens?.cacheRead || 0)
4680
+ const cacheWrite = Number(tokens?.cacheWriteTokens || tokens?.cacheWrite || 0)
4681
+ const total = pTok + cTok + rTok
4682
+ const price = findPrice(modelId, prices)
4683
+ if (!price) return { cost: 0, priced: false, source: null, pTok, cTok, rTok, cacheRead, cacheWrite, total }
4684
+ const uncachedInput = Math.max(0, pTok - cacheRead - cacheWrite)
4685
+ const cost = (
4686
+ uncachedInput * Number(price.input || 0) +
4687
+ cTok * Number(price.output || 0) +
4688
+ cacheRead * Number(price.cacheRead || 0) +
4689
+ cacheWrite * Number(price.cacheWrite || 0)
4690
+ ) / 1000000
4691
+ return { cost, priced: true, source: price.source || 'estimate', pTok, cTok, rTok, cacheRead, cacheWrite, total }
3118
4692
  }
3119
4693
 
4694
+ const COST_ANCHOR = '[data-slot="conversation.composer.dock"]'
4695
+ const STATS_ROOT = '[data-composer-stats]'
4696
+ const STATS_USAGE = '[data-composer-stat="usage"]'
4697
+ const STATS_ANY = '[data-composer-stat]'
4698
+ const DIALOG_TRIGGER = 'button[aria-haspopup="dialog"]'
4699
+ const MISSES_BEFORE_WARNING = 3
4700
+
4701
+ /**
4702
+ * Inject the session cost into the native token-usage pill instead of drawing a
4703
+ * second pill. The composer's `conversation.composer.dock` entry is still the
4704
+ * only way to read the session projection; the rendered node is a hidden anchor
4705
+ * the injection locates its own composer from.
4706
+ */
3120
4707
  function ComposerCostDock(props) {
4708
+ if (typeof props?.useProjection !== 'function') return null
4709
+ return React.createElement(ComposerCostEntry, props)
4710
+ }
4711
+
4712
+ function ComposerCostEntry(props) {
3121
4713
  const t = props?.t || makeT(en, en)
4714
+ const configSnapshot = useConfigFormsSnapshot(props?.ctx)
3122
4715
  const [stats, setStats] = React.useState(null)
3123
- const [showPopover, setShowPopover] = React.useState(false)
4716
+ const [prices, setPrices] = React.useState([])
4717
+ const [sessionStats, setSessionStats] = React.useState(null)
4718
+ const markerRef = React.useRef(null)
4719
+
4720
+ const projectedTokens = props.useProjection('tokenUsage')
4721
+ const selection = props.useProjection('modelSelection')
4722
+ const sessionId = String(props?.sessionId || '')
4723
+ const priceOverridesKey = JSON.stringify(configSnapshot?.value?.priceOverrides || {})
4724
+
4725
+ React.useEffect(() => {
4726
+ let active = true
4727
+ fetchWithTimeout(`${ROUTE_PREFIX}/prices`, { cache: 'no-store' })
4728
+ .then((response) => response.json())
4729
+ .then((data) => {
4730
+ if (active && data?.ok && Array.isArray(data.models)) setPrices(data.models)
4731
+ })
4732
+ .catch(() => {})
4733
+ return () => { active = false }
4734
+ }, [priceOverridesKey])
3124
4735
 
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
4736
+ React.useEffect(() => {
4737
+ if (!sessionId) {
4738
+ setSessionStats(null)
4739
+ return undefined
4740
+ }
4741
+ let active = true
4742
+ const loadSession = async () => {
4743
+ try {
4744
+ const res = await fetchWithTimeout(
4745
+ `${ROUTE_PREFIX}/stats/session?sessionId=${encodeURIComponent(sessionId)}`,
4746
+ { cache: 'no-store' },
4747
+ )
4748
+ const data = await res.json().catch(() => null)
4749
+ if (active && data?.ok) setSessionStats(data.session || null)
4750
+ } catch {
4751
+ /* quiet */
4752
+ }
4753
+ }
4754
+ loadSession()
4755
+ let timer = null
4756
+ const schedule = () => {
4757
+ timer = setTimeout(async () => {
4758
+ await loadSession()
4759
+ if (active) schedule()
4760
+ }, 10000)
4761
+ }
4762
+ schedule()
4763
+ return () => {
4764
+ active = false
4765
+ if (timer) clearTimeout(timer)
4766
+ }
4767
+ }, [sessionId, projectedTokens])
3128
4768
 
3129
4769
  React.useEffect(() => {
3130
- if (projectedTokens) return
4770
+ if (projectedTokens || sessionId) return undefined
3131
4771
  let active = true
3132
4772
  const loadStats = async () => {
3133
4773
  try {
3134
4774
  const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' })
3135
4775
  if (!res.ok) return
3136
4776
  const data = await res.json().catch(() => null)
3137
- if (active && data?.stats?.summary) {
3138
- setStats(data.stats.summary)
3139
- }
4777
+ const totals = data?.stats?.totals || data?.stats?.summary || null
4778
+ if (active && totals) setStats(totals)
3140
4779
  } catch {
3141
- // quiet
4780
+ /* quiet */
3142
4781
  }
3143
4782
  }
3144
4783
  loadStats()
3145
4784
  const timer = setTimeout(loadStats, 30000)
3146
4785
  return () => { active = false; clearTimeout(timer) }
3147
- }, [projectedTokens])
3148
-
3149
- const tokens = projectedTokens || {
4786
+ }, [projectedTokens, sessionId])
4787
+
4788
+ const selected = selection?.lastUsed
4789
+ ?? selection?.next
4790
+ ?? (selection?.provider || selection?.model ? selection : null)
4791
+ const provider = String(selected?.provider || '')
4792
+ const modelId = String(selected?.model || selected?.id || '')
4793
+ const isClineBot = (provider === '' || provider === 'clinebot' || provider === 'llm-clinebot')
4794
+ && (provider === 'clinebot' || provider === 'llm-clinebot' || modelId.startsWith('cline-pass/'))
4795
+
4796
+ // ClineBot requests are recorded in the plugin's own per-session bucket. That
4797
+ // bucket is provider-scoped, unlike the host's session-wide tokenUsage, so a
4798
+ // session that also used OpenCode cannot inflate the ClineBot read-out.
4799
+ const session = sessionStats && Number(sessionStats.requests || 0) > 0 ? sessionStats : null
4800
+ const sessionTokens = session
4801
+ ? {
4802
+ promptTokens: Number(session.promptTokens || 0),
4803
+ completionTokens: Number(session.completionTokens || 0),
4804
+ reasoningTokens: Number(session.reasoningTokens || 0),
4805
+ cacheReadTokens: Number(session.cacheReadTokens || 0),
4806
+ cacheWriteTokens: Number(session.cacheWriteTokens || 0),
4807
+ }
4808
+ : null
4809
+ const rawTokens = sessionTokens || (sessionId ? null : (projectedTokens || {
3150
4810
  promptTokens: stats?.promptTokens || 0,
3151
4811
  completionTokens: stats?.completionTokens || 0,
3152
4812
  reasoningTokens: stats?.reasoningTokens || 0,
4813
+ cacheReadTokens: stats?.cacheReadTokens || 0,
4814
+ cacheWriteTokens: stats?.cacheWriteTokens || 0,
4815
+ }))
4816
+ const cacheRead = Number(rawTokens?.cacheReadTokens || 0)
4817
+ const cacheWrite = Number(rawTokens?.cacheWriteTokens || 0)
4818
+ const uncachedInput = Number(rawTokens?.uncachedInputTokens)
4819
+ const promptTokens = rawTokens
4820
+ ? (Number.isFinite(uncachedInput)
4821
+ ? uncachedInput + cacheRead + cacheWrite
4822
+ : Number(rawTokens.promptTokens || rawTokens.inputTokens || 0))
4823
+ : 0
4824
+ const tokens = {
4825
+ promptTokens,
4826
+ completionTokens: Number(rawTokens?.completionTokens || rawTokens?.outputTokens || 0),
4827
+ reasoningTokens: Number(rawTokens?.reasoningTokens || 0),
4828
+ cacheReadTokens: cacheRead,
4829
+ cacheWriteTokens: cacheWrite,
3153
4830
  }
4831
+ const calculated = calculateCost(tokens, session?.model || modelId, prices)
4832
+ const cost = session ? Number(session.costUsd || 0) : calculated.cost
4833
+ const priced = session ? Number(session.pricedRequests || 0) > 0 : calculated.priced
4834
+ const source = session
4835
+ ? (findPrice(session.model || modelId, prices)?.source || 'estimate')
4836
+ : calculated.source
4837
+ const { pTok, cTok, rTok } = calculated
4838
+ const total = session ? Number(session.totalTokens || pTok + cTok) : calculated.total
4839
+ const last = session?.last || null
4840
+ const lastCost = last ? Number(last.costUsd || 0) : 0
4841
+ const lastPriced = Boolean(last?.priced)
4842
+
4843
+ const costValue = priced
4844
+ ? (cost === 0 ? t('composer.free') : (cost < 0.0001 ? '< $0.0001' : `$${cost.toFixed(4)}`))
4845
+ : t('composer.unpriced')
4846
+ const costDisplay = priced && source === 'estimate' && cost > 0 ? `≈ ${costValue}` : costValue
4847
+ const lastCostValue = last
4848
+ ? (lastPriced
4849
+ ? (lastCost === 0 ? t('composer.free') : (lastCost < 0.0001 ? '< $0.0001' : `$${lastCost.toFixed(4)}`))
4850
+ : t('composer.unpriced'))
4851
+ : ''
4852
+ const title = [
4853
+ `${t('composer.cost_title')} ${costDisplay}`,
4854
+ ...(last ? [`${t('composer.last_request')} ${lastCostValue}`] : []),
4855
+ `${t('composer.prompt_tok')} ${formatTokens(pTok)}`,
4856
+ `${t('composer.completion_tok')} ${formatTokens(cTok)}`,
4857
+ ...(rTok > 0 ? [`${t('composer.reasoning_tok')} ${formatTokens(rTok)}`] : []),
4858
+ ...(cacheRead > 0 ? [`${t('composer.cache_read')} ${formatTokens(cacheRead)}`] : []),
4859
+ ...(cacheWrite > 0 ? [`${t('composer.cache_write')} ${formatTokens(cacheWrite)}`] : []),
4860
+ ...(priced ? [`${t('composer.price_source')} ${source === 'override' ? t('composer.price_override') : t('composer.price_estimate')}`] : []),
4861
+ ].join(' · ')
4862
+ const inject = isClineBot && priced && total > 0
3154
4863
 
3155
- const modelId = projectedModel?.id || ''
3156
- const { cost, pTok, cTok, rTok, total } = calculateCost(tokens, modelId)
3157
-
3158
- if (total === 0 && !stats) {
3159
- return null
3160
- }
3161
-
3162
- const costDisplay = cost < 0.001 && cost > 0 ? '< $0.001' : `$${cost.toFixed(3)}`
4864
+ React.useEffect(() => {
4865
+ if (!inject || typeof document === 'undefined') return undefined
4866
+ const marker = markerRef.current
4867
+ if (!marker) return undefined
4868
+ const scope = marker.closest(COST_ANCHOR)
4869
+ if (!scope) return undefined
4870
+
4871
+ let host = null
4872
+ let root = null
4873
+ let valueNode = null
4874
+ let misses = 0
4875
+
4876
+ const apply = () => {
4877
+ if (!root || root.parentNode === null) {
4878
+ const usageAnchor = scope.querySelector(STATS_USAGE) || scope.querySelector(STATS_ANY)
4879
+ const statsRoot = usageAnchor || scope.querySelector(STATS_ROOT) || scope
4880
+ const triggers = statsRoot.querySelectorAll(DIALOG_TRIGGER)
4881
+ const button = triggers[triggers.length - 1] || usageAnchor
4882
+ if (!button) {
4883
+ misses += 1
4884
+ if (misses === MISSES_BEFORE_WARNING) {
4885
+ console.warn('[dsh-clinebot] no native token-usage pill found for the session cost')
4886
+ }
4887
+ return
4888
+ }
4889
+ misses = 0
4890
+ host = button
4891
+ root = document.createElement('span')
4892
+ root.setAttribute('data-clinebot-session-cost', '')
4893
+ root.style.display = 'inline-flex'
4894
+ root.style.alignItems = 'center'
4895
+ const separator = document.createElement('span')
4896
+ separator.textContent = '·'
4897
+ separator.setAttribute('aria-hidden', 'true')
4898
+ separator.style.color = 'var(--dsw-alias-separator-primary)'
4899
+ separator.style.margin = '0 6px 0 0'
4900
+ valueNode = document.createElement('span')
4901
+ valueNode.style.fontWeight = '500'
4902
+ root.appendChild(separator)
4903
+ root.appendChild(valueNode)
4904
+ button.appendChild(root)
4905
+ }
4906
+ if (root.parentNode !== host) host.appendChild(root)
4907
+ if (valueNode.textContent !== costDisplay) valueNode.textContent = costDisplay
4908
+ if (root.title !== title) root.title = title
4909
+ }
3163
4910
 
3164
- return React.createElement(
3165
- 'div',
3166
- {
3167
- className: 'cb-composer-cost-dock',
3168
- 'data-composer-session-cost': 'true',
3169
- style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3170
- },
3171
- React.createElement(
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`)
3195
- ),
3196
- showPopover
3197
- ? React.createElement(
3198
- 'div',
3199
- {
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: 'var(--dsw-elevation-prominent)',
3212
- zIndex: 100,
3213
- fontSize: '12px',
3214
- color: 'var(--dsw-alias-label-primary)',
3215
- display: 'flex',
3216
- flexDirection: 'column',
3217
- gap: '6px',
3218
- },
3219
- },
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
- )
3251
- )
4911
+ apply()
4912
+ const observer = typeof window !== 'undefined' && typeof window.MutationObserver === 'function'
4913
+ ? new window.MutationObserver(apply)
3252
4914
  : null
3253
- )
4915
+ observer?.observe(document.body, { childList: true })
4916
+ let timer = null
4917
+ const tick = () => {
4918
+ apply()
4919
+ timer = setTimeout(tick, 2000)
4920
+ }
4921
+ timer = setTimeout(tick, 2000)
4922
+ return () => {
4923
+ observer?.disconnect()
4924
+ if (timer) clearTimeout(timer)
4925
+ if (root?.parentNode) root.parentNode.removeChild(root)
4926
+ }
4927
+ }, [inject, costDisplay, title])
4928
+
4929
+ if (configSnapshot?.value?.showComposerCost === false) return null
4930
+ if (total === 0 && !stats) return null
4931
+ if (!inject) return null
4932
+ return React.createElement('span', {
4933
+ ref: markerRef,
4934
+ style: { display: 'none' },
4935
+ 'data-clinebot-cost-anchor': '',
4936
+ })
3254
4937
  }
3255
4938
 
3256
4939
  // --- src/client/settings-page.js ---
@@ -3275,6 +4958,7 @@ function SettingsPage(props) {
3275
4958
 
3276
4959
  React.useEffect(() => { ensureCss() }, [])
3277
4960
 
4961
+ const pendingRef = React.useRef({})
3278
4962
  const load = React.useCallback(async () => {
3279
4963
  setErr('')
3280
4964
  const [res, statsRes] = await Promise.all([
@@ -3286,11 +4970,15 @@ function SettingsPage(props) {
3286
4970
  const rawStats = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
3287
4971
  const statsObj = (rawStats && typeof rawStats === 'object' && rawStats.stats) ? rawStats.stats : (rawStats || data.statsSummary || {})
3288
4972
  setStatus({ ...data, statsSummary: statsObj })
3289
- setDraft(data.config || {})
4973
+ // Preserve unsaved staged fields across an account/model refresh.
4974
+ setDraft({ ...(data.config || {}), ...(pendingRef.current || {}) })
3290
4975
  }, [])
3291
4976
 
3292
4977
  React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
3293
4978
 
4979
+ const staged = useStagedConfig({ status, draft, setDraft, load, t, pendingRef })
4980
+ const modelSelection = useModelSelection({ status, draft, setDraft, load, setErr })
4981
+
3294
4982
  const checkUpdate = React.useCallback(async () => {
3295
4983
  setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
3296
4984
  try {
@@ -3378,6 +5066,14 @@ function SettingsPage(props) {
3378
5066
  }
3379
5067
  }
3380
5068
 
5069
+ const {
5070
+ handlePinAccount,
5071
+ handleAddAccount,
5072
+ handleDeleteAccount,
5073
+ handleRenameAccount,
5074
+ handleClearKey,
5075
+ } = createAccountActions({ performAction, load, t, setMsg })
5076
+
3381
5077
  async function handleSaveKey() {
3382
5078
  const keyVal = String(apiKeyInput || '').trim()
3383
5079
  if (!keyVal) { setErr(t('key.empty_err')); return }
@@ -3498,45 +5194,6 @@ function SettingsPage(props) {
3498
5194
  window.open('https://app.cline.bot/settings/api-keys', '_blank')
3499
5195
  }
3500
5196
 
3501
- async function handlePinAccount(accountEnv) {
3502
- await performAction('pin-account', async () => {
3503
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
3504
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3505
- body: JSON.stringify({ account: accountEnv }),
3506
- })
3507
- const data = await res.json().catch(() => ({}))
3508
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3509
- await load()
3510
- })
3511
- }
3512
-
3513
- async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
3514
- await performAction('add-account', async () => {
3515
- const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
3516
- const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
3517
- const res = await fetchWithTimeout(endpoint, {
3518
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3519
- body: JSON.stringify(payload),
3520
- })
3521
- const data = await res.json().catch(() => ({}))
3522
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3523
- setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
3524
- await load()
3525
- })
3526
- }
3527
-
3528
- async function handleDeleteAccount(accountEnv, deleteSecret = true) {
3529
- await performAction('delete-account', async () => {
3530
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
3531
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3532
- body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
3533
- })
3534
- const data = await res.json().catch(() => ({}))
3535
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3536
- await load()
3537
- })
3538
- }
3539
-
3540
5197
  async function handleConfigPatch(patch) {
3541
5198
  setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
3542
5199
  await performAction('patch-config', async () => {
@@ -3559,60 +5216,6 @@ function SettingsPage(props) {
3559
5216
  })
3560
5217
  }
3561
5218
 
3562
- const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
3563
-
3564
- React.useEffect(() => () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) }, [])
3565
-
3566
- function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
3567
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
3568
- debounceTimerRef.current = setTimeout(async () => {
3569
- try {
3570
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
3571
- method: 'POST',
3572
- headers: { 'Content-Type': 'application/json' },
3573
- body: JSON.stringify({ disabledModels: nextDisabled }),
3574
- })
3575
- const data = await res.json().catch(() => ({}))
3576
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3577
- await load()
3578
- } catch (err) {
3579
- console.warn('[dsh-clinebot] Failed saving disabled models:', err)
3580
- setErr(String(err?.message || err))
3581
- setDraft((prev) => {
3582
- if (!prev) return prev
3583
- const all = status?.availableModels || []
3584
- const prevEnabled = all.map((m) => m.id).filter((mId) => !previousDisabled.includes(mId))
3585
- return { ...prev, disabledModels: previousDisabled, enabledModels: prevEnabled }
3586
- })
3587
- }
3588
- }, 400)
3589
- }
3590
-
3591
- function handleToggleModel(id) {
3592
- const curr = draft?.disabledModels || []
3593
- const isCurrentlyDisabled = curr.includes(id)
3594
- const next = isCurrentlyDisabled ? curr.filter((x) => x !== id) : [...curr, id]
3595
- const all = status?.availableModels || []
3596
- const enabledIds = all.map((m) => m.id).filter((mId) => !next.includes(mId))
3597
- setDraft({ ...draft, disabledModels: next, enabledModels: enabledIds })
3598
- debounceSaveDisabledModels(next, curr)
3599
- }
3600
-
3601
- function handleSetModelsFilter(type) {
3602
- const all = status?.availableModels || []
3603
- let allowed = new Set()
3604
- if (type === 'all') allowed = new Set(all.map((m) => m.id))
3605
- else if (type === 'vision') allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
3606
- else if (type === 'coding') allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
3607
- else if (type === 'recommended') allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
3608
-
3609
- const curr = draft?.disabledModels || []
3610
- const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
3611
- const nextEnabled = Array.from(allowed)
3612
- setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
3613
- debounceSaveDisabledModels(nextDisabled, curr)
3614
- }
3615
-
3616
5219
  if (!status || !draft) {
3617
5220
  if (err) {
3618
5221
  return React.createElement(
@@ -3693,7 +5296,13 @@ function SettingsPage(props) {
3693
5296
 
3694
5297
  // Accounts Pool Card
3695
5298
  React.createElement(AccountsSection, {
3696
- status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount,
5299
+ status,
5300
+ busy,
5301
+ handlePinAccount,
5302
+ handleAddAccount,
5303
+ handleDeleteAccount,
5304
+ handleRenameAccount,
5305
+ handleClearKey,
3697
5306
  accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
3698
5307
  handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
3699
5308
  modelRoutes: draft?.modelRoutes || status?.config?.modelRoutes || {},
@@ -3702,39 +5311,72 @@ function SettingsPage(props) {
3702
5311
  }),
3703
5312
 
3704
5313
  // Quota Warning & Dashboard Card
3705
- React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, showSidebarQuota: draft?.showSidebarQuota !== false, onToggleSidebarQuota: (v) => handleConfigPatch({ showSidebarQuota: v }), t }),
3706
-
3707
- // Card 3: Model Picker Management
3708
- React.createElement(ModelsSection, {
3709
- modelsList,
3710
- disabledSet,
3711
- enabledCount,
5314
+ React.createElement(QuotaSection, {
3712
5315
  keyPresent,
5316
+ status,
5317
+ usage,
3713
5318
  busy,
3714
- handleSyncPlanModels,
3715
- handleUpdateModelContext,
3716
- handleSetModelsFilter,
3717
- handleToggleModel,
3718
- planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
3719
- planSyncedAt: status?.config?.planSyncedAt || 0,
3720
- defaultModelWarning: status?.config?.defaultModelWarning || '',
3721
- customModels: draft?.customModels || [],
3722
- modelReasoningDefaults: draft?.modelReasoningDefaults || {},
3723
- visibleModels: draft?.visibleModels || [],
3724
- filterModelsByPlan: draft?.filterModelsByPlan !== false,
3725
- onConfigUpdate: handleConfigPatch,
3726
- onUpdateReasoningEffort: (modelId, effort) => {
3727
- const next = { ...(draft?.modelReasoningDefaults || {}) }
3728
- if (!effort) delete next[modelId]; else next[modelId] = effort
3729
- handleConfigPatch({ modelReasoningDefaults: next })
3730
- },
5319
+ handleRefreshQuota,
5320
+ showSidebarQuota: draft?.showSidebarQuota !== false,
5321
+ onToggleSidebarQuota: (value) => staged.stage({ showSidebarQuota: value }),
3731
5322
  t,
3732
5323
  }),
3733
5324
 
3734
- // Card 4: Session Metrics & Usage Tracking
5325
+ // Models & context: a foldable group keeps the long model table out of the
5326
+ // initial page while preserving the picker controls.
5327
+ React.createElement(
5328
+ CollapsibleGroup,
5329
+ {
5330
+ title: t('settings.models_group'),
5331
+ description: t('settings.models_group_hint'),
5332
+ badge: `${enabledCount}/${modelsList.length}`,
5333
+ divided: true,
5334
+ defaultOpen: false,
5335
+ },
5336
+ React.createElement(ModelsSection, {
5337
+ modelsList,
5338
+ disabledSet,
5339
+ enabledCount,
5340
+ keyPresent,
5341
+ busy,
5342
+ handleSyncPlanModels,
5343
+ handleUpdateModelContext,
5344
+ handleSetModelsFilter: modelSelection.handleSetModelsFilter,
5345
+ handleToggleModel: modelSelection.handleToggleModel,
5346
+ planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
5347
+ planSyncedAt: status?.config?.planSyncedAt || 0,
5348
+ defaultModelWarning: status?.config?.defaultModelWarning || '',
5349
+ customModels: draft?.customModels || [],
5350
+ modelReasoningDefaults: draft?.modelReasoningDefaults || {},
5351
+ visibleModels: draft?.visibleModels || [],
5352
+ filterModelsByPlan: draft?.filterModelsByPlan !== false,
5353
+ onConfigUpdate: staged.stage,
5354
+ onUpdateReasoningEffort: (modelId, effort) => {
5355
+ const next = { ...(draft?.modelReasoningDefaults || {}) }
5356
+ if (!effort) delete next[modelId]; else next[modelId] = effort
5357
+ staged.stage({ modelReasoningDefaults: next })
5358
+ },
5359
+ t,
5360
+ }),
5361
+ React.createElement(PriceSection, {
5362
+ modelsList,
5363
+ priceOverrides: draft?.priceOverrides || {},
5364
+ onConfigUpdate: staged.stage,
5365
+ t,
5366
+ })
5367
+ ),
5368
+
5369
+ // Session Metrics & Usage Tracking
3735
5370
  React.createElement(StatsSection, { status, onResetStats: handleResetStats, busy, t }),
3736
5371
 
3737
- // Card 5: Diagnostics & Sync with DSH
5372
+ React.createElement(IntegrationsSection, {
5373
+ draft,
5374
+ onStage: staged.stage,
5375
+ transport: draft?.transport || status?.config?.transport || 'adapter',
5376
+ t,
5377
+ }),
5378
+
5379
+ // Diagnostics & Sync with DSH
3738
5380
  React.createElement(DiagSection, {
3739
5381
  keyPresent,
3740
5382
  isRegistered,
@@ -3744,7 +5386,26 @@ function SettingsPage(props) {
3744
5386
  handleUnregister,
3745
5387
  handleRegister,
3746
5388
  proxyMode: draft?.proxyMode !== false,
3747
- onToggleProxy: (val) => handleConfigPatch({ proxyMode: val }),
5389
+ onToggleProxy: (value) => staged.stage({ proxyMode: value }),
5390
+ t,
5391
+ }),
5392
+
5393
+ React.createElement(AdvancedSection, {
5394
+ draft,
5395
+ onStage: staged.stage,
5396
+ transport: draft?.transport || status?.config?.transport || 'adapter',
5397
+ t,
5398
+ }),
5399
+
5400
+ React.createElement(SaveBar, {
5401
+ pendingCount: staged.pendingCount,
5402
+ saving: staged.saving,
5403
+ saveState: staged.saveState,
5404
+ saveError: staged.saveError,
5405
+ invalid: staged.invalid,
5406
+ validationError: staged.validationError,
5407
+ onSave: staged.save,
5408
+ onDiscard: staged.discard,
3748
5409
  t,
3749
5410
  })
3750
5411
  )
@@ -3820,6 +5481,24 @@ function apply(ctx) {
3820
5481
  }
3821
5482
  }
3822
5483
 
5484
+ function selectLayoutPanel(id) {
5485
+ try {
5486
+ const layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout
5487
+ if (!layout || typeof layout.selectPanel !== 'function') return false
5488
+ layout.selectPanel(id)
5489
+ return true
5490
+ } catch (err) {
5491
+ console.warn('[dsh-clinebot] layout.selectPanel failed:', err)
5492
+ return false
5493
+ }
5494
+ }
5495
+
5496
+ const openPanel = () => selectLayoutPanel('clinebot-panel')
5497
+ const closePanel = () => {
5498
+ if (selectLayoutPanel(null)) return
5499
+ selectLayoutPanel('conversation')
5500
+ }
5501
+
3823
5502
  // The plugin's configuration is a section of DSH Settings (`settings.section`),
3824
5503
  // a peer of General/Models/Plugins — not a row in the Plugins list and not a tab
3825
5504
  // inside the Plugins settings section. The shell owns the modal, the section nav
@@ -3856,24 +5535,57 @@ function apply(ctx) {
3856
5535
  id: 'clinebot-quota',
3857
5536
  order: 20,
3858
5537
  locale: NS,
3859
- inject: () => ({ ctx }),
5538
+ inject: () => ({ ctx, open: openPanel }),
3860
5539
  },
3861
5540
  (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(SidebarQuota, { ...props, ctx: (props && props.ctx) || ctx }))
3862
5541
  )
3863
5542
  )
3864
5543
 
3865
- // The quota & status read-out is a window opened from the sidebar meter, not a
3866
- // `main`-slot panel: occupying the centre column meant clicking the meter threw
3867
- // the conversation out of the way to show a status read-out.
3868
-
3869
- // `settings.models.provider-card` is deliberately not registered. The seat is
3870
- // KEYED and dispatched with `entryKey = <provider settingsNs>` on every provider
3871
- // row, unconditionally: the models page renders it before the row's own
3872
- // `open ? editor : null`, and its owner props carry no open state. Anything
3873
- // registered there therefore shows on the collapsed row and reads as an editor
3874
- // that opened by itself. The row's 编辑 affordance already opens a provider
3875
- // editor with the same API-key field, and this plugin's configuration lives in
3876
- // its own Settings section, so the seat is left free.
5544
+ // The quota & usage dashboard opens in the layout's center column, matching
5545
+ // the Command Code / OpenCode provider surfaces. The sidebar card selects it
5546
+ // through `layout.selectPanel`; the panel's Close button selects the
5547
+ // conversation again. When no layout service is available the card falls back
5548
+ // to its local Modal.
5549
+ registerSlotWhenReady('main', () =>
5550
+ ctx.slots.register(
5551
+ {
5552
+ name: 'main',
5553
+ key: 'clinebot-panel',
5554
+ locale: NS,
5555
+ inject: () => ({ ctx, open: openPanel, close: closePanel }),
5556
+ },
5557
+ (props) => React.createElement(
5558
+ ErrorBoundary,
5559
+ { t: props && props.t },
5560
+ React.createElement(DashboardDialog, {
5561
+ ...props,
5562
+ panel: true,
5563
+ ctx: (props && props.ctx) || ctx,
5564
+ onClose: (props && props.close) || closePanel,
5565
+ })
5566
+ )
5567
+ )
5568
+ )
5569
+
5570
+ // Models-page provider card. The seat is keyed by the provider's settings
5571
+ // namespace, which the native adapter registration declares as `dsh-clinebot`
5572
+ // (`settingsNs`). The component returns null for every other provider row, so
5573
+ // it cannot leak onto a sibling's card.
5574
+ registerSlotWhenReady('settings.models.provider-card', () =>
5575
+ ctx.slots.register(
5576
+ {
5577
+ name: 'settings.models.provider-card',
5578
+ key: NS,
5579
+ locale: NS,
5580
+ inject: () => ({ ctx }),
5581
+ },
5582
+ (props) => React.createElement(
5583
+ ErrorBoundary,
5584
+ { t: props && props.t },
5585
+ React.createElement(ProviderCard, { ...props, ctx: (props && props.ctx) || ctx })
5586
+ )
5587
+ )
5588
+ )
3877
5589
 
3878
5590
  // Composer Session Cost Dock (conversation.composer.dock slot)
3879
5591
  registerSlotWhenReady('conversation.composer.dock', () =>