@bibobonking/opensol 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +2 -2
  4. package/desktop/README.md +39 -0
  5. package/desktop/loading/index.html +36 -0
  6. package/desktop/src-tauri/Cargo.lock +4301 -0
  7. package/desktop/src-tauri/Cargo.toml +23 -0
  8. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  9. package/desktop/src-tauri/build.rs +3 -0
  10. package/desktop/src-tauri/capabilities/default.json +7 -0
  11. package/desktop/src-tauri/icons/128x128.png +0 -0
  12. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  13. package/desktop/src-tauri/icons/32x32.png +0 -0
  14. package/desktop/src-tauri/icons/64x64.png +0 -0
  15. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  25. package/desktop/src-tauri/icons/icon.icns +0 -0
  26. package/desktop/src-tauri/icons/icon.ico +0 -0
  27. package/desktop/src-tauri/icons/icon.png +0 -0
  28. package/desktop/src-tauri/src/lib.rs +210 -0
  29. package/desktop/src-tauri/src/main.rs +6 -0
  30. package/desktop/src-tauri/tauri.conf.json +48 -0
  31. package/dist/assets/index-CeN54A2N.css +1 -0
  32. package/dist/assets/index-fhIO9a7_.js +318 -0
  33. package/dist/index.html +4 -4
  34. package/index.html +2 -2
  35. package/package.json +2 -1
  36. package/server/README.md +2 -2
  37. package/server/daemon.mjs +138 -2
  38. package/server/lib/mcp-oauth.mjs +219 -0
  39. package/server/lib/mcp-oauth.test.mjs +175 -0
  40. package/server/lib/mcp.mjs +899 -0
  41. package/server/lib/mcp.test.mjs +46 -0
  42. package/server/lib/store.mjs +22 -0
  43. package/server/lib/tools.mjs +59 -1
  44. package/src/App.tsx +2 -0
  45. package/src/agent/tools.ts +6 -5
  46. package/src/components/CommandPalette.tsx +14 -1
  47. package/src/components/Composer.tsx +22 -38
  48. package/src/components/DiffView.tsx +98 -0
  49. package/src/components/Logo.tsx +1 -1
  50. package/src/components/McpPanel.tsx +687 -0
  51. package/src/components/MessageItem.tsx +62 -13
  52. package/src/components/ModelDeck.tsx +661 -0
  53. package/src/components/SessionsTab.tsx +206 -0
  54. package/src/components/SettingsModal.tsx +146 -71
  55. package/src/components/Sidebar.tsx +79 -24
  56. package/src/components/Splash.tsx +1 -1
  57. package/src/components/ToolCallCard.tsx +29 -7
  58. package/src/components/WorkspacePanel.tsx +16 -1
  59. package/src/core/kernel.ts +2 -0
  60. package/src/core/mcp-tools.ts +63 -0
  61. package/src/core/plugins/tools/fs.ts +24 -3
  62. package/src/core/services/agent-loop.ts +3 -1
  63. package/src/core/services/approval.ts +2 -1
  64. package/src/core/services/titles.ts +135 -0
  65. package/src/core/services/tools.ts +11 -8
  66. package/src/core/types.ts +4 -0
  67. package/src/data/providers.ts +1 -1
  68. package/src/index.css +97 -1
  69. package/src/lib/daemon.ts +38 -0
  70. package/src/lib/diff.ts +62 -0
  71. package/src/lib/mcp-config.test.mjs +47 -0
  72. package/src/lib/mcp-config.ts +203 -0
  73. package/src/lib/permissions.ts +6 -6
  74. package/src/store/chats.ts +9 -1
  75. package/src/store/mcp.ts +94 -0
  76. package/src/store/settings.ts +10 -7
  77. package/src/store/ui.ts +17 -0
  78. package/src/store/workspace.ts +26 -0
  79. package/src/types.ts +63 -2
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/dist/assets/index-BmN7pBji.css +0 -1
  82. package/dist/assets/index-DgNDjkOz.js +0 -301
  83. package/src/components/ModelPicker.tsx +0 -487
@@ -0,0 +1,661 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { AlertCircle, Brain, Check, Eye, KeyRound, Plus, RefreshCw, Search, Settings2, X, Zap } from 'lucide-react'
4
+ import { PROVIDERS, PROVIDER_MAP, EFFORT_LABELS, effortLabel } from '../data/providers'
5
+ import type { Effort, ModelInfo, ProviderId } from '../types'
6
+ import { activeModelId, providerHasCredentials, useSettings } from '../store/settings'
7
+ import { useModelCatalog } from '../store/models'
8
+ import { useUi } from '../store/ui'
9
+ import { defaultEffort, effortOptions, normalizeEffort, reasoningSummary } from '../lib/reasoning'
10
+ import { cn } from '../lib/utils'
11
+ import { Spinner } from './ui'
12
+
13
+ const NO_MODELS: ModelInfo[] = []
14
+
15
+ function connected(providerId: ProviderId) {
16
+ return providerHasCredentials(useSettings.getState(), providerId)
17
+ }
18
+
19
+ function contextLabel(context?: number) {
20
+ if (!context) return ''
21
+ if (context >= 1_000_000) return `${(context / 1_000_000).toFixed(context % 1_000_000 ? 1 : 0)}M`
22
+ return `${Math.round(context / 1000)}k`
23
+ }
24
+
25
+ /** 0–1 on a log scale so a 8k and a 1M window are both readable on one bar. */
26
+ function contextFill(context?: number) {
27
+ if (!context) return 0
28
+ return Math.max(0.06, Math.min(1, Math.log10(context / 4000) / Math.log10(2_000_000 / 4000)))
29
+ }
30
+
31
+ function monogram(name: string) {
32
+ const words = name.split(/[\s/.-]+/).filter(Boolean)
33
+ return (words.length > 1 ? words[0][0] + words[1][0] : name.slice(0, 2)).toUpperCase()
34
+ }
35
+
36
+ /** Signal-bar style effort meter used on the composer trigger. */
37
+ export function EffortBars({ efforts, value, className }: { efforts: Effort[]; value: Effort; className?: string }) {
38
+ const index = Math.max(0, efforts.indexOf(value))
39
+ return (
40
+ <span className={cn('flex h-3.5 items-end gap-[2px]', className)} aria-hidden>
41
+ {efforts.map((effort, i) => (
42
+ <span
43
+ key={effort}
44
+ style={{ height: `${38 + (i / Math.max(1, efforts.length - 1)) * 62}%` }}
45
+ className={cn(
46
+ 'w-[3px] rounded-[1px] transition-all duration-300 ease-spring',
47
+ i <= index ? 'bg-accent' : 'bg-line',
48
+ )}
49
+ />
50
+ ))}
51
+ </span>
52
+ )
53
+ }
54
+
55
+ function EffortThrottle({
56
+ efforts,
57
+ value,
58
+ onChange,
59
+ disabled,
60
+ }: {
61
+ efforts: Effort[]
62
+ value: Effort
63
+ onChange: (effort: Effort) => void
64
+ disabled: boolean
65
+ }) {
66
+ const trackRef = useRef<HTMLDivElement>(null)
67
+ const [dragging, setDragging] = useState(false)
68
+ const last = Math.max(1, efforts.length - 1)
69
+ const index = Math.max(0, efforts.indexOf(value))
70
+ const pct = (index / last) * 100
71
+
72
+ const pick = useCallback(
73
+ (clientY: number) => {
74
+ const rect = trackRef.current?.getBoundingClientRect()
75
+ if (!rect || efforts.length === 0) return
76
+ const fraction = 1 - Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))
77
+ const next = efforts[Math.round(fraction * last)]
78
+ if (next && next !== value) onChange(next)
79
+ },
80
+ [efforts, last, onChange, value],
81
+ )
82
+
83
+ return (
84
+ <div className={cn('relative flex h-[280px] gap-4 select-none', disabled && 'pointer-events-none opacity-40')}>
85
+ <div
86
+ ref={trackRef}
87
+ role="slider"
88
+ tabIndex={disabled ? -1 : 0}
89
+ aria-label="Reasoning effort"
90
+ aria-valuemin={0}
91
+ aria-valuemax={efforts.length - 1}
92
+ aria-valuenow={index}
93
+ aria-valuetext={effortLabel(value)}
94
+ onKeyDown={(event: KeyboardEvent) => {
95
+ if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
96
+ event.preventDefault()
97
+ onChange(efforts[Math.min(efforts.length - 1, index + 1)])
98
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') {
99
+ event.preventDefault()
100
+ onChange(efforts[Math.max(0, index - 1)])
101
+ }
102
+ }}
103
+ onPointerDown={(event) => {
104
+ ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
105
+ setDragging(true)
106
+ pick(event.clientY)
107
+ }}
108
+ onPointerMove={(event) => dragging && pick(event.clientY)}
109
+ onPointerUp={() => setDragging(false)}
110
+ onPointerCancel={() => setDragging(false)}
111
+ className="relative w-9 shrink-0 cursor-grab touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/50 active:cursor-grabbing"
112
+ >
113
+ <div className="absolute inset-x-[13px] inset-y-1 rounded-full bg-raised shadow-[inset_0_1px_3px_rgba(0,0,0,0.12)]" />
114
+ <div
115
+ className="absolute inset-x-[13px] bottom-1 rounded-full bg-gradient-to-t from-accent/40 via-accent to-accent transition-[height] duration-500 ease-spring"
116
+ style={{ height: `calc((100% - 8px) * ${pct / 100})` }}
117
+ />
118
+ {efforts.map((effort, i) => (
119
+ <span
120
+ key={effort}
121
+ className={cn(
122
+ 'absolute left-1/2 h-[7px] w-[7px] -translate-x-1/2 translate-y-1/2 rounded-full border transition-colors duration-300',
123
+ i <= index ? 'border-on-accent/40 bg-on-accent/80' : 'border-line bg-panel',
124
+ )}
125
+ style={{ bottom: `calc(4px + (100% - 8px) * ${i / last})` }}
126
+ />
127
+ ))}
128
+ <span
129
+ className={cn(
130
+ 'throttle-knob absolute left-1/2 h-6 w-6 -translate-x-1/2 translate-y-1/2 rounded-full border-2 border-accent bg-panel shadow-float transition-[bottom] duration-500 ease-spring',
131
+ dragging && 'scale-110 !duration-75',
132
+ )}
133
+ style={{ bottom: `calc(4px + (100% - 8px) * ${index / last})` }}
134
+ >
135
+ <span className="absolute inset-[5px] rounded-full bg-accent" />
136
+ </span>
137
+ </div>
138
+
139
+ <div className="relative min-w-0 flex-1">
140
+ {efforts.map((effort, i) => {
141
+ const active = i === index
142
+ return (
143
+ <button
144
+ key={effort}
145
+ type="button"
146
+ onClick={() => onChange(effort)}
147
+ className={cn(
148
+ 'absolute left-0 flex -translate-y-1/2 flex-col items-start text-left transition-all duration-300',
149
+ active ? 'text-ink' : 'text-faint hover:text-ink',
150
+ )}
151
+ style={{ top: `calc(4px + (100% - 8px) * ${1 - i / last})` }}
152
+ >
153
+ <span className={cn('font-mono text-[10.5px] uppercase tracking-[0.14em]', active && 'text-accent')}>
154
+ {effortLabel(effort)}
155
+ </span>
156
+ <span
157
+ className={cn(
158
+ 'overflow-hidden text-[10.5px] leading-tight text-muted transition-all duration-300',
159
+ active ? 'mt-0.5 max-h-8 opacity-100' : 'max-h-0 opacity-0',
160
+ )}
161
+ >
162
+ {EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS]?.description}
163
+ </span>
164
+ </button>
165
+ )
166
+ })}
167
+ </div>
168
+ </div>
169
+ )
170
+ }
171
+
172
+ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => void }) {
173
+ const settings = useSettings()
174
+ const selectModel = useSettings((state) => state.selectModel)
175
+ const update = useSettings((state) => state.update)
176
+ const openSettings = useUi((state) => state.openSettings)
177
+ const [query, setQuery] = useState('')
178
+ const [manual, setManual] = useState('')
179
+ const [showManual, setShowManual] = useState(false)
180
+ const [cursor, setCursor] = useState(0)
181
+ const listRef = useRef<HTMLDivElement>(null)
182
+ const searchRef = useRef<HTMLInputElement>(null)
183
+
184
+ const railRef = useRef<HTMLElement>(null)
185
+ const provider = settings.provider
186
+ const models = useModelCatalog((state) => state.byProvider[provider]) ?? NO_MODELS
187
+ const loading = useModelCatalog((state) => Boolean(state.loading[provider]))
188
+ const error = useModelCatalog((state) => state.errors[provider] ?? '')
189
+ const note = useModelCatalog((state) => state.notes[provider] ?? '')
190
+ const fetchModels = useModelCatalog((state) => state.fetchModels)
191
+
192
+ const modelId = activeModelId(settings)
193
+ const active = models.find((model) => model.id === modelId) ?? null
194
+ const connectedNow = connected(provider)
195
+ const keyless = Boolean(PROVIDER_MAP[provider]?.keyless) || provider === 'custom'
196
+ const efforts = effortOptions(active)
197
+
198
+ useEffect(() => {
199
+ if (!open) return
200
+ if (connectedNow || keyless) void fetchModels(provider)
201
+ }, [open, provider, connectedNow, keyless, fetchModels])
202
+
203
+ useEffect(() => {
204
+ if (!open) {
205
+ setQuery('')
206
+ setShowManual(false)
207
+ return
208
+ }
209
+ const timer = window.setTimeout(() => searchRef.current?.focus(), 60)
210
+ const previous = document.activeElement as HTMLElement | null
211
+ const overflow = document.body.style.overflow
212
+ document.body.style.overflow = 'hidden'
213
+ return () => {
214
+ window.clearTimeout(timer)
215
+ document.body.style.overflow = overflow
216
+ previous?.focus?.()
217
+ }
218
+ }, [open])
219
+
220
+ const filtered = useMemo(() => {
221
+ const clean = query.trim().toLowerCase()
222
+ if (!clean) return models
223
+ return models.filter(
224
+ (model) => model.name.toLowerCase().includes(clean) || model.id.toLowerCase().includes(clean),
225
+ )
226
+ }, [models, query])
227
+
228
+ useLayoutEffect(() => {
229
+ setCursor(0)
230
+ }, [query, provider])
231
+
232
+ useEffect(() => {
233
+ listRef.current?.querySelector<HTMLElement>(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'center' })
234
+ }, [cursor])
235
+
236
+ const choose = (model: ModelInfo) => {
237
+ const options = effortOptions(model)
238
+ const effort = options.length > 0 ? normalizeEffort(settings.effort, model) : settings.effort
239
+ selectModel(provider, model.id, options.length > 0 && !options.includes(effort) ? defaultEffort(model) : effort)
240
+ }
241
+
242
+ const chooseManual = (event?: FormEvent) => {
243
+ event?.preventDefault()
244
+ const id = manual.trim()
245
+ if (!id) return
246
+ selectModel(provider, id, settings.effort)
247
+ setManual('')
248
+ setShowManual(false)
249
+ }
250
+
251
+ const chooseProvider = (next: ProviderId) => {
252
+ if (next === provider) return
253
+ update({ provider: next, model: '' })
254
+ setQuery('')
255
+ void fetchModels(next)
256
+ }
257
+
258
+ const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
259
+ if (event.key === 'ArrowDown') {
260
+ event.preventDefault()
261
+ setCursor((value) => Math.min(filtered.length - 1, value + 1))
262
+ } else if (event.key === 'ArrowUp') {
263
+ event.preventDefault()
264
+ setCursor((value) => Math.max(0, value - 1))
265
+ } else if (event.key === 'Enter') {
266
+ event.preventDefault()
267
+ const model = filtered[cursor]
268
+ if (model && model.id !== modelId) choose(model)
269
+ else onClose()
270
+ }
271
+ }
272
+
273
+ useEffect(() => {
274
+ if (!open) return
275
+ const onKey = (event: globalThis.KeyboardEvent) => {
276
+ if (event.key === 'Escape') {
277
+ event.preventDefault()
278
+ onClose()
279
+ }
280
+ }
281
+ document.addEventListener('keydown', onKey)
282
+ return () => document.removeEventListener('keydown', onKey)
283
+ }, [open, onClose])
284
+
285
+ // Keep the active provider in view: the rail scrolls and the selected one can sit far down.
286
+ useEffect(() => {
287
+ if (!open) return
288
+ const frame = requestAnimationFrame(() => railRef.current?.querySelector('[aria-pressed="true"]')?.scrollIntoView({ block: 'center' }))
289
+ return () => cancelAnimationFrame(frame)
290
+ }, [open, provider])
291
+
292
+ if (!open) return null
293
+
294
+ const needsKey = !connectedNow && !keyless
295
+ const railIndex = Math.max(0, PROVIDERS.findIndex((entry) => entry.id === provider))
296
+
297
+ return createPortal(
298
+ <div className="fixed inset-0 z-[90] flex items-end justify-center sm:items-center sm:p-6">
299
+ <button
300
+ type="button"
301
+ aria-label="Close model deck"
302
+ tabIndex={-1}
303
+ onClick={onClose}
304
+ className="deck-scrim absolute inset-0 cursor-default bg-black/40 backdrop-blur-[6px] dark:bg-black/65"
305
+ />
306
+ <div
307
+ role="dialog"
308
+ aria-modal="true"
309
+ aria-label="Model deck"
310
+ className="deck-panel relative flex h-[min(92dvh,640px)] w-full flex-col overflow-hidden rounded-t-[28px] border border-line bg-panel shadow-float sm:w-[min(920px,96vw)] sm:rounded-[28px]"
311
+ >
312
+ <div className="deck-aurora pointer-events-none absolute inset-x-0 top-0 h-40" />
313
+
314
+ <header className="relative flex shrink-0 items-end gap-4 px-6 pb-4 pt-5">
315
+ <div className="min-w-0 flex-1">
316
+ <p className="font-mono text-[10px] uppercase tracking-[0.22em] text-faint">Model deck</p>
317
+ <h2 className="mt-1 truncate font-display text-[26px] font-semibold leading-none tracking-[-0.03em] text-ink">
318
+ {active?.name || modelId || 'Pick a model'}
319
+ </h2>
320
+ <p className="mt-1.5 flex items-center gap-2 text-[11.5px] text-muted">
321
+ <span>{PROVIDER_MAP[provider]?.name ?? provider}</span>
322
+ {efforts.length > 0 && (
323
+ <>
324
+ <span className="text-faint">·</span>
325
+ <span className="text-accent">{effortLabel(settings.effort)} effort</span>
326
+ </>
327
+ )}
328
+ {active?.context ? (
329
+ <>
330
+ <span className="text-faint">·</span>
331
+ <span>{contextLabel(active.context)} context</span>
332
+ </>
333
+ ) : null}
334
+ </p>
335
+ </div>
336
+ <button
337
+ type="button"
338
+ className="icon-button h-8 w-8"
339
+ onClick={() => void fetchModels(provider, true)}
340
+ disabled={loading || needsKey}
341
+ title="Refresh live models"
342
+ aria-label="Refresh live models"
343
+ >
344
+ <RefreshCw size={14} className={cn(loading && 'animate-spin')} />
345
+ </button>
346
+ <button
347
+ type="button"
348
+ className="icon-button h-8 w-8"
349
+ onClick={() => {
350
+ onClose()
351
+ openSettings('models')
352
+ }}
353
+ title="Provider settings"
354
+ aria-label="Provider settings"
355
+ >
356
+ <Settings2 size={14} />
357
+ </button>
358
+ <button type="button" className="icon-button h-8 w-8" onClick={onClose} aria-label="Close">
359
+ <X size={15} />
360
+ </button>
361
+ </header>
362
+
363
+ <div className="relative flex min-h-0 flex-1 flex-col border-t border-line md:flex-row">
364
+ {/* Provider rail */}
365
+ <nav
366
+ ref={railRef}
367
+ aria-label="Providers"
368
+ className="thin-scroll relative flex shrink-0 gap-1.5 overflow-x-auto border-b border-line bg-canvas/70 p-2 md:w-[78px] md:flex-col md:overflow-y-auto md:overflow-x-hidden md:border-b-0 md:border-r md:p-2.5"
369
+ >
370
+ <span
371
+ aria-hidden
372
+ className="deck-rail-glow pointer-events-none absolute left-2.5 top-2.5 hidden h-[46px] w-[calc(100%-20px)] rounded-2xl bg-accent-soft ring-1 ring-accent/40 transition-transform duration-500 ease-spring md:block"
373
+ style={{ transform: `translateY(${railIndex * 52}px)` }}
374
+ />
375
+ {PROVIDERS.map((entry) => {
376
+ const ready = connected(entry.id) || entry.keyless || entry.id === 'custom'
377
+ const selected = entry.id === provider
378
+ return (
379
+ <button
380
+ key={entry.id}
381
+ type="button"
382
+ onClick={() => chooseProvider(entry.id)}
383
+ disabled={!ready}
384
+ title={`${entry.name}${ready ? '' : ' · connect to enable'}`}
385
+ aria-pressed={selected}
386
+ className={cn(
387
+ 'relative flex h-[46px] min-w-[58px] shrink-0 flex-col items-center justify-center gap-0.5 rounded-2xl transition-colors disabled:cursor-not-allowed disabled:opacity-40 md:min-w-0',
388
+ selected ? 'text-accent max-md:bg-accent-soft' : 'text-muted hover:bg-raised hover:text-ink',
389
+ )}
390
+ >
391
+ <span className="font-display text-[13px] font-semibold tracking-tight">{monogram(entry.name)}</span>
392
+ <span className="max-w-full truncate px-1 text-[8.5px] leading-none text-faint">{entry.name.split(' ')[0]}</span>
393
+ <span
394
+ className={cn(
395
+ 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',
396
+ ready ? 'bg-success' : 'bg-warning/70',
397
+ )}
398
+ />
399
+ </button>
400
+ )
401
+ })}
402
+ </nav>
403
+
404
+ {/* Models */}
405
+ <section className="flex min-h-0 min-w-0 flex-1 flex-col">
406
+ {needsKey ? (
407
+ <div className="flex flex-1 flex-col items-center justify-center gap-3 px-8 text-center">
408
+ <span className="deck-pop flex h-12 w-12 items-center justify-center rounded-2xl bg-warning/10 text-warning">
409
+ <KeyRound size={20} />
410
+ </span>
411
+ <p className="font-display text-[16px] font-semibold text-ink">Connect {PROVIDER_MAP[provider]?.name ?? provider}</p>
412
+ <p className="max-w-[320px] text-[12px] leading-relaxed text-muted">
413
+ Keys are encrypted by the local daemon before they touch disk and never come back to this page.
414
+ </p>
415
+ <button
416
+ type="button"
417
+ onClick={() => {
418
+ onClose()
419
+ openSettings('models')
420
+ }}
421
+ className="mt-1 inline-flex h-9 items-center gap-1.5 rounded-full bg-ink px-5 text-[12px] font-medium text-panel transition-transform active:scale-95"
422
+ >
423
+ Add API key
424
+ </button>
425
+ </div>
426
+ ) : (
427
+ <>
428
+ <div className="shrink-0 px-4 pb-2 pt-3.5">
429
+ <div className="relative">
430
+ <Search size={14} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-faint" />
431
+ <input
432
+ ref={searchRef}
433
+ value={query}
434
+ onChange={(event) => setQuery(event.target.value)}
435
+ onKeyDown={onSearchKey}
436
+ placeholder={models.length > 0 ? `Search ${models.length} models…` : 'Search models…'}
437
+ className="h-10 w-full rounded-full border border-line bg-canvas/70 pl-10 pr-14 text-[13px] text-ink outline-none transition-all placeholder:text-faint focus:border-accent/50 focus:bg-panel focus:shadow-[0_0_0_4px_rgb(var(--accent)/0.10)]"
438
+ aria-label="Search models"
439
+ />
440
+ <kbd className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md border border-line bg-panel px-1.5 py-0.5 font-mono text-[9.5px] text-faint">
441
+ ↑↓ ↵
442
+ </kbd>
443
+ </div>
444
+ {error && (
445
+ <div className="mt-2 flex items-start gap-1.5 rounded-lg border border-danger/20 bg-danger/[0.04] px-2.5 py-2">
446
+ <AlertCircle size={12} className="mt-0.5 shrink-0 text-danger" />
447
+ <p className="min-w-0 flex-1 text-[11px] leading-relaxed text-danger">{error}</p>
448
+ <button type="button" onClick={() => void fetchModels(provider, true)} className="text-[11px] font-medium text-danger">
449
+ Retry
450
+ </button>
451
+ </div>
452
+ )}
453
+ {note && !error && <p className="mt-1.5 px-1 text-[10.5px] leading-relaxed text-faint">{note}</p>}
454
+ </div>
455
+
456
+ <div ref={listRef} className="thin-scroll min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
457
+ {loading && models.length === 0 && (
458
+ <p className="flex items-center gap-2 px-3 py-4 text-[12px] text-faint">
459
+ <Spinner size={12} /> Loading live models…
460
+ </p>
461
+ )}
462
+ {filtered.map((model, i) => {
463
+ const selected = model.id === modelId
464
+ const highlighted = i === cursor
465
+ const thinks = model.reasoningOptions.length > 0
466
+ return (
467
+ <button
468
+ key={model.id}
469
+ type="button"
470
+ data-row={i}
471
+ onClick={() => {
472
+ setCursor(i)
473
+ choose(model)
474
+ }}
475
+ onMouseMove={() => highlighted || setCursor(i)}
476
+ style={{ animationDelay: `${Math.min(i * 22, 260)}ms` }}
477
+ className={cn(
478
+ 'deck-row group/row relative mb-1 flex w-full items-center gap-3 overflow-hidden rounded-2xl px-3 py-2.5 text-left transition-colors',
479
+ selected ? 'bg-accent-soft' : highlighted ? 'bg-raised' : 'hover:bg-raised/70',
480
+ )}
481
+ >
482
+ <span
483
+ className={cn(
484
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-xl transition-all duration-300',
485
+ selected ? 'scale-100 bg-accent text-on-accent' : 'bg-raised text-faint group-hover/row:bg-panel',
486
+ )}
487
+ >
488
+ {selected ? <Check size={14} strokeWidth={2.4} /> : <Zap size={13} />}
489
+ </span>
490
+ <span className="min-w-0 flex-1">
491
+ <span className="flex items-center gap-2">
492
+ <span className={cn('truncate text-[13px] font-medium', selected ? 'text-accent' : 'text-ink')}>
493
+ {model.name || model.id}
494
+ </span>
495
+ {model.free && (
496
+ <span className="rounded-full bg-success/10 px-1.5 py-px text-[9px] font-medium text-success">free</span>
497
+ )}
498
+ {!model.toolCall && (
499
+ <span className="rounded-full bg-warning/10 px-1.5 py-px text-[9px] font-medium text-warning">no tools</span>
500
+ )}
501
+ </span>
502
+ <span className="mt-0.5 block truncate font-mono text-[10px] text-faint">{model.id}</span>
503
+ </span>
504
+ <span className="hidden shrink-0 items-center gap-2.5 text-faint sm:flex">
505
+ {thinks && <Brain size={12} className="text-accent/80" aria-label="reasoning" />}
506
+ {model.vision && <Eye size={12} aria-label="vision" />}
507
+ {model.context ? (
508
+ <span className="flex w-[74px] flex-col items-end gap-1">
509
+ <span className="font-mono text-[10px] tabular-nums">{contextLabel(model.context)}</span>
510
+ <span className="h-[3px] w-full overflow-hidden rounded-full bg-line">
511
+ <span
512
+ className={cn('block h-full rounded-full', selected ? 'bg-accent' : 'bg-faint/60')}
513
+ style={{ width: `${contextFill(model.context) * 100}%` }}
514
+ />
515
+ </span>
516
+ </span>
517
+ ) : null}
518
+ </span>
519
+ </button>
520
+ )
521
+ })}
522
+ {!loading && filtered.length === 0 && models.length > 0 && (
523
+ <p className="px-3 py-6 text-center text-[12px] text-faint">No model matches “{query}”.</p>
524
+ )}
525
+ {!loading && models.length === 0 && !error && (
526
+ <p className="px-3 py-6 text-center text-[12px] text-faint">This provider returned no usable chat models.</p>
527
+ )}
528
+
529
+ {showManual ? (
530
+ <form onSubmit={chooseManual} className="deck-row mt-1 flex items-center gap-2 px-1">
531
+ <input
532
+ autoFocus
533
+ value={manual}
534
+ onChange={(event) => setManual(event.target.value)}
535
+ placeholder="model-id"
536
+ className="h-9 min-w-0 flex-1 rounded-full border border-line bg-panel px-4 font-mono text-[11.5px] outline-none focus:border-accent/50"
537
+ aria-label="Model ID"
538
+ />
539
+ <button
540
+ type="submit"
541
+ disabled={!manual.trim()}
542
+ className="h-9 rounded-full bg-ink px-4 text-[11.5px] font-medium text-panel transition-opacity disabled:opacity-40"
543
+ >
544
+ Use
545
+ </button>
546
+ </form>
547
+ ) : (
548
+ <button
549
+ type="button"
550
+ onClick={() => setShowManual(true)}
551
+ className="mt-1 flex w-full items-center gap-2 rounded-2xl px-3 py-2.5 text-left text-[12px] text-faint transition-colors hover:bg-raised/70 hover:text-ink"
552
+ >
553
+ <Plus size={13} /> Use a model ID that is not listed
554
+ </button>
555
+ )}
556
+ </div>
557
+ </>
558
+ )}
559
+ </section>
560
+
561
+ {/* Effort throttle */}
562
+ <aside className="deck-throttle relative shrink-0 border-t border-line bg-canvas/70 px-6 py-5 md:w-[258px] md:border-l md:border-t-0">
563
+ <div className="mb-4 flex items-center gap-2">
564
+ <Brain size={13} className="text-accent" />
565
+ <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted">Reasoning</p>
566
+ </div>
567
+
568
+ {efforts.length > 1 ? (
569
+ <>
570
+ <div className="hidden md:block">
571
+ <EffortThrottle
572
+ efforts={efforts}
573
+ value={normalizeEffort(settings.effort, active)}
574
+ onChange={(effort) => active && selectModel(provider, active.id, effort)}
575
+ disabled={!active}
576
+ />
577
+ </div>
578
+ <div className="flex gap-1 rounded-full bg-raised p-1 md:hidden">
579
+ {efforts.map((effort) => (
580
+ <button
581
+ key={effort}
582
+ type="button"
583
+ onClick={() => active && selectModel(provider, active.id, effort)}
584
+ className={cn(
585
+ 'h-8 min-w-0 flex-1 rounded-full text-[10.5px] font-medium transition-all',
586
+ settings.effort === effort ? 'bg-accent text-on-accent shadow-pill' : 'text-muted',
587
+ )}
588
+ >
589
+ {effortLabel(effort)}
590
+ </button>
591
+ ))}
592
+ </div>
593
+ </>
594
+ ) : (
595
+ <div className="flex h-[120px] flex-col justify-center rounded-2xl border border-dashed border-line px-4 text-center md:h-[280px]">
596
+ <p className="text-[12px] font-medium text-ink">{active ? 'No effort dial' : 'Pick a model'}</p>
597
+ <p className="mt-1 text-[11px] leading-relaxed text-muted">{reasoningSummary(active)}</p>
598
+ </div>
599
+ )}
600
+
601
+ {efforts.length > 1 && <p className="mt-4 hidden text-[10.5px] leading-relaxed text-faint md:block">{reasoningSummary(active)}</p>}
602
+ </aside>
603
+ </div>
604
+
605
+ <footer className="relative flex shrink-0 items-center gap-3 border-t border-line px-6 py-3 text-[10.5px] text-faint">
606
+ {active?.maxOutput ? <span>max output {contextLabel(active.maxOutput)}</span> : null}
607
+ {active?.apiKind ? <span className="rounded-md border border-line px-1.5 py-px font-mono">{active.apiKind}</span> : null}
608
+ <span className="ml-auto hidden sm:inline">Esc to close</span>
609
+ <button
610
+ type="button"
611
+ onClick={onClose}
612
+ className="inline-flex h-8 items-center rounded-full bg-ink px-5 text-[12px] font-medium text-panel transition-transform active:scale-95"
613
+ >
614
+ Done
615
+ </button>
616
+ </footer>
617
+ </div>
618
+ </div>,
619
+ document.body,
620
+ )
621
+ }
622
+
623
+ /** Composer trigger: model name with live effort bars, no dropdown chevron. */
624
+ export function ModelDeckTrigger({
625
+ label,
626
+ open,
627
+ disabled,
628
+ onClick,
629
+ triggerRef,
630
+ efforts,
631
+ effort,
632
+ title,
633
+ }: {
634
+ label: string
635
+ open: boolean
636
+ disabled: boolean
637
+ onClick: () => void
638
+ triggerRef: React.RefObject<HTMLButtonElement | null>
639
+ efforts: Effort[]
640
+ effort: Effort
641
+ title: string
642
+ }) {
643
+ return (
644
+ <button
645
+ ref={triggerRef}
646
+ type="button"
647
+ disabled={disabled}
648
+ onClick={onClick}
649
+ aria-haspopup="dialog"
650
+ aria-expanded={open}
651
+ title={title}
652
+ className={cn(
653
+ 'deck-trigger group/deck flex h-8 max-w-[200px] items-center gap-2 rounded-full border border-transparent pl-2.5 pr-2.5 text-[11px] font-medium text-muted transition-all duration-200 hover:border-line hover:bg-raised hover:text-ink active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45 sm:max-w-[270px]',
654
+ open && 'border-accent/40 bg-accent-soft text-accent',
655
+ )}
656
+ >
657
+ <span className="truncate">{label}</span>
658
+ {efforts.length > 1 && <EffortBars efforts={efforts} value={effort} />}
659
+ </button>
660
+ )
661
+ }