@goodandready/dsh-clinebot 0.5.27 → 0.5.28

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/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.28] - 2026-10-17
9
+
10
+ ### Added
11
+ - **Sidebar Quota Meter (`sidebar.footer.action` slot) (Issue #229)**: Added live subscription quota indicator in DSH sidebar footer right above Settings. Supports collapsed rail mode with 20px dynamic circular ring (`MiniRing`) and wide mode with plan name (`ClinePass Pro`), rolling 5h quota progress bar, and percentage used.
12
+ - **Center-Column Dashboard Overlay (`main` slot) (Issue #229)**: Registered full overlay dashboard under key `clinebot-dashboard`, providing detailed view of 5-hour and weekly quota rolling windows, reset countdown timers, active account pool routing mode, and a close button (`×`) that restores the current chat view without page reload.
13
+ - **Native Models Provider Card (`settings.models.provider-card` slot) (Issue #230)**: Added native provider card registered under `clinebot` and `llm-clinebot` on DSH Settings -> Models page, featuring connection status badges, quick API key input and save into DSH credentials, active model count, and direct navigation to full ClineBot settings.
14
+ - **Sidebar Quota Toggle (`showSidebarQuota`) (Issue #229)**: Added volatile config field and settings toggle (default `true`) allowing users to easily enable or disable the sidebar quota meter.
15
+
8
16
  ## [0.5.27] - 2026-10-17
9
17
 
10
18
  ### Added
package/lib/client.js CHANGED
@@ -219,6 +219,28 @@ const en = {
219
219
  'update.failed': 'Update failed: {error}',
220
220
  'update.checking': 'Checking for updates…',
221
221
  'update.up_to_date': 'Up to date',
222
+ 'sidebar.quota_title': 'Cline Quota',
223
+ 'sidebar.show_quota': 'Show quota meter in sidebar',
224
+ 'sidebar.show_quota_hint': 'Display compact quota meter in DSH sidebar footer.',
225
+ 'dashboard.title': 'Cline Quota & Status',
226
+ 'dashboard.subtitle': 'Real-time subscription quota, rolling limits, and account pool status.',
227
+ 'dashboard.close': 'Close dashboard',
228
+ 'dashboard.refresh': 'Refresh',
229
+ 'dashboard.refreshing': 'Refreshing…',
230
+ 'dashboard.rolling_window': '5-Hour Window',
231
+ 'dashboard.weekly_window': 'Weekly Window',
232
+ 'dashboard.remaining': 'remaining',
233
+ 'dashboard.used': 'used',
234
+ 'dashboard.account_pool': 'Active Routing Account',
235
+ 'dashboard.credits': 'Credit Balance',
236
+ 'dashboard.updated_at': 'Updated at',
237
+ 'card.title': 'ClineBot Provider',
238
+ 'card.status_connected': 'Connected',
239
+ 'card.status_no_key': 'Key missing',
240
+ 'card.open_settings': 'Settings & Accounts',
241
+ 'card.save_key': 'Save Key',
242
+ 'card.key_saved': 'Key saved',
243
+ 'card.models_active': '{count} active models',
222
244
  }
223
245
 
224
246
  const zh = {
@@ -402,6 +424,28 @@ const en = {
402
424
  'update.failed': '更新失败:{error}',
403
425
  'update.checking': '正在检查更新…',
404
426
  'update.up_to_date': '已是最新版本',
427
+ 'sidebar.quota_title': 'Cline 额度',
428
+ 'sidebar.show_quota': '在侧边栏显示额度条',
429
+ 'sidebar.show_quota_hint': '在 DSH 侧边栏底部常驻显示额度 mini 进度指示器。',
430
+ 'dashboard.title': 'Cline 额度与状态',
431
+ 'dashboard.subtitle': '实时订阅额度、滑动窗口限制与账户池状态。',
432
+ 'dashboard.close': '关闭面板',
433
+ 'dashboard.refresh': '刷新',
434
+ 'dashboard.refreshing': '正在刷新…',
435
+ 'dashboard.rolling_window': '5小时滑动窗口',
436
+ 'dashboard.weekly_window': '每周配额窗口',
437
+ 'dashboard.remaining': '剩余',
438
+ 'dashboard.used': '已用',
439
+ 'dashboard.account_pool': '当前承载账户',
440
+ 'dashboard.credits': '点数余额',
441
+ 'dashboard.updated_at': '更新于',
442
+ 'card.title': 'ClineBot 模型服务',
443
+ 'card.status_connected': '已连接',
444
+ 'card.status_no_key': '未配置密钥',
445
+ 'card.open_settings': '完整设置与多账户',
446
+ 'card.save_key': '保存密钥',
447
+ 'card.key_saved': '密钥已保存',
448
+ 'card.models_active': '{count} 个可用模型',
405
449
  }
406
450
 
407
451
  function makeT(dict, fallback) {
@@ -504,6 +548,34 @@ function ensureCss() {
504
548
  .cb-stat-val{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary)}
505
549
  .cb-stat-lbl{font-size:12px;color:var(--dsw-alias-label-secondary)}
506
550
  .cb-preview{padding:12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-family:monospace;font-size:12px;white-space:pre-wrap;word-break:break-all;border:1px solid var(--dsw-alias-border-l2)}
551
+
552
+ [class*="_footArea"] [class*="_footerActions"]{flex-direction:column}
553
+ .cb-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:transparent;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex}
554
+ .cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
555
+ .cb-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
556
+ .cb-footTop{align-items:center;gap:8px;min-width:0;display:flex}
557
+ .cb-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px}
558
+ .cb-footRow{flex-direction:column;gap:4px;min-width:0;display:flex}
559
+ .cb-footHead{align-items:baseline;gap:8px;min-width:0;display:flex}
560
+ .cb-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
561
+ .cb-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}
562
+ .cb-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden}
563
+ .cb-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}
564
+ .cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
565
+ .cb-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
566
+ .cb-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:transparent;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex}
567
+ .cb-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
568
+ .cb-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
569
+ .cb-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)}
570
+
571
+ .cb-mainOverlay{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block}
572
+ .cb-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)}
573
+ .cb-mainHeader{align-items:center;gap:10px;display:flex;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:14px}
574
+ .cb-mainTitle{margin:0;font-size:18px;font-weight:600;line-height:1.4}
575
+ .cb-mainSub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}
576
+ .cb-mainCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:14px}
577
+
578
+ .cb-providerCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px;margin:8px 0}
507
579
  `
508
580
  document.head.appendChild(style)
509
581
  }
@@ -1289,7 +1361,7 @@ function AccountsSection({
1289
1361
  }
1290
1362
 
1291
1363
  // --- src/client/components/quota-section.js ---
1292
- function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }) {
1364
+ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, showSidebarQuota, onToggleSidebarQuota, t }) {
1293
1365
  const isLowQuota = Boolean(
1294
1366
  (usage?.windows?.weekly && typeof usage.windows.weekly.remainingPercent === 'number' && usage.windows.weekly.remainingPercent <= 10) ||
1295
1367
  (usage?.windows?.monthly && typeof usage.windows.monthly.remainingPercent === 'number' && usage.windows.monthly.remainingPercent <= 10) ||
@@ -1379,6 +1451,22 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
1379
1451
  t,
1380
1452
  })
1381
1453
  : null
1454
+ ),
1455
+ React.createElement(
1456
+ 'div',
1457
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '4px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1458
+ React.createElement(
1459
+ 'div',
1460
+ { style: { display: 'flex', flexDirection: 'column' } },
1461
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, t('sidebar.show_quota')),
1462
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, t('sidebar.show_quota_hint'))
1463
+ ),
1464
+ React.createElement('input', {
1465
+ type: 'checkbox',
1466
+ style: { cursor: 'pointer', width: '16px', height: '16px' },
1467
+ checked: showSidebarQuota !== false,
1468
+ onChange: (e) => onToggleSidebarQuota && onToggleSidebarQuota(e.target.checked),
1469
+ })
1382
1470
  )
1383
1471
  )
1384
1472
  : null
@@ -2573,6 +2661,482 @@ function DiagSection({
2573
2661
  )
2574
2662
  }
2575
2663
 
2664
+ // --- src/client/components/sidebar-quota.js ---
2665
+ function MiniRing({ percent, size = 18, warn = false }) {
2666
+ const r = 7
2667
+ const c = 2 * Math.PI * r
2668
+ const pct = Math.max(0, Math.min(100, Number(percent) || 0))
2669
+ const offset = c - (pct / 100) * c
2670
+ const strokeColor = warn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-brand-primary)'
2671
+
2672
+ return React.createElement(
2673
+ 'svg',
2674
+ {
2675
+ className: 'cb-glyph',
2676
+ width: size,
2677
+ height: size,
2678
+ viewBox: '0 0 20 20',
2679
+ fill: 'none',
2680
+ },
2681
+ React.createElement('circle', {
2682
+ cx: 10,
2683
+ cy: 10,
2684
+ r,
2685
+ stroke: 'var(--dsw-alias-bg-layer-2)',
2686
+ strokeWidth: 2.5,
2687
+ }),
2688
+ React.createElement('circle', {
2689
+ cx: 10,
2690
+ cy: 10,
2691
+ r,
2692
+ stroke: strokeColor,
2693
+ strokeWidth: 2.5,
2694
+ strokeDasharray: c,
2695
+ strokeDashoffset: offset,
2696
+ strokeLinecap: 'round',
2697
+ transform: 'rotate(-90 10 10)',
2698
+ style: { transition: 'stroke-dashoffset 0.3s ease' },
2699
+ })
2700
+ )
2701
+ }
2702
+
2703
+ function SidebarQuota({ ctx, wide, open, t: propT }) {
2704
+ const t = propT || makeT(en, en)
2705
+ const [data, setData] = React.useState(null)
2706
+ const [visible, setVisible] = React.useState(true)
2707
+
2708
+ const loadQuota = React.useCallback(async () => {
2709
+ try {
2710
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' })
2711
+ if (!res.ok) return
2712
+ const json = await res.json().catch(() => null)
2713
+ if (json && json.ok) setData(json)
2714
+ } catch {
2715
+ // quiet on network fail
2716
+ }
2717
+ }, [])
2718
+
2719
+ React.useEffect(() => {
2720
+ let timer = null
2721
+ const scheduleNext = () => {
2722
+ timer = setTimeout(async () => {
2723
+ await loadQuota()
2724
+ scheduleNext()
2725
+ }, 60000)
2726
+ }
2727
+ loadQuota()
2728
+ scheduleNext()
2729
+ return () => { if (timer) clearTimeout(timer) }
2730
+ }, [loadQuota])
2731
+
2732
+ // Check configForms snapshot for showSidebarQuota toggle
2733
+ const config = useConfigFormsSnapshot(ctx)
2734
+ const show = config?.showSidebarQuota !== false
2735
+ if (!show || !visible) return null
2736
+
2737
+ const fiveHour = data?.windows?.fiveHour
2738
+ const weekly = data?.windows?.weekly
2739
+ const activeWindow = fiveHour || weekly
2740
+ const pct = Math.round(activeWindow?.percentUsed || 0)
2741
+ const isWarn = pct >= 85
2742
+ const planName = data?.plan || t('title')
2743
+
2744
+ const handleOpen = () => {
2745
+ if (typeof open === 'function') {
2746
+ open()
2747
+ } else {
2748
+ ctx?.get?.('layout')?.selectPanel?.('clinebot-dashboard')
2749
+ }
2750
+ }
2751
+
2752
+ const tooltip = `${t('sidebar.quota_title')}: ${pct}% ${t('dashboard.used')} (${planName})`
2753
+
2754
+ if (!wide) {
2755
+ return React.createElement(
2756
+ 'button',
2757
+ {
2758
+ type: 'button',
2759
+ className: 'cb-railButton',
2760
+ 'aria-label': tooltip,
2761
+ title: tooltip,
2762
+ onClick: handleOpen,
2763
+ },
2764
+ React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 20 })
2765
+ )
2766
+ }
2767
+
2768
+ return React.createElement(
2769
+ 'button',
2770
+ {
2771
+ type: 'button',
2772
+ className: 'cb-foot',
2773
+ 'aria-label': tooltip,
2774
+ title: tooltip,
2775
+ onClick: handleOpen,
2776
+ },
2777
+ React.createElement(
2778
+ 'div',
2779
+ { className: 'cb-footTop' },
2780
+ React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
2781
+ React.createElement('span', { className: 'cb-footName' }, planName),
2782
+ React.createElement('span', { className: 'cb-footPct', style: { marginLeft: 'auto', fontWeight: 600, color: isWarn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)' } }, `${pct}%`)
2783
+ ),
2784
+ React.createElement(
2785
+ 'div',
2786
+ { className: 'cb-footRow' },
2787
+ React.createElement(
2788
+ 'div',
2789
+ { className: 'cb-footHead' },
2790
+ React.createElement('span', { className: 'cb-footLabel' }, t('dashboard.rolling_window')),
2791
+ React.createElement('span', { className: 'cb-footAmount' }, `${100 - pct}% ${t('dashboard.remaining')}`)
2792
+ ),
2793
+ React.createElement(
2794
+ 'span',
2795
+ { className: 'cb-footBar' },
2796
+ React.createElement('span', {
2797
+ className: isWarn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
2798
+ style: { width: `${Math.min(100, Math.max(2, pct))}%` },
2799
+ })
2800
+ )
2801
+ )
2802
+ )
2803
+ }
2804
+
2805
+ // --- src/client/components/dashboard-overlay.js ---
2806
+ function DashboardOverlay(props) {
2807
+ const ctx = props?.ctx
2808
+ const t = props?.t || makeT(en, en)
2809
+ const [data, setData] = React.useState(null)
2810
+ const [status, setStatus] = React.useState(null)
2811
+ const [loading, setLoading] = React.useState(false)
2812
+ const [updatedAt, setUpdatedAt] = React.useState('')
2813
+ const [err, setErr] = React.useState('')
2814
+
2815
+ const handleClose = () => {
2816
+ if (typeof props?.close === 'function') {
2817
+ props.close()
2818
+ } else {
2819
+ ctx?.get?.('layout')?.selectPanel?.(null)
2820
+ }
2821
+ }
2822
+
2823
+ const loadData = React.useCallback(async () => {
2824
+ setLoading(true)
2825
+ setErr('')
2826
+ try {
2827
+ const [uRes, sRes] = await Promise.all([
2828
+ fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' }),
2829
+ fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
2830
+ ])
2831
+ const uJson = await uRes.json().catch(() => ({}))
2832
+ const sJson = await sRes.json().catch(() => ({}))
2833
+ if (uJson?.ok) setData(uJson)
2834
+ if (sJson?.ok) setStatus(sJson)
2835
+ setUpdatedAt(new Date().toLocaleTimeString())
2836
+ } catch (e) {
2837
+ setErr(String(e?.message || e))
2838
+ } finally {
2839
+ setLoading(false)
2840
+ }
2841
+ }, [])
2842
+
2843
+ React.useEffect(() => {
2844
+ loadData()
2845
+ }, [loadData])
2846
+
2847
+ const plan = data?.plan || status?.config?.defaultModel || 'ClinePass'
2848
+ const email = data?.user?.email || ''
2849
+ const activeAcc = status?.config?.activeAccount || 'Auto (Least-used)'
2850
+ const enabledCount = status?.config?.enabledModels?.length || 0
2851
+
2852
+ return React.createElement(
2853
+ 'div',
2854
+ { className: 'cb-mainOverlay', role: 'region', 'aria-label': t('dashboard.title') },
2855
+ React.createElement(
2856
+ 'div',
2857
+ { className: 'cb-mainInner' },
2858
+ // Header
2859
+ React.createElement(
2860
+ 'header',
2861
+ { className: 'cb-mainHeader' },
2862
+ React.createElement(
2863
+ 'div',
2864
+ { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 } },
2865
+ React.createElement('h2', { className: 'cb-mainTitle' }, t('dashboard.title')),
2866
+ React.createElement('p', { className: 'cb-mainSub' }, t('dashboard.subtitle'))
2867
+ ),
2868
+ React.createElement('span', { style: { flex: 1 } }),
2869
+ updatedAt
2870
+ ? React.createElement(
2871
+ 'span',
2872
+ { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', fontVariantNumeric: 'tabular-nums' } },
2873
+ `${t('dashboard.updated_at')} ${updatedAt}`
2874
+ )
2875
+ : null,
2876
+ React.createElement(
2877
+ 'button',
2878
+ {
2879
+ type: 'button',
2880
+ className: 'cb-btn',
2881
+ disabled: loading,
2882
+ onClick: loadData,
2883
+ },
2884
+ loading ? t('dashboard.refreshing') : t('dashboard.refresh')
2885
+ ),
2886
+ React.createElement(
2887
+ 'button',
2888
+ {
2889
+ type: 'button',
2890
+ className: 'cb-btn',
2891
+ style: { minWidth: '32px', padding: '0 8px', fontSize: '16px' },
2892
+ 'aria-label': t('dashboard.close'),
2893
+ title: t('dashboard.close'),
2894
+ onClick: handleClose,
2895
+ },
2896
+ '✕'
2897
+ )
2898
+ ),
2899
+
2900
+ err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
2901
+
2902
+ // Card 1: Account & Plan Status
2903
+ React.createElement(
2904
+ 'div',
2905
+ { className: 'cb-mainCard' },
2906
+ React.createElement(
2907
+ 'div',
2908
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
2909
+ React.createElement(
2910
+ 'div',
2911
+ { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
2912
+ React.createElement('span', { style: { fontSize: '20px' } }, '💳'),
2913
+ React.createElement(
2914
+ 'div',
2915
+ null,
2916
+ React.createElement('div', { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, plan),
2917
+ email ? React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, email) : null
2918
+ )
2919
+ ),
2920
+ React.createElement(
2921
+ 'span',
2922
+ { className: 'cb-badge cb-badge-ok' },
2923
+ t('badge.registered', { count: enabledCount })
2924
+ )
2925
+ ),
2926
+ React.createElement(
2927
+ 'div',
2928
+ { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px', marginTop: '6px' } },
2929
+ React.createElement(
2930
+ 'div',
2931
+ { className: 'cb-stat-box' },
2932
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
2933
+ React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px', wordBreak: 'break-all' } }, activeAcc)
2934
+ ),
2935
+ React.createElement(
2936
+ 'div',
2937
+ { className: 'cb-stat-box' },
2938
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
2939
+ React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px' } }, status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used'))
2940
+ )
2941
+ )
2942
+ ),
2943
+
2944
+ // Card 2: Quota Windows
2945
+ React.createElement(
2946
+ 'div',
2947
+ { className: 'cb-mainCard' },
2948
+ React.createElement(
2949
+ 'div',
2950
+ { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
2951
+ t('quota.title')
2952
+ ),
2953
+ React.createElement(
2954
+ 'div',
2955
+ { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
2956
+ data?.windows?.fiveHour
2957
+ ? React.createElement(ProgressBar, {
2958
+ label: t('quota.window_5h'),
2959
+ percentUsed: data.windows.fiveHour.percentUsed || 0,
2960
+ remainingPercent: data.windows.fiveHour.remainingPercent || 100,
2961
+ resetsAt: data.windows.fiveHour.resetsAt,
2962
+ t,
2963
+ })
2964
+ : null,
2965
+ React.createElement(ProgressBar, {
2966
+ label: t('quota.window_weekly'),
2967
+ percentUsed: data?.windows?.weekly?.percentUsed || 0,
2968
+ remainingPercent: data?.windows?.weekly?.remainingPercent || 100,
2969
+ resetsAt: data?.windows?.weekly?.resetsAt,
2970
+ t,
2971
+ }),
2972
+ data?.windows?.monthly
2973
+ ? React.createElement(ProgressBar, {
2974
+ label: t('quota.window_monthly'),
2975
+ percentUsed: data.windows.monthly.percentUsed || 0,
2976
+ remainingPercent: data.windows.monthly.remainingPercent || 100,
2977
+ resetsAt: data.windows.monthly.resetsAt,
2978
+ t,
2979
+ })
2980
+ : null
2981
+ )
2982
+ )
2983
+ )
2984
+ )
2985
+ }
2986
+
2987
+ // --- src/client/components/models-provider-card.js ---
2988
+ function ModelsProviderCard(props) {
2989
+ const ctx = props?.ctx
2990
+ const t = props?.t || makeT(en, en)
2991
+ const [status, setStatus] = React.useState(null)
2992
+ const [apiKey, setApiKey] = React.useState('')
2993
+ const [showKey, setShowKey] = React.useState(false)
2994
+ const [saving, setSaving] = React.useState(false)
2995
+ const [msg, setMsg] = React.useState('')
2996
+ const [err, setErr] = React.useState('')
2997
+
2998
+ const loadStatus = React.useCallback(async () => {
2999
+ try {
3000
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
3001
+ if (!res.ok) return
3002
+ const json = await res.json().catch(() => null)
3003
+ if (json && json.ok) setStatus(json)
3004
+ } catch {
3005
+ // quiet on network fail
3006
+ }
3007
+ }, [])
3008
+
3009
+ React.useEffect(() => {
3010
+ loadStatus()
3011
+ }, [loadStatus])
3012
+
3013
+ const keyConfigured = Boolean(status?.keyPresent || status?.config?.hasKey)
3014
+ const modelsCount = status?.config?.enabledModels?.length || status?.availableModels?.length || 0
3015
+
3016
+ const handleSaveKey = async () => {
3017
+ const keyVal = String(apiKey || '').trim()
3018
+ if (!keyVal) {
3019
+ setErr(t('key.empty_err'))
3020
+ return
3021
+ }
3022
+ setSaving(true)
3023
+ setErr('')
3024
+ setMsg('')
3025
+ try {
3026
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/save-key`, {
3027
+ method: 'POST',
3028
+ headers: { 'Content-Type': 'application/json' },
3029
+ body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: status?.config?.apiKeyEnv }),
3030
+ })
3031
+ const data = await res.json().catch(() => ({}))
3032
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3033
+ setApiKey('')
3034
+ setMsg(t('card.key_saved'))
3035
+ await loadStatus()
3036
+ } catch (e) {
3037
+ setErr(String(e?.message || e))
3038
+ } finally {
3039
+ setSaving(false)
3040
+ }
3041
+ }
3042
+
3043
+ const handleOpenSettings = () => {
3044
+ const layout = ctx?.get?.('layout')
3045
+ if (typeof layout?.selectPanel === 'function') {
3046
+ layout.selectPanel('clinebot-dashboard')
3047
+ } else if (typeof props?.openSettings === 'function') {
3048
+ props.openSettings()
3049
+ }
3050
+ }
3051
+
3052
+ return React.createElement(
3053
+ 'div',
3054
+ { className: 'cb-providerCard', 'data-clinebot-provider-card': 'true' },
3055
+ // Header
3056
+ React.createElement(
3057
+ 'div',
3058
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3059
+ React.createElement(
3060
+ 'div',
3061
+ { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
3062
+ React.createElement('span', { style: { fontSize: '18px' } }, '🤖'),
3063
+ React.createElement('span', { style: { fontSize: '15px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t('card.title'))
3064
+ ),
3065
+ React.createElement(
3066
+ 'div',
3067
+ { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
3068
+ React.createElement(
3069
+ 'span',
3070
+ { className: keyConfigured ? 'cb-badge cb-badge-ok' : 'cb-badge cb-badge-warn' },
3071
+ keyConfigured ? `✓ ${t('card.status_connected')}` : `⚠️ ${t('card.status_no_key')}`
3072
+ ),
3073
+ modelsCount > 0
3074
+ ? React.createElement(
3075
+ 'span',
3076
+ { className: 'cb-badge cb-badge-ok' },
3077
+ t('card.models_active', { count: modelsCount })
3078
+ )
3079
+ : null
3080
+ )
3081
+ ),
3082
+
3083
+ // Quick Key Input Form
3084
+ React.createElement(
3085
+ 'div',
3086
+ { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
3087
+ React.createElement(
3088
+ 'div',
3089
+ { className: 'cb-input-group' },
3090
+ React.createElement('input', {
3091
+ type: showKey ? 'text' : 'password',
3092
+ className: 'cb-input',
3093
+ style: { flex: 1 },
3094
+ value: apiKey,
3095
+ placeholder: keyConfigured ? t('key.placeholder_has') : t('key.placeholder_empty'),
3096
+ onChange: (e) => setApiKey(e.target.value),
3097
+ onKeyDown: (e) => { if (e.key === 'Enter') handleSaveKey() },
3098
+ }),
3099
+ React.createElement(
3100
+ 'button',
3101
+ {
3102
+ type: 'button',
3103
+ className: 'cb-btn',
3104
+ onClick: () => setShowKey(!showKey),
3105
+ },
3106
+ showKey ? t('key.hide') : t('key.show')
3107
+ ),
3108
+ React.createElement(
3109
+ 'button',
3110
+ {
3111
+ type: 'button',
3112
+ className: 'cb-btn cb-btn-primary',
3113
+ disabled: saving || !apiKey.trim(),
3114
+ onClick: handleSaveKey,
3115
+ },
3116
+ saving ? t('key.saving') : t('card.save_key')
3117
+ )
3118
+ ),
3119
+ msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
3120
+ err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null
3121
+ ),
3122
+
3123
+ // Footer with navigation to full settings
3124
+ React.createElement(
3125
+ 'div',
3126
+ { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
3127
+ React.createElement(
3128
+ 'button',
3129
+ {
3130
+ type: 'button',
3131
+ className: 'cb-btn',
3132
+ onClick: handleOpenSettings,
3133
+ },
3134
+ `⚙️ ${t('card.open_settings')}`
3135
+ )
3136
+ )
3137
+ )
3138
+ }
3139
+
2576
3140
  // --- src/client/settings-page.js ---
2577
3141
  function SettingsPage(props) {
2578
3142
  const ctx = props?.ctx
@@ -3020,7 +3584,7 @@ function SettingsPage(props) {
3020
3584
  }),
3021
3585
 
3022
3586
  // Quota Warning & Dashboard Card
3023
- React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, t }),
3587
+ React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, showSidebarQuota: draft?.showSidebarQuota !== false, onToggleSidebarQuota: (v) => handleConfigPatch({ showSidebarQuota: v }), t }),
3024
3588
 
3025
3589
  // Card 3: Model Picker Management
3026
3590
  React.createElement(ModelsSection, {
@@ -3237,6 +3801,48 @@ function apply(ctx) {
3237
3801
  (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
3238
3802
  )
3239
3803
  )
3804
+
3805
+ // Sidebar Quota Meter (sidebar.footer.action slot)
3806
+ registerSlotWhenReady('sidebar.footer.action', () =>
3807
+ ctx.slots.register(
3808
+ {
3809
+ name: 'sidebar.footer.action',
3810
+ id: 'clinebot-quota',
3811
+ order: 20,
3812
+ locale: NS,
3813
+ inject: () => ({ ctx }),
3814
+ },
3815
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(SidebarQuota, { ...props, ctx: (props && props.ctx) || ctx }))
3816
+ )
3817
+ )
3818
+
3819
+ // Central Overlay Dashboard (main slot)
3820
+ registerSlotWhenReady('main', () =>
3821
+ ctx.slots.register(
3822
+ {
3823
+ name: 'main',
3824
+ key: 'clinebot-dashboard',
3825
+ locale: NS,
3826
+ inject: () => ({ ctx }),
3827
+ },
3828
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(DashboardOverlay, { ...props, ctx: (props && props.ctx) || ctx }))
3829
+ )
3830
+ )
3831
+
3832
+ // Settings Models Provider Card (settings.models.provider-card slot)
3833
+ for (const pKey of ['clinebot', 'llm-clinebot']) {
3834
+ registerSlotWhenReady('settings.models.provider-card', () =>
3835
+ ctx.slots.register(
3836
+ {
3837
+ name: 'settings.models.provider-card',
3838
+ key: pKey,
3839
+ locale: NS,
3840
+ inject: () => ({ ctx }),
3841
+ },
3842
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(ModelsProviderCard, { ...props, ctx: (props && props.ctx) || ctx }))
3843
+ )
3844
+ )
3845
+ }
3240
3846
  }
3241
3847
 
3242
3848
  module.exports = { apply, inject: ['slots', 'locale', 'configForms'] }
package/lib/config.js CHANGED
@@ -41,6 +41,8 @@ export const Config = z.object({
41
41
  .description('Automatically hide models not included in active subscription plan from chat picker.').volatile(),
42
42
  visibleModels: z.array(z.string()).default([])
43
43
  .description('User-selected visible models in chat model picker (empty means all supported models).').volatile(),
44
+ showSidebarQuota: z.boolean().default(true)
45
+ .description('Display compact quota meter in DSH sidebar footer.').volatile(),
44
46
  enabledModels: z.array(z.string()).default([])
45
47
  .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
46
48
  dynamicModels: z.array(z.object({
@@ -209,6 +211,7 @@ export function publicConfig(cfg) {
209
211
  disabledModels: disabledList,
210
212
  filterModelsByPlan,
211
213
  visibleModels,
214
+ showSidebarQuota: cfg?.showSidebarQuota !== false,
212
215
  enabledModels: activeIds,
213
216
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
214
217
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
@@ -14,9 +14,9 @@ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
14
14
  let cachedToken = null
15
15
 
16
16
  const require = createRequire(import.meta.url)
17
- let packageVersion = '0.5.27'
17
+ let packageVersion = '0.5.28'
18
18
  try {
19
- packageVersion = require('../package.json').version || '0.5.27'
19
+ packageVersion = require('../package.json').version || '0.5.28'
20
20
  } catch (err) {
21
21
  /* fallback to default version if manifest is unreadable */
22
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.27",
3
+ "version": "0.5.28",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",