free-coding-models 0.5.14 → 0.5.16

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.
Files changed (34) hide show
  1. package/changelog/v0.5.12.md +86 -2
  2. package/changelog/v0.5.13.md +19 -53
  3. package/changelog/v0.5.15.md +17 -0
  4. package/changelog/v0.5.16.md +15 -0
  5. package/package.json +4 -3
  6. package/src/tui/app.js +16 -4
  7. package/src/tui/cli-help.js +87 -0
  8. package/src/tui/key-handler.js +2 -14
  9. package/src/tui/render-table.js +46 -26
  10. package/src/tui/theme.js +70 -0
  11. package/web/README.md +1 -1
  12. package/web/dist/assets/index-avN2GM3H.css +1 -0
  13. package/web/dist/assets/index-vsysCImz.js +41 -0
  14. package/web/dist/index.html +6 -2
  15. package/web/index.html +4 -0
  16. package/web/server.js +249 -15
  17. package/web/src/App.jsx +46 -15
  18. package/web/src/components/dashboard/DetailPanel.jsx +1 -1
  19. package/web/src/components/dashboard/DetailPanel.module.css +5 -0
  20. package/web/src/components/dashboard/ExpandedDetailRow.jsx +464 -0
  21. package/web/src/components/dashboard/ExpandedDetailRow.module.css +347 -0
  22. package/web/src/components/dashboard/FilterBar.jsx +98 -58
  23. package/web/src/components/dashboard/FilterBar.module.css +103 -33
  24. package/web/src/components/dashboard/ModelTable.jsx +49 -29
  25. package/web/src/components/dashboard/ModelTable.module.css +12 -0
  26. package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
  27. package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
  28. package/web/src/components/help/HelpView.jsx +13 -0
  29. package/web/src/components/playground/PlaygroundView.jsx +193 -12
  30. package/web/src/components/playground/PlaygroundView.module.css +82 -0
  31. package/web/src/components/router/RouterView.jsx +15 -6
  32. package/web/vite.config.js +1 -1
  33. package/web/dist/assets/index-Bzqz7KbT.js +0 -39
  34. package/web/dist/assets/index-H9JWDRIh.css +0 -1
@@ -24,19 +24,112 @@
24
24
  white-space: nowrap;
25
25
  }
26
26
 
27
- /* ── Chip rows (TUI cycle behavior) ── */
28
- .chipRow { display: flex; gap: 3px; }
29
- .chip {
30
- font-size: 11px; font-weight: 600; font-family: var(--font-mono);
27
+ /* ── Collapsible filter groups (Tier, Status, Verdict, Health) ── */
28
+ .filterGroup {
29
+ position: relative;
30
+ }
31
+
32
+ .filterTrigger {
33
+ display: inline-flex; align-items: center; gap: 4px;
34
+ font-size: 11px; font-family: var(--font-mono);
31
35
  padding: 4px 8px; border-radius: 5px;
32
- border: 1px solid var(--color-border); background: var(--color-surface);
33
- color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
36
+ border: 1px solid var(--color-border);
37
+ background: var(--color-surface);
38
+ color: var(--color-text-muted);
39
+ cursor: pointer; transition: all 150ms;
40
+ white-space: nowrap;
41
+ user-select: none;
42
+ }
43
+ .filterTrigger:hover {
44
+ background: var(--color-bg-hover);
45
+ color: var(--color-text);
46
+ border-color: var(--color-text-muted);
47
+ }
48
+ .filterTriggerActive {
49
+ border-color: var(--color-accent);
50
+ color: var(--color-text);
51
+ }
52
+ .filterTriggerExpanded {
53
+ border-color: var(--color-accent);
54
+ background: var(--color-bg-hover);
55
+ color: var(--color-text);
56
+ border-bottom-left-radius: 0;
57
+ border-bottom-right-radius: 0;
58
+ z-index: 101;
59
+ }
60
+
61
+ .filterTriggerLabel {
62
+ font-size: 10px; font-weight: 700;
63
+ text-transform: uppercase; letter-spacing: 0.5px;
64
+ color: var(--color-text-muted);
65
+ }
66
+ .filterTriggerSep {
67
+ color: var(--color-border);
68
+ margin: 0 -2px;
69
+ }
70
+ .filterTriggerValue {
71
+ font-weight: 700;
72
+ color: var(--color-text);
73
+ }
74
+ .filterTriggerActive .filterTriggerValue {
75
+ color: var(--color-accent);
76
+ }
77
+
78
+ .filterTriggerChevron {
79
+ flex-shrink: 0;
80
+ transition: transform 200ms ease;
81
+ opacity: 0.6;
82
+ }
83
+ .chevronOpen {
84
+ transform: rotate(180deg);
85
+ opacity: 1;
86
+ }
87
+
88
+ .filterDropdown {
89
+ position: absolute; top: 100%; left: 0;
90
+ z-index: 100;
91
+ padding: 6px 8px 7px;
92
+ background: var(--color-surface);
93
+ border: 1px solid var(--color-accent);
94
+ border-top: none;
95
+ border-radius: 0 0 6px 6px;
96
+ box-shadow: 0 8px 24px rgba(0,0,0,0.3);
97
+ min-width: max-content;
98
+ }
99
+
100
+ .filterChipRow {
101
+ display: flex;
102
+ gap: 0;
103
+ }
104
+
105
+ .filterChip {
106
+ font-size: 11px; font-weight: 600; font-family: var(--font-mono);
107
+ padding: 4px 9px;
108
+ border: 1px solid var(--color-border);
109
+ background: var(--color-bg-card);
110
+ color: var(--color-text-muted);
111
+ cursor: pointer; transition: all 120ms;
34
112
  white-space: nowrap;
113
+ margin-left: -1px;
35
114
  }
36
- .chip:hover { background: var(--color-bg-hover); color: var(--color-text); }
37
- .chipActive {
38
- background: var(--color-accent) !important; color: var(--color-bg) !important;
39
- border-color: var(--color-accent) !important; font-weight: 700;
115
+ .filterChip:first-child {
116
+ border-radius: 4px 0 0 4px;
117
+ margin-left: 0;
118
+ }
119
+ .filterChip:last-child {
120
+ border-radius: 0 4px 4px 0;
121
+ }
122
+ .filterChip:hover {
123
+ background: var(--color-bg-hover);
124
+ color: var(--color-text);
125
+ z-index: 1;
126
+ }
127
+ .filterChipActive {
128
+ background: var(--chip-active-color, var(--color-accent)) !important;
129
+ color: #fff !important;
130
+ border-color: var(--chip-active-color, var(--color-accent)) !important;
131
+ z-index: 2;
132
+ font-weight: 700;
40
133
  }
41
134
 
42
135
  /* ── Select inputs (provider, visibility) ── */
@@ -121,23 +214,6 @@
121
214
  }
122
215
  .customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
123
216
 
124
- /* ── Ping interval selector ── */
125
- .pingRow { display: flex; gap: 3px; }
126
- .pingBtn {
127
- font-size: 11px; font-weight: 600; font-family: var(--font-mono);
128
- padding: 4px 8px; border-radius: 5px;
129
- border: 1px solid var(--color-border); background: var(--color-surface);
130
- color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
131
- white-space: nowrap;
132
- }
133
- .pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
134
- .pingBtnActive {
135
- background: var(--color-bg-hover) !important;
136
- color: var(--ping-active-color, var(--color-accent)) !important;
137
- border-color: var(--ping-active-color, var(--color-accent)) !important;
138
- font-weight: 700;
139
- }
140
-
141
217
  /* ── Next ping countdown (TUI parity) ── */
142
218
  .nextPing {
143
219
  display: flex; align-items: center; gap: 6px;
@@ -157,12 +233,6 @@
157
233
  text-align: right;
158
234
  font-variant-numeric: tabular-nums;
159
235
  }
160
- .pingingDot {
161
- width: 7px; height: 7px; border-radius: 50%;
162
- background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
163
- flex-shrink: 0;
164
- }
165
-
166
236
  @keyframes pulseDot {
167
237
  0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
168
238
  50% { box-shadow: 0 0 0 6px transparent; }
@@ -13,7 +13,7 @@
13
13
  * 📖 Custom widths persist in localStorage via the useColumnSizing hook and survive reloads.
14
14
  * 📖 A "Reset columns" button appears in the toolbar only when the user has custom widths.
15
15
  */
16
- import { useMemo, useEffect } from 'react'
16
+ import { useMemo, useEffect, useState, useCallback, Fragment } from 'react'
17
17
  import {
18
18
  useReactTable,
19
19
  getCoreRowModel,
@@ -43,6 +43,7 @@ import { sweClass } from '../../utils/ranks.js'
43
43
  // 📖 file the TUI uses; both surfaces share the source of truth for which
44
44
  // 📖 providers are compatible with which tools.
45
45
  import { getCompatibleTools } from '../../../../src/core/tool-metadata.js'
46
+ import ExpandedDetailRow from './ExpandedDetailRow.jsx'
46
47
  import styles from './ModelTable.module.css'
47
48
 
48
49
  const colHelper = createColumnHelper()
@@ -345,7 +346,15 @@ export default function ModelTable({
345
346
  filtered, onSelectModel, onBenchmarkRow, onLaunch, favorites,
346
347
  sortColumn, sortDirection, onSort,
347
348
  toolMode = null,
349
+ onToast, onSetToolMode, onCycleToolMode, onOpenFallback,
348
350
  }) {
351
+ // 📖 Expand row state — only one row expanded at a time (accordion)
352
+ const [expandedRowId, setExpandedRowId] = useState(null)
353
+
354
+ const toggleExpand = useCallback((model) => {
355
+ const key = `${model.providerKey}/${model.modelId}`
356
+ setExpandedRowId((prev) => prev === key ? null : key)
357
+ }, [])
349
358
  // Compute top3 for medal rows
350
359
  const top3Ids = useMemo(() => {
351
360
  const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
@@ -501,49 +510,60 @@ export default function ModelTable({
501
510
  <tbody>
502
511
  {rows.map((row, i) => {
503
512
  const m = row.original
513
+ const rowKey = `${m.providerKey}/${m.modelId}`
514
+ const isExpanded = expandedRowId === rowKey
504
515
  const rankIdx = [...top3Ids].indexOf(m.modelId)
505
516
  const rowClasses = []
506
517
  if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
507
- // 📖 Per-row benchmark state comes from the backend. The old global
508
- // 📖 heuristic made every unbenchmarked row spin until the whole scan ended.
509
518
  if (m.isBenchmarking) rowClasses.push(styles.benchRow)
510
- // 📖 Tool-compat dark-red row when an active tool mode is set and the
511
- // 📖 model isn't compatible with it. Mirrors the TUI render-table.js
512
- // 📖 behavior. The picker itself ships in M3; for M1 the visual is wired.
519
+ if (isExpanded) rowClasses.push(styles.expandedRow)
513
520
  if (toolMode) {
514
521
  const compat = getCompatibleTools(m.providerKey)
515
522
  if (!compat.includes(toolMode)) {
516
523
  rowClasses.push(styles.incompatible)
517
524
  }
518
525
  }
519
- // 📖 "Unusable" rows (no API key / bad API key) get the same 80% opacity
520
- // 📖 fade the TUI uses, so the user can see at a glance which models
521
- // 📖 they cannot actually use. The fade is applied as opacity on the
522
- // 📖 whole <tr> so it composes cleanly with rank/bench/incompatible
523
- // 📖 styles. Cursor hover is not implemented in the web, so the
524
- // 📖 fade is never overridden by an active-selection highlight.
525
526
  if (m.status === 'noauth' || m.status === 'auth_error') {
526
527
  rowClasses.push(styles.unusable)
527
528
  }
528
529
  return (
529
- <tr
530
- key={row.id}
531
- className={rowClasses.join(' ')}
532
- onClick={() => onSelectModel(row.original)}
533
- >
534
- {row.getVisibleCells().map(cell => {
535
- const sizePx = `${cell.column.getSize()}px`
536
- return (
537
- <td
538
- key={cell.id}
539
- className={styles.td}
540
- style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
541
- >
542
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
530
+ <Fragment key={row.id}>
531
+ <tr
532
+ className={rowClasses.join(' ')}
533
+ onClick={() => toggleExpand(m)}
534
+ >
535
+ {row.getVisibleCells().map(cell => {
536
+ const sizePx = `${cell.column.getSize()}px`
537
+ return (
538
+ <td
539
+ key={cell.id}
540
+ className={styles.td}
541
+ style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
542
+ >
543
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
544
+ </td>
545
+ )
546
+ })}
547
+ </tr>
548
+ {/* 📖 Expanded detail row — 3-column panel with info, playground, AI latency */}
549
+ {isExpanded && (
550
+ <tr className={styles.expandRowWrapper}>
551
+ <td colSpan={row.getVisibleCells().length} className={styles.expandRowCell}>
552
+ <ExpandedDetailRow
553
+ model={m}
554
+ favorites={favorites}
555
+ onBenchmark={onBenchmarkRow}
556
+ onLaunch={onLaunch}
557
+ onToast={onToast}
558
+ toolMode={toolMode}
559
+ onSetToolMode={onSetToolMode}
560
+ onCycleToolMode={onCycleToolMode}
561
+ onOpenFallback={onOpenFallback}
562
+ />
543
563
  </td>
544
- )
545
- })}
546
- </tr>
564
+ </tr>
565
+ )}
566
+ </Fragment>
547
567
  )
548
568
  })}
549
569
  </tbody>
@@ -356,4 +356,16 @@
356
356
  .incompatible .launchCell,
357
357
  .benchRow .launchCell {
358
358
  opacity: 1;
359
+ }
360
+
361
+ /* ─── Expandable row styles ─── */
362
+ .expandedRow {
363
+ background: var(--color-bg-active) !important;
364
+ }
365
+ .expandRowWrapper {
366
+ border: none;
367
+ }
368
+ .expandRowCell {
369
+ padding: 0 !important;
370
+ border-bottom: 2px solid var(--color-accent) !important;
359
371
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @file web/src/components/dashboard/ProviderDropdown.jsx
3
+ * @description Custom dropdown for provider filtering with inline SVG logos + health indicator.
4
+ *
5
+ * @details
6
+ * - Replaces the native `<select>` for providers with a custom dropdown that
7
+ * renders each provider as [SVG icon + wordmark + health dot + model count].
8
+ * - Uses the same `ProviderLogo` component as the model table so branding is
9
+ * visually consistent across the dashboard.
10
+ * - The health indicator dot shows:
11
+ * 🟢 Green (glow) — provider has at least one model with status 'up' (key works).
12
+ * 🟡 Yellow — provider has keys configured but no models are 'up' yet (pending).
13
+ * 🔴 Red — provider has keys but models are down / auth errors.
14
+ * ⚪ Gray — provider has no API key configured at all.
15
+ * - Theme-aware: colors adapt for both dark and light modes via CSS variables.
16
+ * - Click-outside + Escape to close, keyboard-friendly, scrollable for long lists.
17
+ *
18
+ * @param {object} props
19
+ * @param {Array} props.providers — Array of { key, name, count, hasKey, anyUp }
20
+ * @param {string} props.value — Currently selected provider key ('all' for no filter).
21
+ * @param {function} props.onChange — Callback with new provider key.
22
+ *
23
+ * @functions
24
+ * → ProviderDropdown (default export)
25
+ *
26
+ * @see web/src/components/dashboard/FilterBar.jsx (consumer)
27
+ * @see web/src/components/atoms/ProviderLogo.jsx (logo renderer)
28
+ */
29
+ import { useState, useEffect, useRef, useCallback } from 'react'
30
+ import { IconChevronDown } from '@tabler/icons-react'
31
+ import ProviderLogo from '../atoms/ProviderLogo.jsx'
32
+ import styles from './ProviderDropdown.module.css'
33
+
34
+ /**
35
+ * 📖 Derives the health indicator state from aggregated provider data.
36
+ * Returns one of: 'active' | 'pending' | 'down' | 'nokey'.
37
+ */
38
+ function providerHealthState(provider) {
39
+ if (!provider.hasKey) return 'nokey'
40
+ if (provider.anyUp) return 'active'
41
+ return 'down'
42
+ }
43
+
44
+ function HealthIndicator({ state }) {
45
+ const cls = styles[`dot_${state}`] || styles.dot_nokey
46
+ const titles = {
47
+ active: 'API key works — at least one model is UP',
48
+ pending: 'API key set — waiting for health data',
49
+ down: 'API key set — models are DOWN or have auth errors',
50
+ nokey: 'No API key configured',
51
+ }
52
+ return <span className={`${styles.healthDot} ${cls}`} title={titles[state] || ''} />
53
+ }
54
+
55
+ export default function ProviderDropdown({ providers, value, onChange }) {
56
+ const [open, setOpen] = useState(false)
57
+ const ref = useRef(null)
58
+ const listRef = useRef(null)
59
+
60
+ const close = useCallback(() => setOpen(false), [])
61
+
62
+ // 📖 Close on outside click or Escape.
63
+ useEffect(() => {
64
+ if (!open) return
65
+ const handleClickOutside = (e) => {
66
+ if (ref.current && !ref.current.contains(e.target)) close()
67
+ }
68
+ const handleKey = (e) => { if (e.key === 'Escape') close() }
69
+ document.addEventListener('mousedown', handleClickOutside)
70
+ document.addEventListener('keydown', handleKey)
71
+ return () => {
72
+ document.removeEventListener('mousedown', handleClickOutside)
73
+ document.removeEventListener('keydown', handleKey)
74
+ }
75
+ }, [open, close])
76
+
77
+ // 📖 Scroll selected item into view when dropdown opens.
78
+ useEffect(() => {
79
+ if (!open || !listRef.current) return
80
+ const selected = listRef.current.querySelector(`[data-active="true"]`)
81
+ if (selected) selected.scrollIntoView({ block: 'nearest' })
82
+ }, [open])
83
+
84
+ const selectedProvider = providers.find(p => p.key === value)
85
+ const isFiltered = value !== 'all'
86
+
87
+ return (
88
+ <div className={styles.dropdown} ref={ref}>
89
+ <button
90
+ className={`${styles.trigger} ${isFiltered ? styles.triggerActive : ''} ${open ? styles.triggerOpen : ''}`}
91
+ onClick={() => setOpen(!open)}
92
+ aria-expanded={open}
93
+ aria-haspopup="listbox"
94
+ title={isFiltered ? `Provider: ${selectedProvider?.name || value}` : 'All providers'}
95
+ >
96
+ <span className={styles.triggerContent}>
97
+ {isFiltered && selectedProvider ? (
98
+ <>
99
+ <span className={styles.triggerLogo}>
100
+ <ProviderLogo providerKey={selectedProvider.key} origin={selectedProvider.name} />
101
+ </span>
102
+ <span className={styles.triggerCount}>{selectedProvider.count}</span>
103
+ </>
104
+ ) : (
105
+ <span className={styles.triggerLabel}>All Providers</span>
106
+ )}
107
+ </span>
108
+ <IconChevronDown
109
+ size={12}
110
+ stroke={2}
111
+ className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
112
+ />
113
+ </button>
114
+
115
+ {open && (
116
+ <div className={styles.menu} role="listbox" ref={listRef}>
117
+ {/* ── "All Providers" option ── */}
118
+ <button
119
+ role="option"
120
+ aria-selected={value === 'all'}
121
+ data-active={value === 'all'}
122
+ className={`${styles.option} ${value === 'all' ? styles.optionActive : ''}`}
123
+ onClick={() => { onChange('all'); close() }}
124
+ >
125
+ <span className={styles.optionLabel}>All Providers</span>
126
+ <span className={styles.optionCount}>{providers.reduce((s, p) => s + p.count, 0)}</span>
127
+ </button>
128
+
129
+ <div className={styles.separator} />
130
+
131
+ {/* ── Per-provider options ── */}
132
+ {providers.map((p) => {
133
+ const healthState = providerHealthState(p)
134
+ return (
135
+ <button
136
+ key={p.key}
137
+ role="option"
138
+ aria-selected={value === p.key}
139
+ data-active={value === p.key}
140
+ className={`${styles.option} ${value === p.key ? styles.optionActive : ''}`}
141
+ onClick={() => { onChange(p.key); close() }}
142
+ title={`${p.name} — ${p.count} model${p.count !== 1 ? 's' : ''}`}
143
+ >
144
+ <span className={styles.optionLogo}>
145
+ <ProviderLogo providerKey={p.key} origin={p.name} />
146
+ </span>
147
+ <HealthIndicator state={healthState} />
148
+ <span className={styles.optionCount}>{p.count}</span>
149
+ </button>
150
+ )
151
+ })}
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * @file web/src/components/dashboard/ProviderDropdown.module.css
3
+ * @description Styles for the custom provider dropdown — SVG logos + health dots.
4
+ *
5
+ * 📖 Theme-aware: uses CSS variables for all colors. Dark + light mode both
6
+ * 📖 fully supported. Health dot colors are defined per-theme with explicit
7
+ * 📖 overrides for light mode to maintain contrast.
8
+ */
9
+
10
+ .dropdown {
11
+ position: relative;
12
+ }
13
+
14
+ /* ── Trigger button ── */
15
+ .trigger {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: 6px;
19
+ padding: 4px 10px 4px 8px;
20
+ border: 1px solid var(--color-border);
21
+ border-radius: 5px;
22
+ background: var(--color-surface);
23
+ color: var(--color-text);
24
+ font-size: 11px;
25
+ font-family: var(--font-sans);
26
+ cursor: pointer;
27
+ transition: all 150ms;
28
+ min-width: 130px;
29
+ max-width: 240px;
30
+ user-select: none;
31
+ outline: none;
32
+ }
33
+ .trigger:hover {
34
+ background: var(--color-bg-hover);
35
+ border-color: var(--color-text-muted);
36
+ }
37
+ .trigger:focus {
38
+ border-color: var(--color-accent);
39
+ }
40
+ .triggerActive {
41
+ border-color: var(--color-accent);
42
+ color: var(--color-text);
43
+ }
44
+ .triggerOpen {
45
+ border-color: var(--color-accent);
46
+ border-bottom-left-radius: 0;
47
+ border-bottom-right-radius: 0;
48
+ z-index: 101;
49
+ background: var(--color-bg-hover);
50
+ }
51
+
52
+ .triggerContent {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 8px;
56
+ flex: 1;
57
+ min-width: 0;
58
+ overflow: hidden;
59
+ }
60
+
61
+ .triggerLogo {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ min-width: 0;
65
+ overflow: hidden;
66
+ /* 📖 Inherit max-height from ProviderLogo's row. */
67
+ --pl-max-h: 20px;
68
+ --pl-icon-h: 14px;
69
+ --pl-text-h: 10px;
70
+ }
71
+ /* 📖 Clamp ProviderLogo width inside trigger so long wordmarks don't overflow. */
72
+ .triggerLogo > span {
73
+ max-width: 160px;
74
+ }
75
+
76
+ .triggerLabel {
77
+ font-size: 11px;
78
+ font-weight: 600;
79
+ color: var(--color-text-muted);
80
+ white-space: nowrap;
81
+ }
82
+
83
+ .triggerCount {
84
+ font-size: 10px;
85
+ font-weight: 700;
86
+ color: var(--color-text-muted);
87
+ font-family: var(--font-mono);
88
+ margin-left: auto;
89
+ flex-shrink: 0;
90
+ }
91
+
92
+ .chevron {
93
+ flex-shrink: 0;
94
+ opacity: 0.5;
95
+ transition: transform 200ms ease;
96
+ }
97
+ .chevronOpen {
98
+ transform: rotate(180deg);
99
+ opacity: 0.8;
100
+ }
101
+
102
+ /* ── Dropdown menu ── */
103
+ .menu {
104
+ position: absolute;
105
+ top: 100%;
106
+ left: 0;
107
+ z-index: 100;
108
+ min-width: 100%;
109
+ max-width: 300px;
110
+ max-height: 320px;
111
+ overflow-y: auto;
112
+ background: var(--color-surface);
113
+ border: 1px solid var(--color-accent);
114
+ border-top: none;
115
+ border-radius: 0 0 6px 6px;
116
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
117
+ padding: 4px 0;
118
+ }
119
+
120
+ /* 📖 Custom scrollbar for the dropdown. */
121
+ .menu::-webkit-scrollbar {
122
+ width: 5px;
123
+ }
124
+ .menu::-webkit-scrollbar-track {
125
+ background: transparent;
126
+ }
127
+ .menu::-webkit-scrollbar-thumb {
128
+ background: var(--color-border);
129
+ border-radius: 3px;
130
+ }
131
+
132
+ /* ── Menu options ── */
133
+ .option {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 8px;
137
+ width: 100%;
138
+ padding: 6px 10px;
139
+ border: none;
140
+ background: transparent;
141
+ color: var(--color-text-muted);
142
+ font-size: 11px;
143
+ font-family: var(--font-sans);
144
+ cursor: pointer;
145
+ transition: all 100ms;
146
+ text-align: left;
147
+ outline: none;
148
+ }
149
+ .option:hover {
150
+ background: var(--color-bg-hover);
151
+ color: var(--color-text);
152
+ }
153
+ .option:focus-visible {
154
+ background: var(--color-bg-hover);
155
+ color: var(--color-text);
156
+ box-shadow: inset 0 0 0 1px var(--color-accent);
157
+ }
158
+
159
+ .optionActive {
160
+ background: var(--color-accent-dim);
161
+ color: var(--color-text);
162
+ font-weight: 600;
163
+ }
164
+ .optionActive:hover {
165
+ background: var(--color-bg-hover);
166
+ }
167
+
168
+ .optionLabel {
169
+ font-weight: 600;
170
+ white-space: nowrap;
171
+ }
172
+
173
+ .optionLogo {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ min-width: 0;
177
+ flex: 1;
178
+ overflow: hidden;
179
+ /* 📖 Slightly larger than trigger for readability in the expanded menu. */
180
+ --pl-max-h: 20px;
181
+ --pl-icon-h: 15px;
182
+ --pl-text-h: 11px;
183
+ }
184
+ .optionLogo > span {
185
+ max-width: 180px;
186
+ }
187
+
188
+ .optionCount {
189
+ font-size: 10px;
190
+ font-weight: 700;
191
+ font-family: var(--font-mono);
192
+ color: var(--color-text-dim, #666);
193
+ flex-shrink: 0;
194
+ margin-left: auto;
195
+ }
196
+
197
+ /* ── Separator between "All" and per-provider options ── */
198
+ .separator {
199
+ height: 1px;
200
+ background: var(--color-border);
201
+ margin: 4px 8px;
202
+ }
203
+
204
+ /* ── Health indicator dots ── */
205
+ .healthDot {
206
+ display: inline-block;
207
+ width: 7px;
208
+ height: 7px;
209
+ border-radius: 50%;
210
+ flex-shrink: 0;
211
+ transition: background 200ms ease, box-shadow 200ms ease;
212
+ }
213
+
214
+ /* 📖 Active: green glow — key works and models are UP. */
215
+ .dot_active {
216
+ background: #00ff88;
217
+ box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
218
+ }
219
+
220
+ /* 📖 Pending: yellow — key set, health data not yet available. */
221
+ .dot_pending {
222
+ background: #ffaa00;
223
+ }
224
+
225
+ /* 📖 Down: red — key set but models are down / auth errors. */
226
+ .dot_down {
227
+ background: #ff4444;
228
+ }
229
+
230
+ /* 📖 No key: gray — no API key configured for this provider. */
231
+ .dot_nokey {
232
+ background: #555570;
233
+ }
234
+
235
+ /* ── Light theme overrides — deeper colors for white background contrast ── */
236
+ :global([data-theme="light"]) .dot_active {
237
+ background: #008f4d;
238
+ box-shadow: 0 0 4px rgba(0, 143, 77, 0.35);
239
+ }
240
+ :global([data-theme="light"]) .dot_pending {
241
+ background: #b38600;
242
+ }
243
+ :global([data-theme="light"]) .dot_down {
244
+ background: #c8143a;
245
+ }
246
+ :global([data-theme="light"]) .dot_nokey {
247
+ background: #aaa;
248
+ }