free-coding-models 0.5.5 β†’ 0.5.8

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 (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -13,8 +13,9 @@ import {
13
13
  IconBolt, IconSearch, IconDownload, IconSettings, IconMoon, IconSun,
14
14
  IconPlayerPlay, IconCommand, IconLayoutDashboard, IconActivity,
15
15
  IconSparkles, IconRoute, IconDots, IconQuestionMark, IconHistory,
16
- IconPlug, IconFolders,
16
+ IconPlug, IconFolders, IconMenu2,
17
17
  } from '@tabler/icons-react'
18
+ import ToolPicker from '../tools/ToolPicker.jsx'
18
19
  import styles from './Header.module.css'
19
20
 
20
21
  // πŸ“– Top-level nav items β€” always visible as buttons. Inlined here so the
@@ -24,16 +25,17 @@ const NAV_ITEMS = [
24
25
  { id: 'dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
25
26
  { id: 'settings', label: 'Settings', icon: IconSettings },
26
27
  { id: 'analytics', label: 'Analytics', icon: IconActivity },
27
- { id: 'recommend', label: 'Recommend', icon: IconSparkles, comingIn: 'M3' },
28
- { id: 'router', label: 'Router', icon: IconRoute, comingIn: 'M4' },
28
+ { id: 'recommend', label: 'Recommend', icon: IconSparkles },
29
+ { id: 'router', label: 'Router', icon: IconRoute },
29
30
  ]
30
31
 
31
- // πŸ“– Overflow menu items β€” Help, Changelog, Install Endpoints, Installed Models.
32
+ // πŸ“– Overflow menu items β€” Help + Changelog shipped in M2; Install Endpoints
33
+ // πŸ“– and Installed Models are still M4.
32
34
  const MENU_ITEMS = [
33
- { id: 'help', label: 'Help', icon: IconQuestionMark, comingIn: 'M2' },
34
- { id: 'changelog', label: 'Changelog', icon: IconHistory, comingIn: 'M2' },
35
- { id: 'install-endpoints', label: 'Install Endpoints', icon: IconPlug, comingIn: 'M4' },
36
- { id: 'installed-models', label: 'Installed Models', icon: IconFolders, comingIn: 'M4' },
35
+ { id: 'help', label: 'Help', icon: IconQuestionMark },
36
+ { id: 'changelog', label: 'Changelog', icon: IconHistory },
37
+ { id: 'install-endpoints', label: 'Install Endpoints', icon: IconPlug },
38
+ { id: 'installed-models', label: 'Installed Models', icon: IconFolders },
37
39
  ]
38
40
 
39
41
  export default function Header({
@@ -42,24 +44,29 @@ export default function Header({
42
44
  onToggleTheme, onOpenExport, onOpenCommandPalette,
43
45
  onBenchmark, benchmarkRunning, benchmarkTotal, benchmarkCompleted,
44
46
  modelsCount, theme, onToast,
47
+ toolMode = 'opencode', onSetToolMode, onCycleToolMode,
48
+ updateSlot = null,
45
49
  }) {
46
50
  const [menuOpen, setMenuOpen] = useState(false)
51
+ const [mobileNavOpen, setMobileNavOpen] = useState(false)
47
52
  const menuRef = useRef(null)
48
53
 
49
54
  // πŸ“– Close the overflow menu on outside click or Esc.
50
55
  useEffect(() => {
51
- if (!menuOpen) return
56
+ if (!menuOpen && !mobileNavOpen) return
52
57
  const onClick = (e) => {
53
58
  if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false)
54
59
  }
55
- const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false) }
60
+ const onKey = (e) => {
61
+ if (e.key === 'Escape') { setMenuOpen(false); setMobileNavOpen(false) }
62
+ }
56
63
  document.addEventListener('mousedown', onClick)
57
64
  document.addEventListener('keydown', onKey)
58
65
  return () => {
59
66
  document.removeEventListener('mousedown', onClick)
60
67
  document.removeEventListener('keydown', onKey)
61
68
  }
62
- }, [menuOpen])
69
+ }, [menuOpen, mobileNavOpen])
63
70
 
64
71
  const handleNavClick = (item) => {
65
72
  if (item.comingIn) {
@@ -67,6 +74,7 @@ export default function Header({
67
74
  return
68
75
  }
69
76
  onNavigate(item.id)
77
+ setMobileNavOpen(false)
70
78
  }
71
79
 
72
80
  const handleMenuClick = (item) => {
@@ -91,6 +99,51 @@ export default function Header({
91
99
  </div>
92
100
  <span className={styles.version}>v{__APP_VERSION__}</span>
93
101
 
102
+ {/* πŸ“– M5: Hamburger for narrow viewports (never a sidebar). Shows a
103
+ dropdown with all nav + overflow items on mobile. */}
104
+ <button
105
+ className={styles.hamburgerBtn}
106
+ onClick={() => setMobileNavOpen((o) => !o)}
107
+ aria-label="Navigation menu"
108
+ aria-expanded={mobileNavOpen}
109
+ aria-haspopup="true"
110
+ >
111
+ <IconMenu2 size={18} stroke={1.5} />
112
+ </button>
113
+ {mobileNavOpen && (
114
+ <div className={styles.mobileNav} role="menu">
115
+ {NAV_ITEMS.map((item) => {
116
+ const Icon = item.icon
117
+ return (
118
+ <button
119
+ key={item.id}
120
+ className={`${styles.mobileNavItem} ${currentView === item.id ? styles.mobileNavActive : ''}`}
121
+ onClick={() => handleNavClick(item)}
122
+ role="menuitem"
123
+ >
124
+ <Icon size={16} stroke={1.5} />
125
+ <span>{item.label}</span>
126
+ </button>
127
+ )
128
+ })}
129
+ <div className={styles.mobileNavDivider} />
130
+ {MENU_ITEMS.map((item) => {
131
+ const Icon = item.icon
132
+ return (
133
+ <button
134
+ key={item.id}
135
+ className={styles.mobileNavItem}
136
+ onClick={() => handleMenuClick(item)}
137
+ role="menuitem"
138
+ >
139
+ <Icon size={16} stroke={1.5} />
140
+ <span>{item.label}</span>
141
+ </button>
142
+ )
143
+ })}
144
+ </div>
145
+ )}
146
+
94
147
  {/* Always-visible primary nav (replaces the old left sidebar) */}
95
148
  <nav className={styles.nav} aria-label="Primary">
96
149
  {NAV_ITEMS.map((item) => {
@@ -117,6 +170,7 @@ export default function Header({
117
170
  className={`${styles.navBtn} ${styles.menuTrigger}`}
118
171
  onClick={() => setMenuOpen((o) => !o)}
119
172
  title="More features"
173
+ aria-label="More features"
120
174
  aria-haspopup="true"
121
175
  aria-expanded={menuOpen}
122
176
  >
@@ -160,12 +214,18 @@ export default function Header({
160
214
  </div>
161
215
 
162
216
  <div className={styles.right}>
217
+ <ToolPicker
218
+ toolMode={toolMode}
219
+ onSetToolMode={onSetToolMode}
220
+ onCycleToolMode={onCycleToolMode}
221
+ />
222
+
163
223
  {/* ⌘K β€” the only global keyboard shortcut, opens the command palette */}
164
224
  <button
165
225
  className={styles.cmdkBtn}
166
226
  onClick={onOpenCommandPalette}
167
227
  title="Command palette (⌘K / Ctrl+P)"
168
- aria-label="Open command palette"
228
+ aria-label="Open command palette (⌘K)"
169
229
  >
170
230
  <IconCommand size={14} stroke={1.5} />
171
231
  <span className={styles.cmdkLabel}>⌘K</span>
@@ -176,6 +236,7 @@ export default function Header({
176
236
  onClick={onBenchmark}
177
237
  disabled={benchmarkRunning}
178
238
  title={benchmarkRunning ? `AI Speed Test running β€” ${benchmarkCompleted}/${benchmarkTotal}` : `Run AI Latency benchmark on ${modelsCount} visible models`}
239
+ aria-label={benchmarkRunning ? `AI Speed Test running β€” ${benchmarkCompleted} of ${benchmarkTotal} complete` : 'AI Latency benchmark'}
179
240
  >
180
241
  <IconPlayerPlay size={14} stroke={1.5} />
181
242
  {benchmarkRunning ? (
@@ -188,10 +249,13 @@ export default function Header({
188
249
  )}
189
250
  </button>
190
251
 
191
- <button className={styles.iconBtn} onClick={onToggleTheme} title={`Theme: ${theme} (click to cycle auto / dark / light)`}>
252
+ {/* πŸ“– M2: update chip slot. Hidden when no update is available. */}
253
+ {updateSlot}
254
+
255
+ <button className={styles.iconBtn} onClick={onToggleTheme} title={`Theme: ${theme} (click to cycle auto / dark / light)`} aria-label={`Theme: ${theme}. Click to cycle.`}>
192
256
  {theme === 'light' ? <IconMoon size={16} stroke={1.5} /> : <IconSun size={16} stroke={1.5} />}
193
257
  </button>
194
- <button className={styles.iconBtn} onClick={onOpenExport} title="Export Data">
258
+ <button className={styles.iconBtn} onClick={onOpenExport} title="Export Data" aria-label="Export model data">
195
259
  <IconDownload size={16} stroke={1.5} />
196
260
  </button>
197
261
  </div>
@@ -64,7 +64,7 @@
64
64
  .logoTextHighlight { color: var(--color-brand); }
65
65
  .version {
66
66
  font-size: 10px; font-weight: 500; font-family: var(--font-mono);
67
- color: var(--color-text-dim); background: var(--color-accent-dim);
67
+ color: var(--color-text-muted); background: var(--color-surface);
68
68
  padding: 2px 6px; border-radius: 999px; border: 1px solid var(--color-border);
69
69
  }
70
70
 
@@ -264,8 +264,68 @@
264
264
  .logoText { display: none; }
265
265
  .cmdkLabel { display: none; }
266
266
  .benchmarkBtn > span:not(.benchmarkRunning) { display: none; }
267
+ .nav { display: none; }
268
+ .hamburgerBtn { display: flex; }
267
269
  }
268
270
  @media (max-width: 560px) {
269
271
  .version { display: none; }
270
- .navBtn { padding: 0 6px; }
272
+ }
273
+
274
+ /* ─── Hamburger + mobile nav (M5) ─── */
275
+ .hamburgerBtn {
276
+ display: none;
277
+ align-items: center;
278
+ justify-content: center;
279
+ width: 34px;
280
+ height: 34px;
281
+ background: var(--color-surface);
282
+ border: 1px solid var(--color-border);
283
+ border-radius: var(--radius-md);
284
+ color: var(--color-text-muted);
285
+ cursor: pointer;
286
+ margin-left: 8px;
287
+ &:hover { color: var(--color-text); background: var(--color-bg-hover); }
288
+ }
289
+
290
+ .mobileNav {
291
+ position: absolute;
292
+ top: var(--header-h, 60px);
293
+ left: 0;
294
+ right: 0;
295
+ background: var(--color-bg-elevated);
296
+ border-bottom: 1px solid var(--color-border);
297
+ padding: 8px 16px 16px;
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 2px;
301
+ z-index: 150;
302
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
303
+ }
304
+
305
+ .mobileNavItem {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 10px;
309
+ padding: 10px 12px;
310
+ font-size: 13px;
311
+ font-weight: 600;
312
+ color: var(--color-text-muted);
313
+ background: transparent;
314
+ border: none;
315
+ border-radius: var(--radius-md);
316
+ cursor: pointer;
317
+ text-align: left;
318
+ width: 100%;
319
+ &:hover { background: var(--color-bg-hover); color: var(--color-text); }
320
+ }
321
+
322
+ .mobileNavActive {
323
+ color: var(--color-accent);
324
+ background: var(--color-accent-dim);
325
+ }
326
+
327
+ .mobileNavDivider {
328
+ height: 1px;
329
+ background: var(--color-border);
330
+ margin: 6px 0;
271
331
  }
@@ -1,72 +1,152 @@
1
1
  /**
2
2
  * @file web/src/components/palette/CommandPalette.jsx
3
- * @description ⌘K / Ctrl+P command palette β€” M1 placeholder, M2 ships the full version.
4
- * πŸ“– M1 keeps the modal opening, the ⌘K wiring, and a small launcher that exposes
5
- * πŸ“– the most-used actions (cycle theme, reset view, navigate views, change ping mode).
6
- * πŸ“– M2 will replace this with the TUI-aligned palette that consumes
7
- * πŸ“– `src/tui/command-palette.js` via `getCommandPaletteSnapshot()` for full parity.
3
+ * @description ⌘K / Ctrl+P command palette β€” M2 full version, fed by the TUI registry.
4
+ * πŸ“– M1 shipped a placeholder; this version pulls entries from
5
+ * πŸ“– `src/tui/command-palette.js` (the TUI's source of truth) so the Web and
6
+ * πŸ“– TUI palettes always show the same set of commands. Web-only commands
7
+ * πŸ“– (open modal pages, set the theme, open the help / changelog / etc.) are
8
+ * πŸ“– appended on top of the TUI list.
9
+ *
10
+ * πŸ“– The palette is a flat searchable list β€” the TUI's hierarchical category
11
+ * πŸ“– tree is preserved as a leading "section" label per entry. We fuzzy-search
12
+ * πŸ“– using the TUI's `filterCommandPaletteEntries()` so ranking is identical
13
+ * πŸ“– across both surfaces.
8
14
  *
9
15
  * @functions
10
- * β†’ CommandPalette β€” main modal component
16
+ * β†’ CommandPalette β†’ main modal component
11
17
  */
12
- import { useEffect, useMemo, useRef, useState } from 'react'
13
- import { IconSearch, IconCommand, IconBolt, IconArrowRight, IconArrowsExchange } from '@tabler/icons-react'
18
+ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
19
+ import { IconSearch, IconCommand, IconBolt, IconArrowsExchange, IconExternalLink } from '@tabler/icons-react'
20
+ import { buildCommandPaletteEntries, filterCommandPaletteEntries } from '../../../../src/tui/command-palette.js'
14
21
  import styles from './CommandPalette.module.css'
15
22
 
23
+ const SECTION_META = {
24
+ // TUI categories β†’ emoji + label
25
+ 'filter': { icon: 'πŸ”', label: 'Filter' },
26
+ 'sort': { icon: 'πŸ“Ά', label: 'Sort' },
27
+ 'action': { icon: 'βš™οΈ', label: 'Action' },
28
+ 'page': { icon: 'πŸ“„', label: 'Page' },
29
+ 'update': { icon: '⬆️', label: 'Update' },
30
+ // TUI 'tool' sub-categories
31
+ 'tool': { icon: '🧰', label: 'Tool' },
32
+ }
33
+
16
34
  const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
17
35
 
36
+ // πŸ“– Web-only entries (the Web has modals, not TUI overlays, so the palette
37
+ // πŸ“– has a few commands the TUI doesn't). They are appended to the TUI registry
38
+ // πŸ“– after a `Web` separator so the two surfaces stay easy to compare.
39
+ function buildWebEntries(deps) {
40
+ const { onCycleTheme, onResetView, onSetPingMode, onOpenChangelog, theme, pingMode, modelsCount, onExport } = deps
41
+ const web = [
42
+ // Pages (open as modals)
43
+ { id: 'page.help', section: 'page', label: 'Open Help', keywords: ['help', 'shortcuts', 'reference'], run: () => deps.onOpenHelp?.() },
44
+ { id: 'page.changelog', section: 'page', label: 'Open Changelog', keywords: ['changelog', 'release', 'history', 'version'], run: () => onOpenChangelog?.() },
45
+ { id: 'page.install-endpoints', section: 'page', label: 'Open Install Endpoints (M4)', keywords: ['install', 'endpoint', 'tool', 'configure'], disabled: true },
46
+ { id: 'page.installed-models', section: 'page', label: 'Open Installed Models (M4)', keywords: ['installed', 'models', 'tools'], disabled: true },
47
+
48
+ // Theme
49
+ { id: 'action.theme.cycle', section: 'action', label: `Cycle theme (current: ${theme})`, keywords: ['theme', 'dark', 'light', 'auto'], run: onCycleTheme },
50
+
51
+ // Reset
52
+ { id: 'action.reset.view', section: 'action', label: 'Reset view (filters + sort)', keywords: ['reset', 'view', 'clear', 'filters', 'sort'], run: onResetView },
53
+
54
+ // Ping mode (the TUI has these too; Web keeps them for parity)
55
+ { id: 'action.ping.speed', section: 'action', label: 'Ping mode β†’ Speed (2s)', keywords: ['ping', 'mode', 'speed', 'fast', '2s'], run: () => onSetPingMode?.('speed') },
56
+ { id: 'action.ping.normal', section: 'action', label: 'Ping mode β†’ Normal (10s)', keywords: ['ping', 'mode', 'normal', '10s', 'default'], run: () => onSetPingMode?.('normal') },
57
+ { id: 'action.ping.slow', section: 'action', label: 'Ping mode β†’ Slow (30s)', keywords: ['ping', 'mode', 'slow', '30s', 'idle'], run: () => onSetPingMode?.('slow') },
58
+ { id: 'action.ping.forced', section: 'action', label: 'Ping mode β†’ Forced (4s)', keywords: ['ping', 'mode', 'forced', '4s'], run: () => onSetPingMode?.('forced') },
59
+
60
+ // Export
61
+ { id: 'action.export', section: 'action', label: 'Export models…', keywords: ['export', 'download', 'json', 'csv', 'clipboard'], run: onExport },
62
+ ]
63
+ // TUI palette entries with a 'page' type β†’ route through the same Web pages.
64
+ return web
65
+ }
66
+
18
67
  export default function CommandPalette({
19
68
  onClose, onNavigate, onCycleTheme, onResetView,
20
- onSetPingMode, onToast, onExport,
21
- currentView, theme, pingMode,
69
+ onSetPingMode, onOpenHelp, onOpenChangelog, onOpenCommandPalette,
70
+ onToast, onExport, currentView, theme, pingMode, models,
71
+ updateAvailable, latestVersion, onRunUpdate,
22
72
  }) {
23
73
  const [query, setQuery] = useState('')
24
74
  const [cursor, setCursor] = useState(0)
25
75
  const inputRef = useRef(null)
26
76
  const listRef = useRef(null)
27
77
 
28
- // πŸ“– Static command list for M1 β€” these are the universal "no matter what view
29
- // πŸ“– I'm in, this is what I want" actions. M2 will replace this with the TUI
30
- // πŸ“– registry import.
31
- const commands = useMemo(() => {
32
- const items = [
33
- { id: 'view.dashboard', label: 'Go to Dashboard', icon: IconArrowRight, run: () => onNavigate('dashboard') },
34
- { id: 'view.settings', label: 'Go to Settings', icon: IconArrowRight, run: () => onNavigate('settings') },
35
- { id: 'view.analytics', label: 'Go to Analytics', icon: IconArrowRight, run: () => onNavigate('analytics') },
36
- { id: 'view.recommend', label: 'Open Smart Recommend (coming in M3)', icon: IconArrowRight, disabled: true },
37
- { id: 'view.router', label: 'Open Router Dashboard (coming in M4)', icon: IconArrowRight, disabled: true },
38
- { id: 'view.help', label: 'Open Help (coming in M2)', icon: IconArrowRight, disabled: true },
39
- { id: 'view.changelog', label: 'Open Changelog (coming in M2)', icon: IconArrowRight, disabled: true },
40
- { id: 'action.cycle-theme', label: `Cycle theme (current: ${theme})`, icon: IconArrowsExchange, run: onCycleTheme },
41
- { id: 'action.reset-view', label: 'Reset view (filters + sort)', icon: IconArrowsExchange, run: onResetView },
42
- { id: 'action.ping.speed', label: 'Ping mode β†’ Speed (2s)', icon: IconBolt, run: () => onSetPingMode('speed') },
43
- { id: 'action.ping.normal', label: 'Ping mode β†’ Normal (10s)', icon: IconBolt, run: () => onSetPingMode('normal') },
44
- { id: 'action.ping.slow', label: 'Ping mode β†’ Slow (30s)', icon: IconBolt, run: () => onSetPingMode('slow') },
45
- { id: 'action.ping.forced', label: 'Ping mode β†’ Forced (4s)', icon: IconBolt, run: () => onSetPingMode('forced') },
46
- { id: 'action.export', label: 'Export models…', icon: IconArrowRight, run: onExport },
47
- ]
48
- return items
49
- }, [onNavigate, onCycleTheme, onResetView, onSetPingMode, onExport, theme])
50
-
51
- // πŸ“– Fuzzy filter: substring match (case-insensitive). Good enough for M1's small set.
78
+ // πŸ“– Build the combined list once per render. TUI registry first (so
79
+ // πŸ“– its section labels come through), then Web-only entries.
80
+ const allCommands = useMemo(() => {
81
+ const tuiEntries = buildCommandPaletteEntries(models || []).map((entry) => {
82
+ // πŸ“– The TUI palette doesn't carry a 'section' field; derive one from
83
+ // πŸ“– the entry id so the Web list can show a category label.
84
+ let section = 'action'
85
+ if (entry.id.startsWith('filter-')) section = 'filter'
86
+ else if (entry.id.startsWith('sort-')) section = 'sort'
87
+ else if (entry.id.startsWith('action-')) section = 'action'
88
+ else if (entry.id.startsWith('action-set-tool-')) section = 'tool'
89
+ else if (entry.id.startsWith('page-') || entry.id === 'open-settings' || entry.id === 'open-help' || entry.id === 'open-changelog') section = 'page'
90
+ return { ...entry, section }
91
+ })
92
+ const webEntries = buildWebEntries({
93
+ onCycleTheme, onResetView, onSetPingMode,
94
+ onOpenChangelog, onOpenHelp, theme, pingMode, modelsCount: models?.length ?? 0, onExport,
95
+ })
96
+ // πŸ“– Update banner entry (only when an update is available) β€” mirrors
97
+ // πŸ“– the TUI palette's "auto-prepended when newer version known" rule.
98
+ const updateEntries = []
99
+ if (updateAvailable && latestVersion) {
100
+ updateEntries.push({
101
+ id: 'action.update.run', section: 'update',
102
+ label: `⬆️ UPDATE NOW β€” v${latestVersion} available (recommended!)`,
103
+ keywords: ['update', 'upgrade', 'version', 'install', 'new'],
104
+ run: () => onRunUpdate?.(),
105
+ })
106
+ }
107
+ return [...updateEntries, ...tuiEntries, ...webEntries]
108
+ }, [models, theme, pingMode, onCycleTheme, onResetView, onSetPingMode, onOpenChangelog, onOpenHelp, onExport, updateAvailable, latestVersion, onRunUpdate])
109
+
110
+ // πŸ“– Filter using the TUI's exact fuzzy rank so the two palettes are 1:1.
111
+ // πŸ“– Empty query β†’ return everything; non-empty β†’ return ranked matches.
52
112
  const filtered = useMemo(() => {
53
- const q = query.trim().toLowerCase()
54
- if (!q) return commands
55
- return commands.filter((cmd) => cmd.label.toLowerCase().includes(q))
56
- }, [commands, query])
113
+ if (!query.trim()) return allCommands
114
+ return filterCommandPaletteEntries(allCommands, query)
115
+ }, [allCommands, query])
116
+
117
+ // πŸ“– Group filtered commands by section so the list reads top-down in
118
+ // πŸ“– the same order as the TUI's hierarchical view.
119
+ const grouped = useMemo(() => {
120
+ const groups = new Map()
121
+ for (const cmd of filtered) {
122
+ if (!groups.has(cmd.section)) groups.set(cmd.section, [])
123
+ groups.get(cmd.section).push(cmd)
124
+ }
125
+ return Array.from(groups.entries())
126
+ }, [filtered])
127
+
128
+ // πŸ“– Build a flat list for keyboard navigation. Cursor is an index into
129
+ // πŸ“– this flat list, not the grouped structure.
130
+ const flatList = useMemo(() => grouped.flatMap(([, items]) => items), [grouped])
57
131
 
58
- // πŸ“– Focus the search input on open.
59
132
  useEffect(() => {
60
133
  inputRef.current?.focus()
61
134
  }, [])
62
135
 
63
- // πŸ“– Keyboard handler β€” Esc, arrows, Enter.
136
+ // πŸ“– Clamp cursor whenever the result set changes.
137
+ useEffect(() => {
138
+ if (cursor >= flatList.length) setCursor(0)
139
+ }, [flatList, cursor])
140
+
141
+ // πŸ“– Keyboard handler: Esc, arrows, Enter, Tab (expand/collapse on TUI;
142
+ // πŸ“– the Web list is flat so Tab cycles focus to the next button β€” and
143
+ // πŸ“– Enter runs the highlighted command).
64
144
  useEffect(() => {
65
145
  const onKey = (e) => {
66
146
  if (e.key === 'Escape') { e.preventDefault(); onClose(); return }
67
147
  if (e.key === 'ArrowDown') {
68
148
  e.preventDefault()
69
- setCursor((c) => Math.min(filtered.length - 1, c + 1))
149
+ setCursor((c) => Math.min(flatList.length - 1, c + 1))
70
150
  return
71
151
  }
72
152
  if (e.key === 'ArrowUp') {
@@ -76,21 +156,77 @@ export default function CommandPalette({
76
156
  }
77
157
  if (e.key === 'Enter') {
78
158
  e.preventDefault()
79
- const item = filtered[cursor]
80
- if (item && !item.disabled) {
81
- item.run?.()
82
- onClose()
83
- } else if (item?.disabled) {
84
- onToast?.(`${item.label} is not available yet.`, 'info')
85
- }
159
+ const item = flatList[cursor]
160
+ if (!item) return
161
+ if (item.disabled) { onToast?.(`${item.label} is not available yet.`, 'info'); return }
162
+ runCommand(item)
86
163
  }
87
164
  }
88
165
  document.addEventListener('keydown', onKey)
89
166
  return () => document.removeEventListener('keydown', onKey)
90
- }, [filtered, cursor, onClose, onToast])
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [flatList, cursor, onClose, onToast])
169
+
170
+ // πŸ“– When a command runs, the palette closes. The handler lives in a
171
+ // πŸ“– useCallback so the keyboard handler doesn't recreate on every render.
172
+ const runCommand = useCallback((item) => {
173
+ // πŸ“– TUI registry entries carry the action in their own `id` form. We
174
+ // πŸ“– translate the most common ones to the Web's React callbacks.
175
+ // πŸ“– For entries that don't have a Web equivalent (e.g. tool cycle, which
176
+ // πŸ“– ships in M3), we route the user to the right header menu.
177
+ const id = item.id
178
+ if (id === 'open-settings') { onNavigate?.('settings'); onClose(); return }
179
+ if (id === 'open-help') { onOpenHelp?.(); onClose(); return }
180
+ if (id === 'open-changelog') { onOpenChangelog?.(); onClose(); return }
181
+ if (id === 'open-recommend') { onToast?.('Recommend arrives in M3', 'info'); onClose(); return }
182
+ if (id === 'open-router-dashboard') { onToast?.('Router dashboard arrives in M4', 'info'); onClose(); return }
183
+ if (id === 'open-installed-models') { onToast?.('Installed Models arrives in M4', 'info'); onClose(); return }
184
+ if (id === 'open-install-endpoints') { onToast?.('Install Endpoints arrives in M4', 'info'); onClose(); return }
185
+ if (id === 'action-update-now') { onRunUpdate?.(); onClose(); return }
186
+ if (id === 'action-cycle-theme') { onCycleTheme?.(); onClose(); return }
187
+ if (id === 'action-reset-view') { onResetView?.(); onClose(); return }
188
+ if (id === 'action-cycle-ping-mode') {
189
+ const idx = PING_MODE_CYCLE.indexOf(pingMode)
190
+ onSetPingMode?.(PING_MODE_CYCLE[(idx + 1) % PING_MODE_CYCLE.length])
191
+ onClose()
192
+ return
193
+ }
194
+ if (id === 'action-cycle-tool-mode') { onToast?.('Tool mode picker arrives in M3', 'info'); onClose(); return }
195
+ if (id === 'action-toggle-favorite') { onToast?.('Press F on a model row in the table.', 'info'); onClose(); return }
196
+ if (id === 'action-toggle-favorite-mode') { onToast?.('Set the favorites display mode in Settings (M2).', 'info'); onClose(); return }
197
+ if (id === 'action-export') { onExport?.(); onClose(); return }
198
+ if (id === 'action-benchmark-row') { onToast?.('Click any AI Lat. cell to benchmark the highlighted row.', 'info'); onClose(); return }
91
199
 
92
- // πŸ“– Reset cursor when query changes.
93
- useEffect(() => { setCursor(0) }, [query])
200
+ // πŸ“– Filter / sort / ping mode commands are dispatched to the same Web
201
+ // πŸ“– callbacks the TUI palette uses (cycle / set). For per-model filter
202
+ // πŸ“– entries the user typed, we just apply a text filter.
203
+ if (id === 'filter-provider-cycle') { onToast?.('Use the Provider dropdown in the FilterBar.', 'info'); onClose(); return }
204
+ if (id.startsWith('filter-tier-')) { onToast?.('Tier filter β€” use the Tier chip row in the FilterBar.', 'info'); onClose(); return }
205
+ if (id.startsWith('filter-provider-')) { onToast?.('Provider filter β€” use the Provider dropdown.', 'info'); onClose(); return }
206
+ if (id === 'filter-configured-toggle') { onToast?.('Use the Visibility dropdown in the FilterBar.', 'info'); onClose(); return }
207
+ if (id.startsWith('sort-')) { onToast?.('Sort β€” click the column header in the table.', 'info'); onClose(); return }
208
+ if (id === 'action-set-ping-speed') { onSetPingMode?.('speed'); onClose(); return }
209
+ if (id === 'action-set-ping-normal') { onSetPingMode?.('normal'); onClose(); return }
210
+ if (id === 'action-set-ping-slow') { onSetPingMode?.('slow'); onClose(); return }
211
+ if (id === 'action-set-ping-forced') { onSetPingMode?.('forced'); onClose(); return }
212
+
213
+ // πŸ“– Fallback for any TUI command the Web hasn't wired yet.
214
+ if (typeof item.run === 'function') {
215
+ item.run()
216
+ onClose()
217
+ return
218
+ }
219
+ onToast?.(`${item.label} is not wired on the Web yet.`, 'info')
220
+ onClose()
221
+ }, [onNavigate, onClose, onToast, onCycleTheme, onResetView, onSetPingMode, onOpenHelp, onOpenChangelog, onRunUpdate, onExport, pingMode])
222
+
223
+ // πŸ“– Keep the focused item in view as the cursor moves. Mirrors the TUI
224
+ // πŸ“– palette's "follow the selection" behavior.
225
+ useEffect(() => {
226
+ if (!listRef.current) return
227
+ const el = listRef.current.querySelector(`[data-cmd-index="${cursor}"]`)
228
+ if (el) el.scrollIntoView({ block: 'nearest' })
229
+ }, [cursor])
94
230
 
95
231
  return (
96
232
  <div className={styles.backdrop} onClick={onClose}>
@@ -100,7 +236,7 @@ export default function CommandPalette({
100
236
  <input
101
237
  ref={inputRef}
102
238
  className={styles.input}
103
- placeholder="Type a command…"
239
+ placeholder="Type a command… (filters, sorts, tools, theme, ping, export)"
104
240
  value={query}
105
241
  onChange={(e) => setQuery(e.target.value)}
106
242
  autoComplete="off"
@@ -108,37 +244,55 @@ export default function CommandPalette({
108
244
  <span className={styles.kbd}><IconCommand size={10} stroke={1.5} />K</span>
109
245
  </div>
110
246
 
111
- <ul className={styles.list} ref={listRef} role="listbox">
112
- {filtered.length === 0 && (
113
- <li className={styles.empty}>No matching command.</li>
247
+ <div className={styles.list} ref={listRef} role="listbox">
248
+ {flatList.length === 0 && (
249
+ <div className={styles.empty}>No matching command.</div>
114
250
  )}
115
- {filtered.map((cmd, idx) => {
116
- const Icon = cmd.icon
251
+ {grouped.map(([section, items]) => {
252
+ const meta = SECTION_META[section] || { icon: 'β€’', label: section }
117
253
  return (
118
- <li
119
- key={cmd.id}
120
- className={`${styles.item} ${idx === cursor ? styles.itemActive : ''} ${cmd.disabled ? styles.itemDisabled : ''}`}
121
- onClick={() => {
122
- if (cmd.disabled) { onToast?.(`${cmd.label} is not available yet.`, 'info'); return }
123
- cmd.run?.()
124
- onClose()
125
- }}
126
- onMouseEnter={() => setCursor(idx)}
127
- role="option"
128
- aria-selected={idx === cursor}
129
- >
130
- <Icon size={14} stroke={1.5} className={styles.itemIcon} />
131
- <span className={styles.itemLabel}>{cmd.label}</span>
132
- {idx === cursor && <span className={styles.itemEnter}>↡</span>}
133
- </li>
254
+ <div key={section} className={styles.section}>
255
+ <div className={styles.sectionHeader}>
256
+ <span className={styles.sectionIcon}>{meta.icon}</span>
257
+ <span>{meta.label}</span>
258
+ </div>
259
+ {items.map((cmd) => {
260
+ // πŸ“– Flat index across all groups so the keyboard cursor is stable.
261
+ const flatIdx = flatList.indexOf(cmd)
262
+ const isActive = flatIdx === cursor
263
+ return (
264
+ <button
265
+ key={cmd.id}
266
+ data-cmd-index={flatIdx}
267
+ className={`${styles.item} ${isActive ? styles.itemActive : ''} ${cmd.disabled ? styles.itemDisabled : ''}`}
268
+ onClick={() => runCommand(cmd)}
269
+ onMouseEnter={() => setCursor(flatIdx)}
270
+ role="option"
271
+ aria-selected={isActive}
272
+ aria-disabled={cmd.disabled ? 'true' : undefined}
273
+ >
274
+ <span className={styles.itemLabel}>
275
+ {cmd.label}
276
+ {cmd.shortcut && <span className={styles.shortcut}>{cmd.shortcut}</span>}
277
+ </span>
278
+ {cmd.description && <span className={styles.itemDesc}>{cmd.description}</span>}
279
+ {isActive && <span className={styles.itemEnter}>↡</span>}
280
+ </button>
281
+ )
282
+ })}
283
+ </div>
134
284
  )
135
285
  })}
136
- </ul>
286
+ </div>
137
287
 
138
288
  <div className={styles.footer}>
139
289
  <span><kbd>↑↓</kbd> navigate</span>
140
290
  <span><kbd>↡</kbd> select</span>
141
291
  <span><kbd>Esc</kbd> close</span>
292
+ <span className={styles.footerRight}>
293
+ Powered by the TUI command registry β€” 1:1 parity
294
+ <IconExternalLink size={10} stroke={1.5} style={{ marginLeft: 4 }} />
295
+ </span>
142
296
  </div>
143
297
  </div>
144
298
  </div>