free-coding-models 0.5.6 → 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 (48) hide show
  1. package/README.md +14 -9
  2. package/changelog/v0.5.7.md +20 -0
  3. package/package.json +1 -1
  4. package/src/core/endpoint-installer.js +3 -3
  5. package/src/core/tool-launchers.js +2 -2
  6. package/web/dist/assets/index-DKmmiDip.css +1 -0
  7. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  8. package/web/dist/index.html +2 -2
  9. package/web/server.js +386 -1
  10. package/web/src/App.jsx +145 -26
  11. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  12. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  13. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  14. package/web/src/components/atoms/TierBadge.module.css +3 -3
  15. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  16. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  17. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  18. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  19. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  20. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  21. package/web/src/components/help/HelpView.jsx +1 -1
  22. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  23. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  24. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  25. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  26. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  27. package/web/src/components/launch/LaunchButton.jsx +30 -0
  28. package/web/src/components/launch/LaunchButton.module.css +35 -0
  29. package/web/src/components/launch/LaunchModal.module.css +125 -0
  30. package/web/src/components/layout/Header.jsx +70 -11
  31. package/web/src/components/layout/Header.module.css +62 -2
  32. package/web/src/components/recommend/RecommendView.jsx +121 -0
  33. package/web/src/components/recommend/RecommendView.module.css +55 -0
  34. package/web/src/components/router/RouterView.jsx +286 -0
  35. package/web/src/components/router/RouterView.module.css +357 -0
  36. package/web/src/components/tools/ToolPicker.jsx +86 -0
  37. package/web/src/components/tools/ToolPicker.module.css +84 -0
  38. package/web/src/global.css +23 -0
  39. package/web/src/hooks/urlState.constants.js +3 -0
  40. package/web/src/hooks/useInstalledModels.js +42 -0
  41. package/web/src/hooks/useRecommend.js +67 -0
  42. package/web/src/hooks/useRouterDashboard.js +133 -0
  43. package/web/src/hooks/useTokenUsage.js +103 -0
  44. package/web/src/hooks/useToolMode.js +77 -0
  45. package/web/src/hooks/useUrlState.js +4 -3
  46. package/web/src/utils/m3.js +55 -0
  47. package/web/dist/assets/index-Blp9QJev.js +0 -39
  48. package/web/dist/assets/index-Cz_aCLTR.css +0 -1
package/web/src/App.jsx CHANGED
@@ -12,6 +12,7 @@
12
12
  * 📖 - UpdateChip in header (polls /api/version, popover with "Update now" + "What's new")
13
13
  * 📖 - URL write-back (every filter / sort / view / palette / toolMode change
14
14
  * 📖 updates the URL via history.replaceState, debounced at 80ms)
15
+ * 📖 - M3 Web endpoint install flow: writes tool config only; never starts CLIs.
15
16
  * 📖 - New Settings rows: theme dropdown, favorites mode toggle, startup AI scan
16
17
  * 📖 toggle, shell-env toggle, legacy proxy cleanup button, open Changelog link,
17
18
  * 📖 update status row, per-provider test key button
@@ -23,6 +24,7 @@ import { useSocket } from './hooks/useSocket.js'
23
24
  import { useFilter } from './hooks/useFilter.js'
24
25
  import { useTheme } from './hooks/useTheme.js'
25
26
  import { useFavorites } from './hooks/useFavorites.js'
27
+ import { useToolMode } from './hooks/useToolMode.js'
26
28
  import { useUrlState } from './hooks/useUrlState.js'
27
29
  import { useUpdateChecker } from './hooks/useUpdateChecker.js'
28
30
  import Header from './components/layout/Header.jsx'
@@ -37,7 +39,13 @@ import CommandPalette from './components/palette/CommandPalette.jsx'
37
39
  import HelpView from './components/help/HelpView.jsx'
38
40
  import ChangelogView from './components/changelog/ChangelogView.jsx'
39
41
  import UpdateChip from './components/update/UpdateChip.jsx'
42
+ import RecommendView from './components/recommend/RecommendView.jsx'
43
+ import IncompatibleFallbackModal from './components/launch/IncompatibleFallbackModal.jsx'
44
+ import RouterView from './components/router/RouterView.jsx'
45
+ import InstalledModelsView from './components/installed/InstalledModelsView.jsx'
46
+ import InstallEndpointsView from './components/install/InstallEndpointsView.jsx'
40
47
  import ToastContainer from './components/atoms/ToastContainer.jsx'
48
+ import { isModelCompatibleWithTool } from '../../src/core/tool-metadata.js'
41
49
 
42
50
  let toastIdCounter = 0
43
51
 
@@ -59,10 +67,27 @@ export default function App() {
59
67
  const [helpOpen, setHelpOpen] = useState(false)
60
68
  const [changelogOpen, setChangelogOpen] = useState(false)
61
69
  const [changelogDefaultVersion, setChangelogDefaultVersion] = useState(null)
62
- const [toolMode, setToolMode] = useState(null) // 📖 M3 will use this; the row highlighter already keys off it
70
+ const [recommendOpen, setRecommendOpen] = useState(false)
71
+ const [routerOpen, setRouterOpen] = useState(false)
72
+ const [installedModelsOpen, setInstalledModelsOpen] = useState(false)
73
+ const [installEndpointsOpen, setInstallEndpointsOpen] = useState(false)
74
+ const [incompatibleRequest, setIncompatibleRequest] = useState(null)
63
75
  const [toasts, setToasts] = useState([])
64
76
  const lastActivityRef = useRef(Date.now())
65
77
 
78
+ // ── Toast helpers ────────────────────────────────────────────────────────
79
+ const addToast = useCallback((message, type = 'info') => {
80
+ const id = ++toastIdCounter
81
+ setToasts((prev) => [...prev, { id, message, type }])
82
+ setTimeout(() => {
83
+ setToasts((prev) => prev.filter((t) => t.id !== id))
84
+ }, 4000)
85
+ }, [])
86
+
87
+ const dismissToast = useCallback((id) => {
88
+ setToasts((prev) => prev.filter((t) => t.id !== id))
89
+ }, [])
90
+
66
91
  const {
67
92
  filtered,
68
93
  filterTier, setFilterTier,
@@ -77,6 +102,8 @@ export default function App() {
77
102
  resetView,
78
103
  } = useFilter(models)
79
104
 
105
+ const { toolMode, setToolMode, cycleToolMode } = useToolMode({ onToast: addToast })
106
+
80
107
  // 📖 URL deep-linking (M2 = read + write). Hydrates on mount, then pushes
81
108
  // 📖 every change back via history.replaceState (debounced 80ms).
82
109
  useUrlState({
@@ -99,10 +126,47 @@ export default function App() {
99
126
  // 📖 Favorites — single source of truth shared with the TUI.
100
127
  const favorites = useFavorites({ models })
101
128
 
129
+ // ── M3 endpoint flow: compat guard → /api/install-endpoint → fallback modal ──
130
+ const handleInstallEndpoint = useCallback(async (model, overrideMode = null) => {
131
+ const mode = overrideMode || toolMode || 'opencode'
132
+ if (!model) return
133
+ if (!isModelCompatibleWithTool(model.providerKey, mode)) {
134
+ setIncompatibleRequest({ model, toolMode: mode })
135
+ return
136
+ }
137
+ try {
138
+ const resp = await fetch('/api/install-endpoint', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ providerKey: model.providerKey, modelId: model.modelId, toolMode: mode }),
142
+ })
143
+ const payload = await resp.json().catch(() => ({}))
144
+ if (resp.status === 422 && payload.code === 'incompatible_model') {
145
+ setIncompatibleRequest({ model, toolMode: mode })
146
+ return
147
+ }
148
+ if (!resp.ok) throw new Error(payload.error || `HTTP ${resp.status}`)
149
+ addToast(`Endpoint installed for ${model.label} in ${mode}. Start the tool yourself.`, 'success')
150
+ } catch (err) {
151
+ addToast(`Endpoint install failed: ${err.message}`, 'error')
152
+ }
153
+ }, [addToast, toolMode])
154
+
155
+ const handleSwitchToolAndInstall = useCallback(async (mode, model) => {
156
+ await setToolMode(mode)
157
+ setIncompatibleRequest(null)
158
+ await handleInstallEndpoint(model, mode)
159
+ }, [handleInstallEndpoint, setToolMode])
160
+
161
+ const handlePinAndInstall = useCallback(async (model) => {
162
+ if (!favorites.isFavorite(model)) await favorites.toggle(model)
163
+ await handleInstallEndpoint(model)
164
+ }, [favorites, handleInstallEndpoint])
165
+
102
166
  // 📖 Update checker (5-minute poll). Returns `updateAvailable` for the chip.
103
167
  const {
104
168
  localVersion, latestVersion, updateAvailable, runUpdate, checkNow, error: updateError,
105
- } = useUpdateChecker({ onToast: addToastInternal })
169
+ } = useUpdateChecker({ onToast: addToast })
106
170
 
107
171
  // 📖 Build the provider list for the FilterBar dropdown.
108
172
  const providers = useMemo(() => {
@@ -140,25 +204,12 @@ export default function App() {
140
204
  })
141
205
  if (!resp.ok && resp.status !== 202) {
142
206
  const err = await resp.json().catch(() => ({}))
143
- addToastInternal?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
207
+ addToast(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
144
208
  }
145
209
  } catch (err) {
146
210
  console.error('[Benchmark] per-row failed:', err.message)
147
211
  }
148
- }, [])
149
-
150
- // ── Toast helpers ────────────────────────────────────────────────────────
151
- function addToastInternal(message, type = 'info') {
152
- const id = ++toastIdCounter
153
- setToasts((prev) => [...prev, { id, message, type }])
154
- setTimeout(() => {
155
- setToasts((prev) => prev.filter((t) => t.id !== id))
156
- }, 4000)
157
- }
158
- const addToast = useCallback(addToastInternal, [])
159
- const dismissToast = useCallback((id) => {
160
- setToasts((prev) => prev.filter((t) => t.id !== id))
161
- }, [])
212
+ }, [addToast])
162
213
 
163
214
  // ── Selection / detail panel ─────────────────────────────────────────────
164
215
  const handleSelectModel = useCallback((model) => {
@@ -180,10 +231,10 @@ export default function App() {
180
231
  // 📖 toasts (M3/M4) — they don't switch the currentView.
181
232
  if (viewId === 'help') { setHelpOpen(true); return }
182
233
  if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
183
- if (viewId === 'recommend') { addToast?.('Smart Recommend arrives in M3', 'info'); return }
184
- if (viewId === 'router') { addToast?.('Router dashboard arrives in M4', 'info'); return }
185
- if (viewId === 'install-endpoints') { addToast?.('Install Endpoints arrives in M4', 'info'); return }
186
- if (viewId === 'installed-models') { addToast?.('Installed Models arrives in M4', 'info'); return }
234
+ if (viewId === 'recommend') { setRecommendOpen(true); return }
235
+ if (viewId === 'router') { setRouterOpen(true); return }
236
+ if (viewId === 'install-endpoints') { setInstallEndpointsOpen(true); return }
237
+ if (viewId === 'installed-models') { setInstalledModelsOpen(true); return }
187
238
  setCurrentView(VIEW_TO_NAV[viewId] || viewId)
188
239
  lastActivityRef.current = Date.now()
189
240
  }, [addToast])
@@ -192,8 +243,8 @@ export default function App() {
192
243
  const handleResetView = useCallback(() => {
193
244
  resetView()
194
245
  setSearchQuery('')
195
- addToastInternal('View reset to defaults.', 'info')
196
- }, [resetView, setSearchQuery])
246
+ addToast('View reset to defaults.', 'info')
247
+ }, [addToast, resetView, setSearchQuery])
197
248
 
198
249
  // ── Changelog open with optional version (e.g. from UpdateChip "What's new") ─
199
250
  const openChangelogAt = useCallback((version) => {
@@ -219,13 +270,25 @@ export default function App() {
219
270
  if (paletteOpen) { setPaletteOpen(false); return }
220
271
  if (helpOpen) { setHelpOpen(false); return }
221
272
  if (changelogOpen) { setChangelogOpen(false); return }
273
+ if (recommendOpen) { setRecommendOpen(false); return }
274
+ if (routerOpen) { setRouterOpen(false); return }
275
+ if (installEndpointsOpen) { setInstallEndpointsOpen(false); return }
276
+ if (installedModelsOpen) { setInstalledModelsOpen(false); return }
277
+ if (incompatibleRequest) { setIncompatibleRequest(null); return }
222
278
  if (selectedModel) { setSelectedModel(null); return }
223
279
  if (exportOpen) { setExportOpen(false); return }
224
280
  }
225
281
  }
226
282
  window.addEventListener('keydown', handler)
227
283
  return () => window.removeEventListener('keydown', handler)
228
- }, [paletteOpen, helpOpen, changelogOpen, selectedModel, exportOpen])
284
+ }, [paletteOpen, helpOpen, changelogOpen, recommendOpen, routerOpen, installEndpointsOpen, installedModelsOpen, incompatibleRequest, selectedModel, exportOpen])
285
+
286
+ useEffect(() => {
287
+ if (currentView === 'recommend') {
288
+ setRecommendOpen(true)
289
+ setCurrentView('dashboard')
290
+ }
291
+ }, [currentView])
229
292
 
230
293
  return (
231
294
  <>
@@ -245,6 +308,9 @@ export default function App() {
245
308
  modelsCount={filtered.length}
246
309
  theme={theme}
247
310
  onToast={addToast}
311
+ toolMode={toolMode}
312
+ onSetToolMode={setToolMode}
313
+ onCycleToolMode={cycleToolMode}
248
314
  updateSlot={
249
315
  <UpdateChip
250
316
  updateAvailable={updateAvailable}
@@ -257,7 +323,7 @@ export default function App() {
257
323
 
258
324
  <div className="app-content">
259
325
  {currentView === 'dashboard' && (
260
- <div className="view">
326
+ <main className="view">
261
327
  <FilterBar
262
328
  filterTier={filterTier}
263
329
  setFilterTier={setFilterTier}
@@ -283,18 +349,20 @@ export default function App() {
283
349
  globalBenchmarkRunning={globalBenchmarkRunning}
284
350
  globalBenchmarkTotal={globalBenchmarkTotal}
285
351
  globalBenchmarkCompleted={globalBenchmarkCompleted}
352
+ toolMode={toolMode}
286
353
  />
287
354
  <ModelTable
288
355
  filtered={filtered}
289
356
  onSelectModel={handleSelectModel}
290
357
  onBenchmarkRow={handleBenchmarkRow}
358
+ onLaunch={handleInstallEndpoint}
291
359
  favorites={favorites}
292
360
  sortColumn={sortColumn}
293
361
  sortDirection={sortDirection}
294
362
  onSort={toggleSort}
295
363
  toolMode={toolMode}
296
364
  />
297
- </div>
365
+ </main>
298
366
  )}
299
367
 
300
368
  {currentView === 'settings' && (
@@ -322,6 +390,12 @@ export default function App() {
322
390
  onClose={handleCloseDetail}
323
391
  favorites={favorites}
324
392
  onBenchmark={handleBenchmarkRow}
393
+ onLaunch={handleInstallEndpoint}
394
+ toolMode={toolMode}
395
+ onSetToolMode={setToolMode}
396
+ onCycleToolMode={cycleToolMode}
397
+ models={models}
398
+ onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
325
399
  onToast={addToast}
326
400
  />
327
401
 
@@ -354,6 +428,30 @@ export default function App() {
354
428
  />
355
429
  )}
356
430
 
431
+ {recommendOpen && (
432
+ <RecommendView
433
+ onClose={() => setRecommendOpen(false)}
434
+ toolMode={toolMode}
435
+ onLaunch={handleInstallEndpoint}
436
+ onPinAndLaunch={handlePinAndInstall}
437
+ onToast={addToast}
438
+ />
439
+ )}
440
+
441
+ {incompatibleRequest && (
442
+ <IncompatibleFallbackModal
443
+ request={incompatibleRequest}
444
+ models={models}
445
+ onClose={() => setIncompatibleRequest(null)}
446
+ onSwitchToolLaunch={handleSwitchToolAndInstall}
447
+ onLaunchSimilar={(candidate) => {
448
+ setIncompatibleRequest(null)
449
+ const model = models.find((m) => m.providerKey === candidate.providerKey && m.modelId === candidate.modelId) || candidate
450
+ void handleInstallEndpoint(model)
451
+ }}
452
+ />
453
+ )}
454
+
357
455
  {helpOpen && <HelpView onClose={() => setHelpOpen(false)} />}
358
456
  {changelogOpen && (
359
457
  <ChangelogView
@@ -362,6 +460,27 @@ export default function App() {
362
460
  />
363
461
  )}
364
462
 
463
+ {routerOpen && (
464
+ <RouterView
465
+ onClose={() => setRouterOpen(false)}
466
+ onToast={addToast}
467
+ />
468
+ )}
469
+
470
+ {installedModelsOpen && (
471
+ <InstalledModelsView
472
+ onClose={() => setInstalledModelsOpen(false)}
473
+ onToast={addToast}
474
+ />
475
+ )}
476
+
477
+ {installEndpointsOpen && (
478
+ <InstallEndpointsView
479
+ onClose={() => setInstallEndpointsOpen(false)}
480
+ onToast={addToast}
481
+ />
482
+ )}
483
+
365
484
  <ToastContainer toasts={toasts} dismissToast={dismissToast} />
366
485
  </>
367
486
  )
@@ -7,6 +7,7 @@
7
7
  import { useMemo } from 'react'
8
8
  import { IconActivity, IconTrophy } from '@tabler/icons-react'
9
9
  import TierBadge from '../atoms/TierBadge.jsx'
10
+ import TokenUsagePanel from './TokenUsagePanel.jsx'
10
11
  import styles from './AnalyticsView.module.css'
11
12
 
12
13
  // 📖 TIER_COLORS is now derived from CSS custom properties so the chart fills
@@ -138,6 +139,9 @@ export default function AnalyticsView({ models }) {
138
139
  </div>
139
140
  </div>
140
141
  </div>
142
+
143
+ {/* M4: Token Usage — always shown, displays "no data" if router hasn't been used */}
144
+ <TokenUsagePanel />
141
145
  </div>
142
146
  )
143
147
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file web/src/components/analytics/TokenUsagePanel.jsx
3
+ * @description Token Usage sub-section inside Analytics — 7-day chart, top models/providers.
4
+ * 📖 M4: Uses useTokenUsage hook, pure CSS bars (no charting library).
5
+ */
6
+ import { useTokenUsage } from '../../hooks/useTokenUsage.js'
7
+ import styles from './TokenUsagePanel.module.css'
8
+
9
+ function formatTokens(n) {
10
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
11
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
12
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
13
+ return String(Math.round(n))
14
+ }
15
+
16
+ export default function TokenUsagePanel() {
17
+ const { data, loading } = useTokenUsage()
18
+
19
+ if (loading) {
20
+ return (
21
+ <div className={styles.card}>
22
+ <h3 className={styles.cardTitle}>Token Usage</h3>
23
+ <div className={styles.empty}>Loading…</div>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ if (!data || !data.hasData) {
29
+ return (
30
+ <div className={styles.card}>
31
+ <h3 className={styles.cardTitle}>Token Usage</h3>
32
+ <div className={styles.empty}>No token data yet. Start the router and send requests to track usage.</div>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ const maxDayTokens = Math.max(...data.sevenDays.map(d => d.totalTokens), 1)
38
+
39
+ return (
40
+ <>
41
+ {/* Summary cards */}
42
+ <div className={styles.summaryGrid}>
43
+ <div className={styles.summaryCard}>
44
+ <span className={styles.summaryLabel}>Today</span>
45
+ <span className={styles.summaryValue}>{formatTokens(data.today.totalTokens)}</span>
46
+ <span className={styles.summarySub}>{data.today.requests} requests</span>
47
+ </div>
48
+ <div className={styles.summaryCard}>
49
+ <span className={styles.summaryLabel}>All Time</span>
50
+ <span className={styles.summaryValue}>{formatTokens(data.allTime.totalTokens)}</span>
51
+ <span className={styles.summarySub}>{data.allTime.requests} requests</span>
52
+ </div>
53
+ </div>
54
+
55
+ {/* 7-day chart */}
56
+ <div className={styles.card}>
57
+ <h3 className={styles.cardTitle}>7-Day Usage</h3>
58
+ <div className={styles.chart}>
59
+ {data.sevenDays.map((day) => (
60
+ <div key={day.date} className={styles.chartBar}>
61
+ <div
62
+ className={styles.chartFill}
63
+ style={{ height: `${Math.max(2, (day.totalTokens / maxDayTokens) * 100)}%` }}
64
+ title={`${day.date}: ${formatTokens(day.totalTokens)} tokens`}
65
+ />
66
+ <span className={styles.chartLabel}>{day.label}</span>
67
+ </div>
68
+ ))}
69
+ </div>
70
+ </div>
71
+
72
+ {/* Top Models */}
73
+ {data.topModels.length > 0 && (
74
+ <div className={styles.card}>
75
+ <h3 className={styles.cardTitle}>Top Models</h3>
76
+ <div className={styles.topList}>
77
+ {data.topModels.map((m) => (
78
+ <div key={m.key} className={styles.topRow}>
79
+ <span className={styles.topKey}>{m.key}</span>
80
+ <span className={styles.topVal}>{formatTokens(m.total)} tokens</span>
81
+ <span className={styles.topReq}>{m.requests} req</span>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ </div>
86
+ )}
87
+
88
+ {/* Top Providers */}
89
+ {data.topProviders.length > 0 && (
90
+ <div className={styles.card}>
91
+ <h3 className={styles.cardTitle}>Top Providers</h3>
92
+ <div className={styles.topList}>
93
+ {data.topProviders.map((p) => (
94
+ <div key={p.key} className={styles.topRow}>
95
+ <span className={styles.topKey}>{p.key}</span>
96
+ <span className={styles.topVal}>{formatTokens(p.total)} tokens</span>
97
+ <span className={styles.topReq}>{p.requests} req</span>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ )}
103
+ </>
104
+ )
105
+ }
@@ -0,0 +1,126 @@
1
+ .card {
2
+ margin-top: 16px;
3
+ }
4
+
5
+ .cardTitle {
6
+ font-size: 13px;
7
+ font-weight: 600;
8
+ color: var(--text-secondary, #aaa);
9
+ margin: 0 0 10px 0;
10
+ text-transform: uppercase;
11
+ letter-spacing: 0.5px;
12
+ }
13
+
14
+ .empty {
15
+ color: var(--text-muted, #666);
16
+ font-size: 13px;
17
+ padding: 20px 0;
18
+ text-align: center;
19
+ }
20
+
21
+ /* Summary Grid */
22
+ .summaryGrid {
23
+ display: grid;
24
+ grid-template-columns: 1fr 1fr;
25
+ gap: 12px;
26
+ margin-top: 16px;
27
+ }
28
+
29
+ .summaryCard {
30
+ background: var(--bg-secondary, #14141f);
31
+ border: 1px solid var(--border, #1e1e2e);
32
+ border-radius: 8px;
33
+ padding: 14px 16px;
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: 2px;
37
+ }
38
+
39
+ .summaryLabel {
40
+ font-size: 11px;
41
+ font-weight: 600;
42
+ color: var(--text-muted, #888);
43
+ text-transform: uppercase;
44
+ letter-spacing: 0.5px;
45
+ }
46
+
47
+ .summaryValue {
48
+ font-size: 22px;
49
+ font-weight: 700;
50
+ color: var(--text-primary, #e0e0e0);
51
+ }
52
+
53
+ .summarySub {
54
+ font-size: 11px;
55
+ color: var(--text-muted, #888);
56
+ }
57
+
58
+ /* 7-Day Chart */
59
+ .chart {
60
+ display: flex;
61
+ align-items: flex-end;
62
+ gap: 6px;
63
+ height: 100px;
64
+ padding: 0 4px;
65
+ }
66
+
67
+ .chartBar {
68
+ flex: 1;
69
+ display: flex;
70
+ flex-direction: column;
71
+ align-items: center;
72
+ height: 100%;
73
+ justify-content: flex-end;
74
+ gap: 4px;
75
+ }
76
+
77
+ .chartFill {
78
+ width: 100%;
79
+ min-height: 2px;
80
+ background: linear-gradient(180deg, #00c864 0%, rgba(0, 200, 100, 0.3) 100%);
81
+ border-radius: 3px 3px 0 0;
82
+ transition: height 0.3s ease;
83
+ }
84
+
85
+ .chartLabel {
86
+ font-size: 10px;
87
+ color: var(--text-muted, #888);
88
+ text-transform: uppercase;
89
+ }
90
+
91
+ /* Top Lists */
92
+ .topList {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 4px;
96
+ }
97
+
98
+ .topRow {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 8px;
102
+ padding: 4px 0;
103
+ font-size: 12px;
104
+ }
105
+
106
+ .topKey {
107
+ flex: 1;
108
+ color: var(--text-primary, #e0e0e0);
109
+ font-family: 'SF Mono', monospace;
110
+ overflow: hidden;
111
+ text-overflow: ellipsis;
112
+ white-space: nowrap;
113
+ }
114
+
115
+ .topVal {
116
+ color: var(--text-muted, #888);
117
+ min-width: 80px;
118
+ text-align: right;
119
+ }
120
+
121
+ .topReq {
122
+ color: var(--text-muted, #666);
123
+ min-width: 50px;
124
+ text-align: right;
125
+ font-size: 11px;
126
+ }
@@ -13,9 +13,9 @@
13
13
  .tier_aplus { background: rgba(0,200,255,0.1); color: #00c8ff; border: 1px solid rgba(0,200,255,0.2); }
14
14
  .tier_a { background: rgba(61,220,132,0.1); color: #3ddc84; border: 1px solid rgba(61,220,132,0.2); }
15
15
  .tier_aminus{ background: rgba(126,207,126,0.1); color: #7ecf7e; border: 1px solid rgba(126,207,126,0.2); }
16
- .tier_bplus { background: rgba(168,168,200,0.1); color: #a8a8c8; border: 1px solid rgba(168,168,200,0.15); }
17
- .tier_b { background: rgba(128,128,152,0.1); color: #808098; border: 1px solid rgba(128,128,152,0.15); }
18
- .tier_c { background: rgba(96,96,120,0.1); color: #606078; border: 1px solid rgba(96,96,120,0.15); }
16
+ .tier_bplus { background: rgba(168,168,200,0.1); color: #b8b8d8; border: 1px solid rgba(168,168,200,0.15); }
17
+ .tier_b { background: rgba(128,128,152,0.1); color: #a0a0b8; border: 1px solid rgba(128,128,152,0.15); }
18
+ .tier_c { background: rgba(96,96,120,0.1); color: #909098; border: 1px solid rgba(96,96,120,0.15); }
19
19
 
20
20
  /* 📖 Light theme: darker, AA-contrast tier text colors. Borders stay the same
21
21
  📖 hue so the badge still feels "tier-colored", just more legible. */
@@ -7,14 +7,16 @@
7
7
  * 📖 action section (star + reorder + per-row AI Speed Test button).
8
8
  * @functions DetailPanel → main panel component, buildDetailChart → SVG chart builder
9
9
  */
10
- import { useMemo } from 'react'
11
- import { IconStar, IconStarFilled, IconChevronUp, IconChevronDown, IconPlayerPlayFilled } from '@tabler/icons-react'
10
+ import { IconStar, IconStarFilled, IconChevronUp, IconChevronDown, IconPlayerPlayFilled, IconAlertTriangle } from '@tabler/icons-react'
12
11
  import TierBadge from '../atoms/TierBadge.jsx'
13
12
  import VerdictBadge from '../atoms/VerdictBadge.jsx'
14
13
  import StatusDot from '../atoms/StatusDot.jsx'
15
14
  import StabilityCell from '../atoms/StabilityCell.jsx'
16
15
  import { formatPing, formatAvg, pingClass } from '../../utils/format.js'
17
16
  import { sweClass } from '../../utils/ranks.js'
17
+ import LaunchButton from '../launch/LaunchButton.jsx'
18
+ import ToolPicker from '../tools/ToolPicker.jsx'
19
+ import { getToolMeta, isModelCompatibleWithTool } from '../../../../src/core/tool-metadata.js'
18
20
  import styles from './DetailPanel.module.css'
19
21
 
20
22
  function buildDetailChart(history) {
@@ -70,7 +72,10 @@ function StatRow({ label, children }) {
70
72
  )
71
73
  }
72
74
 
73
- export default function DetailPanel({ model, onClose, favorites, onBenchmark, onToast }) {
75
+ export default function DetailPanel({
76
+ model, onClose, favorites, onBenchmark, onLaunch, onToast,
77
+ toolMode = 'opencode', onSetToolMode, onCycleToolMode, onOpenFallback,
78
+ }) {
74
79
  if (!model) return null
75
80
 
76
81
  const chartSvg = buildDetailChart(model.pingHistory)
@@ -78,6 +83,8 @@ export default function DetailPanel({ model, onClose, favorites, onBenchmark, on
78
83
  const favRank = favorites?.favoriteRank(model) ?? Number.MAX_SAFE_INTEGER
79
84
  const isFavAtTop = isFav && favRank === 0
80
85
  const isFavAtBottom = isFav && favRank === (favorites?.favorites.length ?? 0) - 1
86
+ const toolMeta = getToolMeta(toolMode)
87
+ const compatible = isModelCompatibleWithTool(model.providerKey, toolMode)
81
88
 
82
89
  return (
83
90
  <div className={styles.panel}>
@@ -127,8 +134,23 @@ export default function DetailPanel({ model, onClose, favorites, onBenchmark, on
127
134
  <StatRow label="API Key">
128
135
  {model.hasApiKey ? '✅ Configured' : '❌ Missing'}
129
136
  </StatRow>
137
+ <StatRow label="Tool">
138
+ <ToolPicker
139
+ compact
140
+ toolMode={toolMode}
141
+ onSetToolMode={onSetToolMode}
142
+ onCycleToolMode={onCycleToolMode}
143
+ />
144
+ </StatRow>
145
+ {!compatible && (
146
+ <div className={styles.compatWarning}>
147
+ <IconAlertTriangle size={14} />
148
+ <span>{model.label} is not compatible with {toolMeta.emoji} {toolMeta.label}.</span>
149
+ <button onClick={() => onOpenFallback?.(model)}>Install in compatible tool</button>
150
+ </div>
151
+ )}
130
152
 
131
- {/* ── M1: favorites + per-row benchmark action section ── */}
153
+ {/* ── M1/M3: favorites + benchmark + endpoint install action section ── */}
132
154
  {(favorites || onBenchmark) && (
133
155
  <div className={styles.actions}>
134
156
  {favorites && (
@@ -166,6 +188,9 @@ export default function DetailPanel({ model, onClose, favorites, onBenchmark, on
166
188
  )}
167
189
  </div>
168
190
  )}
191
+ {onLaunch && (
192
+ <LaunchButton model={model} toolMode={toolMode} onLaunch={onLaunch} />
193
+ )}
169
194
  {onBenchmark && (
170
195
  <button
171
196
  className={styles.benchBtn}
@@ -219,6 +219,32 @@
219
219
  background: rgba(180, 0, 255, 0.20);
220
220
  border-color: rgba(180, 0, 255, 0.7);
221
221
  }
222
+
223
+ .compatWarning {
224
+ display: flex;
225
+ align-items: center;
226
+ gap: 8px;
227
+ margin: 12px 0;
228
+ padding: 10px;
229
+ border: 1px solid rgba(255, 90, 122, 0.35);
230
+ border-radius: 8px;
231
+ background: rgba(255, 90, 122, 0.10);
232
+ color: #ff8da3;
233
+ font-size: 12px;
234
+ line-height: 1.35;
235
+ }
236
+ .compatWarning span { flex: 1; }
237
+ .compatWarning button {
238
+ border: 1px solid rgba(255, 90, 122, 0.45);
239
+ border-radius: 6px;
240
+ background: var(--color-bg-card);
241
+ color: #ff8da3;
242
+ padding: 5px 8px;
243
+ cursor: pointer;
244
+ font-size: 11px;
245
+ font-weight: 800;
246
+ }
247
+ .compatWarning button:hover { background: rgba(255, 90, 122, 0.16); }
222
248
  :global([data-theme="light"]) .benchBtn {
223
249
  background: rgba(140, 0, 200, 0.06);
224
250
  color: #6a00a0;