@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.
- package/.github/workflows/build.yml +60 -8
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/desktop/README.md +39 -0
- package/desktop/loading/index.html +36 -0
- package/desktop/src-tauri/Cargo.lock +4301 -0
- package/desktop/src-tauri/Cargo.toml +23 -0
- package/desktop/src-tauri/binaries/.gitkeep +0 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +7 -0
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/lib.rs +210 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +48 -0
- package/dist/assets/index-CeN54A2N.css +1 -0
- package/dist/assets/index-fhIO9a7_.js +318 -0
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +2 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +138 -2
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +899 -0
- package/server/lib/mcp.test.mjs +46 -0
- package/server/lib/store.mjs +22 -0
- package/server/lib/tools.mjs +59 -1
- package/src/App.tsx +2 -0
- package/src/agent/tools.ts +6 -5
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +661 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +146 -71
- package/src/components/Sidebar.tsx +79 -24
- package/src/components/Splash.tsx +1 -1
- package/src/components/ToolCallCard.tsx +29 -7
- package/src/components/WorkspacePanel.tsx +16 -1
- package/src/core/kernel.ts +2 -0
- package/src/core/mcp-tools.ts +63 -0
- package/src/core/plugins/tools/fs.ts +24 -3
- package/src/core/services/agent-loop.ts +3 -1
- package/src/core/services/approval.ts +2 -1
- package/src/core/services/titles.ts +135 -0
- package/src/core/services/tools.ts +11 -8
- package/src/core/types.ts +4 -0
- package/src/data/providers.ts +1 -1
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +38 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +94 -0
- package/src/store/settings.ts +10 -7
- package/src/store/ui.ts +17 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +63 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-BmN7pBji.css +0 -1
- package/dist/assets/index-DgNDjkOz.js +0 -301
- 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
|
+
}
|