@bibobonking/opensol 3.0.3 → 3.1.0

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 (71) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +18 -0
  3. package/desktop/README.md +39 -0
  4. package/desktop/loading/index.html +36 -0
  5. package/desktop/src-tauri/Cargo.lock +4301 -0
  6. package/desktop/src-tauri/Cargo.toml +23 -0
  7. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  8. package/desktop/src-tauri/build.rs +3 -0
  9. package/desktop/src-tauri/capabilities/default.json +7 -0
  10. package/desktop/src-tauri/icons/128x128.png +0 -0
  11. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  12. package/desktop/src-tauri/icons/32x32.png +0 -0
  13. package/desktop/src-tauri/icons/64x64.png +0 -0
  14. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  15. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  23. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  24. package/desktop/src-tauri/icons/icon.icns +0 -0
  25. package/desktop/src-tauri/icons/icon.ico +0 -0
  26. package/desktop/src-tauri/icons/icon.png +0 -0
  27. package/desktop/src-tauri/src/lib.rs +210 -0
  28. package/desktop/src-tauri/src/main.rs +6 -0
  29. package/desktop/src-tauri/tauri.conf.json +48 -0
  30. package/dist/assets/index-DdI1_iPD.css +1 -0
  31. package/dist/assets/index-egG5Bgfo.js +312 -0
  32. package/dist/index.html +2 -2
  33. package/package.json +2 -1
  34. package/server/daemon.mjs +64 -2
  35. package/server/lib/mcp.mjs +532 -0
  36. package/server/lib/mcp.test.mjs +46 -0
  37. package/server/lib/store.mjs +22 -0
  38. package/server/lib/tools.mjs +59 -1
  39. package/src/agent/tools.ts +6 -5
  40. package/src/components/Composer.tsx +22 -38
  41. package/src/components/DiffView.tsx +98 -0
  42. package/src/components/McpTab.tsx +337 -0
  43. package/src/components/MessageItem.tsx +62 -13
  44. package/src/components/ModelDeck.tsx +652 -0
  45. package/src/components/SessionsTab.tsx +206 -0
  46. package/src/components/SettingsModal.tsx +106 -69
  47. package/src/components/Sidebar.tsx +78 -24
  48. package/src/components/ToolCallCard.tsx +29 -7
  49. package/src/components/WorkspacePanel.tsx +16 -1
  50. package/src/core/kernel.ts +2 -0
  51. package/src/core/mcp-tools.ts +63 -0
  52. package/src/core/plugins/tools/fs.ts +24 -3
  53. package/src/core/services/agent-loop.ts +3 -1
  54. package/src/core/services/approval.ts +2 -1
  55. package/src/core/services/titles.ts +135 -0
  56. package/src/core/services/tools.ts +11 -8
  57. package/src/core/types.ts +4 -0
  58. package/src/index.css +97 -1
  59. package/src/lib/daemon.ts +29 -0
  60. package/src/lib/diff.ts +62 -0
  61. package/src/lib/permissions.ts +6 -6
  62. package/src/store/chats.ts +9 -1
  63. package/src/store/mcp.ts +67 -0
  64. package/src/store/settings.ts +9 -6
  65. package/src/store/ui.ts +13 -0
  66. package/src/store/workspace.ts +26 -0
  67. package/src/types.ts +51 -2
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/dist/assets/index-BmN7pBji.css +0 -1
  70. package/dist/assets/index-DgNDjkOz.js +0 -301
  71. package/src/components/ModelPicker.tsx +0 -487
@@ -0,0 +1,652 @@
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 provider = settings.provider
185
+ const models = useModelCatalog((state) => state.byProvider[provider]) ?? NO_MODELS
186
+ const loading = useModelCatalog((state) => Boolean(state.loading[provider]))
187
+ const error = useModelCatalog((state) => state.errors[provider] ?? '')
188
+ const note = useModelCatalog((state) => state.notes[provider] ?? '')
189
+ const fetchModels = useModelCatalog((state) => state.fetchModels)
190
+
191
+ const modelId = activeModelId(settings)
192
+ const active = models.find((model) => model.id === modelId) ?? null
193
+ const connectedNow = connected(provider)
194
+ const keyless = Boolean(PROVIDER_MAP[provider]?.keyless) || provider === 'custom'
195
+ const efforts = effortOptions(active)
196
+
197
+ useEffect(() => {
198
+ if (!open) return
199
+ if (connectedNow || keyless) void fetchModels(provider)
200
+ }, [open, provider, connectedNow, keyless, fetchModels])
201
+
202
+ useEffect(() => {
203
+ if (!open) {
204
+ setQuery('')
205
+ setShowManual(false)
206
+ return
207
+ }
208
+ const timer = window.setTimeout(() => searchRef.current?.focus(), 60)
209
+ const previous = document.activeElement as HTMLElement | null
210
+ const overflow = document.body.style.overflow
211
+ document.body.style.overflow = 'hidden'
212
+ return () => {
213
+ window.clearTimeout(timer)
214
+ document.body.style.overflow = overflow
215
+ previous?.focus?.()
216
+ }
217
+ }, [open])
218
+
219
+ const filtered = useMemo(() => {
220
+ const clean = query.trim().toLowerCase()
221
+ if (!clean) return models
222
+ return models.filter(
223
+ (model) => model.name.toLowerCase().includes(clean) || model.id.toLowerCase().includes(clean),
224
+ )
225
+ }, [models, query])
226
+
227
+ useLayoutEffect(() => {
228
+ setCursor(0)
229
+ }, [query, provider])
230
+
231
+ useEffect(() => {
232
+ listRef.current?.querySelector<HTMLElement>(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'nearest' })
233
+ }, [cursor])
234
+
235
+ const choose = (model: ModelInfo) => {
236
+ const options = effortOptions(model)
237
+ const effort = options.length > 0 ? normalizeEffort(settings.effort, model) : settings.effort
238
+ selectModel(provider, model.id, options.length > 0 && !options.includes(effort) ? defaultEffort(model) : effort)
239
+ }
240
+
241
+ const chooseManual = (event?: FormEvent) => {
242
+ event?.preventDefault()
243
+ const id = manual.trim()
244
+ if (!id) return
245
+ selectModel(provider, id, settings.effort)
246
+ setManual('')
247
+ setShowManual(false)
248
+ }
249
+
250
+ const chooseProvider = (next: ProviderId) => {
251
+ if (next === provider) return
252
+ update({ provider: next, model: '' })
253
+ setQuery('')
254
+ void fetchModels(next)
255
+ }
256
+
257
+ const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
258
+ if (event.key === 'ArrowDown') {
259
+ event.preventDefault()
260
+ setCursor((value) => Math.min(filtered.length - 1, value + 1))
261
+ } else if (event.key === 'ArrowUp') {
262
+ event.preventDefault()
263
+ setCursor((value) => Math.max(0, value - 1))
264
+ } else if (event.key === 'Enter') {
265
+ event.preventDefault()
266
+ const model = filtered[cursor]
267
+ if (model && model.id !== modelId) choose(model)
268
+ else onClose()
269
+ }
270
+ }
271
+
272
+ useEffect(() => {
273
+ if (!open) return
274
+ const onKey = (event: globalThis.KeyboardEvent) => {
275
+ if (event.key === 'Escape') {
276
+ event.preventDefault()
277
+ onClose()
278
+ }
279
+ }
280
+ document.addEventListener('keydown', onKey)
281
+ return () => document.removeEventListener('keydown', onKey)
282
+ }, [open, onClose])
283
+
284
+ if (!open) return null
285
+
286
+ const needsKey = !connectedNow && !keyless
287
+ const railIndex = Math.max(0, PROVIDERS.findIndex((entry) => entry.id === provider))
288
+
289
+ return createPortal(
290
+ <div className="fixed inset-0 z-[90] flex items-end justify-center sm:items-center sm:p-6">
291
+ <button
292
+ type="button"
293
+ aria-label="Close model deck"
294
+ tabIndex={-1}
295
+ onClick={onClose}
296
+ className="deck-scrim absolute inset-0 cursor-default bg-black/40 backdrop-blur-[6px] dark:bg-black/65"
297
+ />
298
+ <div
299
+ role="dialog"
300
+ aria-modal="true"
301
+ aria-label="Model deck"
302
+ 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]"
303
+ >
304
+ <div className="deck-aurora pointer-events-none absolute inset-x-0 top-0 h-40" />
305
+
306
+ <header className="relative flex shrink-0 items-end gap-4 px-6 pb-4 pt-5">
307
+ <div className="min-w-0 flex-1">
308
+ <p className="font-mono text-[10px] uppercase tracking-[0.22em] text-faint">Model deck</p>
309
+ <h2 className="mt-1 truncate font-display text-[26px] font-semibold leading-none tracking-[-0.03em] text-ink">
310
+ {active?.name || modelId || 'Pick a model'}
311
+ </h2>
312
+ <p className="mt-1.5 flex items-center gap-2 text-[11.5px] text-muted">
313
+ <span>{PROVIDER_MAP[provider]?.name ?? provider}</span>
314
+ {efforts.length > 0 && (
315
+ <>
316
+ <span className="text-faint">·</span>
317
+ <span className="text-accent">{effortLabel(settings.effort)} effort</span>
318
+ </>
319
+ )}
320
+ {active?.context ? (
321
+ <>
322
+ <span className="text-faint">·</span>
323
+ <span>{contextLabel(active.context)} context</span>
324
+ </>
325
+ ) : null}
326
+ </p>
327
+ </div>
328
+ <button
329
+ type="button"
330
+ className="icon-button h-8 w-8"
331
+ onClick={() => void fetchModels(provider, true)}
332
+ disabled={loading || needsKey}
333
+ title="Refresh live models"
334
+ aria-label="Refresh live models"
335
+ >
336
+ <RefreshCw size={14} className={cn(loading && 'animate-spin')} />
337
+ </button>
338
+ <button
339
+ type="button"
340
+ className="icon-button h-8 w-8"
341
+ onClick={() => {
342
+ onClose()
343
+ openSettings('models')
344
+ }}
345
+ title="Provider settings"
346
+ aria-label="Provider settings"
347
+ >
348
+ <Settings2 size={14} />
349
+ </button>
350
+ <button type="button" className="icon-button h-8 w-8" onClick={onClose} aria-label="Close">
351
+ <X size={15} />
352
+ </button>
353
+ </header>
354
+
355
+ <div className="relative flex min-h-0 flex-1 flex-col border-t border-line md:flex-row">
356
+ {/* Provider rail */}
357
+ <nav
358
+ aria-label="Providers"
359
+ 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"
360
+ >
361
+ <span
362
+ aria-hidden
363
+ className="deck-rail-glow pointer-events-none absolute left-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"
364
+ style={{ transform: `translateY(${railIndex * 52}px)` }}
365
+ />
366
+ {PROVIDERS.map((entry) => {
367
+ const ready = connected(entry.id) || entry.keyless || entry.id === 'custom'
368
+ const selected = entry.id === provider
369
+ return (
370
+ <button
371
+ key={entry.id}
372
+ type="button"
373
+ onClick={() => chooseProvider(entry.id)}
374
+ disabled={!ready}
375
+ title={`${entry.name}${ready ? '' : ' · connect to enable'}`}
376
+ aria-pressed={selected}
377
+ className={cn(
378
+ '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:mb-[6px] md:min-w-0',
379
+ selected ? 'text-accent max-md:bg-accent-soft' : 'text-muted hover:bg-raised hover:text-ink',
380
+ )}
381
+ >
382
+ <span className="font-display text-[13px] font-semibold tracking-tight">{monogram(entry.name)}</span>
383
+ <span className="max-w-full truncate px-1 text-[8.5px] leading-none text-faint">{entry.name.split(' ')[0]}</span>
384
+ <span
385
+ className={cn(
386
+ 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',
387
+ ready ? 'bg-success' : 'bg-warning/70',
388
+ )}
389
+ />
390
+ </button>
391
+ )
392
+ })}
393
+ </nav>
394
+
395
+ {/* Models */}
396
+ <section className="flex min-h-0 min-w-0 flex-1 flex-col">
397
+ {needsKey ? (
398
+ <div className="flex flex-1 flex-col items-center justify-center gap-3 px-8 text-center">
399
+ <span className="deck-pop flex h-12 w-12 items-center justify-center rounded-2xl bg-warning/10 text-warning">
400
+ <KeyRound size={20} />
401
+ </span>
402
+ <p className="font-display text-[16px] font-semibold text-ink">Connect {PROVIDER_MAP[provider]?.name ?? provider}</p>
403
+ <p className="max-w-[320px] text-[12px] leading-relaxed text-muted">
404
+ Keys are encrypted by the local daemon before they touch disk and never come back to this page.
405
+ </p>
406
+ <button
407
+ type="button"
408
+ onClick={() => {
409
+ onClose()
410
+ openSettings('models')
411
+ }}
412
+ 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"
413
+ >
414
+ Add API key
415
+ </button>
416
+ </div>
417
+ ) : (
418
+ <>
419
+ <div className="shrink-0 px-4 pb-2 pt-3.5">
420
+ <div className="relative">
421
+ <Search size={14} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-faint" />
422
+ <input
423
+ ref={searchRef}
424
+ value={query}
425
+ onChange={(event) => setQuery(event.target.value)}
426
+ onKeyDown={onSearchKey}
427
+ placeholder={models.length > 0 ? `Search ${models.length} models…` : 'Search models…'}
428
+ 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)]"
429
+ aria-label="Search models"
430
+ />
431
+ <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">
432
+ ↑↓ ↵
433
+ </kbd>
434
+ </div>
435
+ {error && (
436
+ <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">
437
+ <AlertCircle size={12} className="mt-0.5 shrink-0 text-danger" />
438
+ <p className="min-w-0 flex-1 text-[11px] leading-relaxed text-danger">{error}</p>
439
+ <button type="button" onClick={() => void fetchModels(provider, true)} className="text-[11px] font-medium text-danger">
440
+ Retry
441
+ </button>
442
+ </div>
443
+ )}
444
+ {note && !error && <p className="mt-1.5 px-1 text-[10.5px] leading-relaxed text-faint">{note}</p>}
445
+ </div>
446
+
447
+ <div ref={listRef} className="thin-scroll min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
448
+ {loading && models.length === 0 && (
449
+ <p className="flex items-center gap-2 px-3 py-4 text-[12px] text-faint">
450
+ <Spinner size={12} /> Loading live models…
451
+ </p>
452
+ )}
453
+ {filtered.map((model, i) => {
454
+ const selected = model.id === modelId
455
+ const highlighted = i === cursor
456
+ const thinks = model.reasoningOptions.length > 0
457
+ return (
458
+ <button
459
+ key={model.id}
460
+ type="button"
461
+ data-row={i}
462
+ onClick={() => {
463
+ setCursor(i)
464
+ choose(model)
465
+ }}
466
+ onMouseMove={() => highlighted || setCursor(i)}
467
+ style={{ animationDelay: `${Math.min(i * 22, 260)}ms` }}
468
+ className={cn(
469
+ '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',
470
+ selected ? 'bg-accent-soft' : highlighted ? 'bg-raised' : 'hover:bg-raised/70',
471
+ )}
472
+ >
473
+ <span
474
+ className={cn(
475
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-xl transition-all duration-300',
476
+ selected ? 'scale-100 bg-accent text-on-accent' : 'bg-raised text-faint group-hover/row:bg-panel',
477
+ )}
478
+ >
479
+ {selected ? <Check size={14} strokeWidth={2.4} /> : <Zap size={13} />}
480
+ </span>
481
+ <span className="min-w-0 flex-1">
482
+ <span className="flex items-center gap-2">
483
+ <span className={cn('truncate text-[13px] font-medium', selected ? 'text-accent' : 'text-ink')}>
484
+ {model.name || model.id}
485
+ </span>
486
+ {model.free && (
487
+ <span className="rounded-full bg-success/10 px-1.5 py-px text-[9px] font-medium text-success">free</span>
488
+ )}
489
+ {!model.toolCall && (
490
+ <span className="rounded-full bg-warning/10 px-1.5 py-px text-[9px] font-medium text-warning">no tools</span>
491
+ )}
492
+ </span>
493
+ <span className="mt-0.5 block truncate font-mono text-[10px] text-faint">{model.id}</span>
494
+ </span>
495
+ <span className="hidden shrink-0 items-center gap-2.5 text-faint sm:flex">
496
+ {thinks && <Brain size={12} className="text-accent/80" aria-label="reasoning" />}
497
+ {model.vision && <Eye size={12} aria-label="vision" />}
498
+ {model.context ? (
499
+ <span className="flex w-[74px] flex-col items-end gap-1">
500
+ <span className="font-mono text-[10px] tabular-nums">{contextLabel(model.context)}</span>
501
+ <span className="h-[3px] w-full overflow-hidden rounded-full bg-line">
502
+ <span
503
+ className={cn('block h-full rounded-full', selected ? 'bg-accent' : 'bg-faint/60')}
504
+ style={{ width: `${contextFill(model.context) * 100}%` }}
505
+ />
506
+ </span>
507
+ </span>
508
+ ) : null}
509
+ </span>
510
+ </button>
511
+ )
512
+ })}
513
+ {!loading && filtered.length === 0 && models.length > 0 && (
514
+ <p className="px-3 py-6 text-center text-[12px] text-faint">No model matches “{query}”.</p>
515
+ )}
516
+ {!loading && models.length === 0 && !error && (
517
+ <p className="px-3 py-6 text-center text-[12px] text-faint">This provider returned no usable chat models.</p>
518
+ )}
519
+
520
+ {showManual ? (
521
+ <form onSubmit={chooseManual} className="deck-row mt-1 flex items-center gap-2 px-1">
522
+ <input
523
+ autoFocus
524
+ value={manual}
525
+ onChange={(event) => setManual(event.target.value)}
526
+ placeholder="model-id"
527
+ 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"
528
+ aria-label="Model ID"
529
+ />
530
+ <button
531
+ type="submit"
532
+ disabled={!manual.trim()}
533
+ className="h-9 rounded-full bg-ink px-4 text-[11.5px] font-medium text-panel transition-opacity disabled:opacity-40"
534
+ >
535
+ Use
536
+ </button>
537
+ </form>
538
+ ) : (
539
+ <button
540
+ type="button"
541
+ onClick={() => setShowManual(true)}
542
+ 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"
543
+ >
544
+ <Plus size={13} /> Use a model ID that is not listed
545
+ </button>
546
+ )}
547
+ </div>
548
+ </>
549
+ )}
550
+ </section>
551
+
552
+ {/* Effort throttle */}
553
+ <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">
554
+ <div className="mb-4 flex items-center gap-2">
555
+ <Brain size={13} className="text-accent" />
556
+ <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted">Reasoning</p>
557
+ </div>
558
+
559
+ {efforts.length > 1 ? (
560
+ <>
561
+ <div className="hidden md:block">
562
+ <EffortThrottle
563
+ efforts={efforts}
564
+ value={normalizeEffort(settings.effort, active)}
565
+ onChange={(effort) => active && selectModel(provider, active.id, effort)}
566
+ disabled={!active}
567
+ />
568
+ </div>
569
+ <div className="flex gap-1 rounded-full bg-raised p-1 md:hidden">
570
+ {efforts.map((effort) => (
571
+ <button
572
+ key={effort}
573
+ type="button"
574
+ onClick={() => active && selectModel(provider, active.id, effort)}
575
+ className={cn(
576
+ 'h-8 min-w-0 flex-1 rounded-full text-[10.5px] font-medium transition-all',
577
+ settings.effort === effort ? 'bg-accent text-on-accent shadow-pill' : 'text-muted',
578
+ )}
579
+ >
580
+ {effortLabel(effort)}
581
+ </button>
582
+ ))}
583
+ </div>
584
+ </>
585
+ ) : (
586
+ <div className="flex h-[120px] flex-col justify-center rounded-2xl border border-dashed border-line px-4 text-center md:h-[280px]">
587
+ <p className="text-[12px] font-medium text-ink">{active ? 'No effort dial' : 'Pick a model'}</p>
588
+ <p className="mt-1 text-[11px] leading-relaxed text-muted">{reasoningSummary(active)}</p>
589
+ </div>
590
+ )}
591
+
592
+ {efforts.length > 1 && <p className="mt-4 hidden text-[10.5px] leading-relaxed text-faint md:block">{reasoningSummary(active)}</p>}
593
+ </aside>
594
+ </div>
595
+
596
+ <footer className="relative flex shrink-0 items-center gap-3 border-t border-line px-6 py-3 text-[10.5px] text-faint">
597
+ {active?.maxOutput ? <span>max output {contextLabel(active.maxOutput)}</span> : null}
598
+ {active?.apiKind ? <span className="rounded-md border border-line px-1.5 py-px font-mono">{active.apiKind}</span> : null}
599
+ <span className="ml-auto hidden sm:inline">Esc to close</span>
600
+ <button
601
+ type="button"
602
+ onClick={onClose}
603
+ className="inline-flex h-8 items-center rounded-full bg-ink px-5 text-[12px] font-medium text-panel transition-transform active:scale-95"
604
+ >
605
+ Done
606
+ </button>
607
+ </footer>
608
+ </div>
609
+ </div>,
610
+ document.body,
611
+ )
612
+ }
613
+
614
+ /** Composer trigger: model name with live effort bars, no dropdown chevron. */
615
+ export function ModelDeckTrigger({
616
+ label,
617
+ open,
618
+ disabled,
619
+ onClick,
620
+ triggerRef,
621
+ efforts,
622
+ effort,
623
+ title,
624
+ }: {
625
+ label: string
626
+ open: boolean
627
+ disabled: boolean
628
+ onClick: () => void
629
+ triggerRef: React.RefObject<HTMLButtonElement | null>
630
+ efforts: Effort[]
631
+ effort: Effort
632
+ title: string
633
+ }) {
634
+ return (
635
+ <button
636
+ ref={triggerRef}
637
+ type="button"
638
+ disabled={disabled}
639
+ onClick={onClick}
640
+ aria-haspopup="dialog"
641
+ aria-expanded={open}
642
+ title={title}
643
+ className={cn(
644
+ '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]',
645
+ open && 'border-accent/40 bg-accent-soft text-accent',
646
+ )}
647
+ >
648
+ <span className="truncate">{label}</span>
649
+ {efforts.length > 1 && <EffortBars efforts={efforts} value={effort} />}
650
+ </button>
651
+ )
652
+ }