ModelPickerDialog.tsx
1 import { Button } from "@nous-research/ui/ui/components/button"; 2 import { ListItem } from "@nous-research/ui/ui/components/list-item"; 3 import { Spinner } from "@nous-research/ui/ui/components/spinner"; 4 import { Input } from "@/components/ui/input"; 5 import type { GatewayClient } from "@/lib/gatewayClient"; 6 import { Check, Search, X } from "lucide-react"; 7 import { useEffect, useMemo, useRef, useState } from "react"; 8 9 /** 10 * Two-stage model picker modal. 11 * 12 * Mirrors ui-tui/src/components/modelPicker.tsx: 13 * Stage 1: pick provider (authenticated providers only) 14 * Stage 2: pick model within that provider 15 * 16 * Two invocation modes: 17 * 18 * 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker 19 * loads options via `model.options` JSON-RPC and emits the result as a 20 * slash command string (`/model <model> --provider <slug> [--global]`) 21 * through `onSubmit`, which the ChatPage pipes to `slashExec`. 22 * 23 * 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and 24 * `onApply`. The picker fetches options via the REST endpoint and calls 25 * `onApply(provider, model, persistGlobal)` instead of emitting a slash 26 * command. This lets the Models page reuse the same UI without 27 * requiring an open chat PTY. 28 */ 29 30 interface ModelOptionProvider { 31 name: string; 32 slug: string; 33 models?: string[]; 34 total_models?: number; 35 is_current?: boolean; 36 warning?: string; 37 } 38 39 interface ModelOptionsResponse { 40 model?: string; 41 provider?: string; 42 providers?: ModelOptionProvider[]; 43 } 44 45 interface Props { 46 /** Chat-mode: when present, picker emits a slash command via onSubmit. */ 47 gw?: GatewayClient; 48 sessionId?: string; 49 onSubmit?(slashCommand: string): void; 50 51 /** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */ 52 loader?(): Promise<ModelOptionsResponse>; 53 onApply?(args: { 54 provider: string; 55 model: string; 56 persistGlobal: boolean; 57 }): Promise<void> | void; 58 59 onClose(): void; 60 title?: string; 61 /** If true, hides "Persist globally" checkbox — always saves to config.yaml. */ 62 alwaysGlobal?: boolean; 63 } 64 65 export function ModelPickerDialog(props: Props) { 66 const { 67 gw, 68 sessionId, 69 onSubmit, 70 loader, 71 onApply, 72 onClose, 73 title = "Switch Model", 74 alwaysGlobal = false, 75 } = props; 76 const standalone = !!loader && !!onApply; 77 78 const [providers, setProviders] = useState<ModelOptionProvider[]>([]); 79 const [currentModel, setCurrentModel] = useState(""); 80 const [currentProviderSlug, setCurrentProviderSlug] = useState(""); 81 const [loading, setLoading] = useState(true); 82 const [error, setError] = useState<string | null>(null); 83 const [selectedSlug, setSelectedSlug] = useState(""); 84 const [selectedModel, setSelectedModel] = useState(""); 85 const [query, setQuery] = useState(""); 86 const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal); 87 const [applying, setApplying] = useState(false); 88 const closedRef = useRef(false); 89 90 // Load providers + models on open. 91 useEffect(() => { 92 closedRef.current = false; 93 94 const promise = standalone 95 ? (loader as () => Promise<ModelOptionsResponse>)() 96 : (gw as GatewayClient).request<ModelOptionsResponse>( 97 "model.options", 98 sessionId ? { session_id: sessionId } : {}, 99 ); 100 101 promise 102 .then((r) => { 103 if (closedRef.current) return; 104 const next = r?.providers ?? []; 105 setProviders(next); 106 setCurrentModel(String(r?.model ?? "")); 107 setCurrentProviderSlug(String(r?.provider ?? "")); 108 setSelectedSlug( 109 (next.find((p) => p.is_current) ?? next[0])?.slug ?? "", 110 ); 111 setSelectedModel(""); 112 setLoading(false); 113 }) 114 .catch((e) => { 115 if (closedRef.current) return; 116 setError(e instanceof Error ? e.message : String(e)); 117 setLoading(false); 118 }); 119 120 return () => { 121 closedRef.current = true; 122 }; 123 // Deliberately omit props from deps — stable for the dialog's lifetime. 124 // eslint-disable-next-line react-hooks/exhaustive-deps 125 }, []); 126 127 // Esc closes. 128 useEffect(() => { 129 const onKey = (e: KeyboardEvent) => { 130 if (e.key === "Escape") { 131 e.preventDefault(); 132 onClose(); 133 } 134 }; 135 window.addEventListener("keydown", onKey); 136 return () => window.removeEventListener("keydown", onKey); 137 }, [onClose]); 138 139 const selectedProvider = useMemo( 140 () => providers.find((p) => p.slug === selectedSlug) ?? null, 141 [providers, selectedSlug], 142 ); 143 144 const models = useMemo( 145 () => selectedProvider?.models ?? [], 146 [selectedProvider], 147 ); 148 149 const needle = query.trim().toLowerCase(); 150 151 const filteredProviders = useMemo( 152 () => 153 !needle 154 ? providers 155 : providers.filter( 156 (p) => 157 p.name.toLowerCase().includes(needle) || 158 p.slug.toLowerCase().includes(needle) || 159 (p.models ?? []).some((m) => m.toLowerCase().includes(needle)), 160 ), 161 [providers, needle], 162 ); 163 164 const filteredModels = useMemo( 165 () => 166 !needle ? models : models.filter((m) => m.toLowerCase().includes(needle)), 167 [models, needle], 168 ); 169 170 const canConfirm = !!selectedProvider && !!selectedModel && !applying; 171 172 const confirm = async () => { 173 if (!canConfirm || !selectedProvider) return; 174 if (standalone && onApply) { 175 setApplying(true); 176 try { 177 await onApply({ 178 provider: selectedProvider.slug, 179 model: selectedModel, 180 persistGlobal, 181 }); 182 onClose(); 183 } catch (e) { 184 setError(e instanceof Error ? e.message : String(e)); 185 } finally { 186 setApplying(false); 187 } 188 } else if (onSubmit) { 189 const global = persistGlobal ? " --global" : ""; 190 onSubmit( 191 `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`, 192 ); 193 onClose(); 194 } 195 }; 196 197 return ( 198 <div 199 className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4" 200 onClick={(e) => e.target === e.currentTarget && onClose()} 201 role="dialog" 202 aria-modal="true" 203 aria-labelledby="model-picker-title" 204 > 205 <div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col"> 206 <Button 207 ghost 208 size="icon" 209 onClick={onClose} 210 className="absolute right-2 top-2 text-muted-foreground hover:text-foreground" 211 aria-label="Close" 212 > 213 <X /> 214 </Button> 215 216 <header className="p-5 pb-3 border-b border-border"> 217 <h2 218 id="model-picker-title" 219 className="font-display text-base tracking-wider uppercase" 220 > 221 {title} 222 </h2> 223 <p className="text-xs text-muted-foreground mt-1 font-mono"> 224 current: {currentModel || "(unknown)"} 225 {currentProviderSlug && ` · ${currentProviderSlug}`} 226 </p> 227 </header> 228 229 <div className="px-5 pt-3 pb-2 border-b border-border"> 230 <div className="relative"> 231 <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> 232 <Input 233 autoFocus 234 placeholder="Filter providers and models…" 235 value={query} 236 onChange={(e) => setQuery(e.target.value)} 237 className="pl-7 h-8 text-sm" 238 /> 239 </div> 240 </div> 241 242 <div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden"> 243 <ProviderColumn 244 loading={loading} 245 error={error} 246 providers={filteredProviders} 247 total={providers.length} 248 selectedSlug={selectedSlug} 249 query={needle} 250 onSelect={(slug) => { 251 setSelectedSlug(slug); 252 setSelectedModel(""); 253 }} 254 /> 255 256 <ModelColumn 257 provider={selectedProvider} 258 models={filteredModels} 259 allModels={models} 260 selectedModel={selectedModel} 261 currentModel={currentModel} 262 currentProviderSlug={currentProviderSlug} 263 onSelect={setSelectedModel} 264 onConfirm={(m) => { 265 setSelectedModel(m); 266 // Confirm on next tick so state settles. 267 window.setTimeout(confirm, 0); 268 }} 269 /> 270 </div> 271 272 <footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap"> 273 {alwaysGlobal ? ( 274 <span className="text-xs text-muted-foreground"> 275 Saves to config.yaml — applies to new sessions. 276 </span> 277 ) : ( 278 <label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none"> 279 <input 280 type="checkbox" 281 checked={persistGlobal} 282 onChange={(e) => setPersistGlobal(e.target.checked)} 283 className="cursor-pointer" 284 /> 285 Persist globally (otherwise this session only) 286 </label> 287 )} 288 289 <div className="flex items-center gap-2 ml-auto"> 290 <Button outlined onClick={onClose} disabled={applying}> 291 Cancel 292 </Button> 293 <Button onClick={confirm} disabled={!canConfirm}> 294 {applying ? <Spinner /> : "Switch"} 295 </Button> 296 </div> 297 </footer> 298 </div> 299 </div> 300 ); 301 } 302 303 /* ------------------------------------------------------------------ */ 304 /* Provider column */ 305 /* ------------------------------------------------------------------ */ 306 307 function ProviderColumn({ 308 loading, 309 error, 310 providers, 311 total, 312 selectedSlug, 313 query, 314 onSelect, 315 }: { 316 loading: boolean; 317 error: string | null; 318 providers: ModelOptionProvider[]; 319 total: number; 320 selectedSlug: string; 321 query: string; 322 onSelect(slug: string): void; 323 }) { 324 return ( 325 <div className="border-r border-border overflow-y-auto"> 326 {loading && ( 327 <div className="flex items-center gap-2 p-4 text-xs text-muted-foreground"> 328 <Spinner className="text-xs" /> loading… 329 </div> 330 )} 331 332 {error && <div className="p-4 text-xs text-destructive">{error}</div>} 333 334 {!loading && !error && providers.length === 0 && ( 335 <div className="p-4 text-xs text-muted-foreground italic"> 336 {query 337 ? "no matches" 338 : total === 0 339 ? "no authenticated providers" 340 : "no matches"} 341 </div> 342 )} 343 344 {providers.map((p) => { 345 const active = p.slug === selectedSlug; 346 return ( 347 <ListItem 348 key={p.slug} 349 active={active} 350 onClick={() => onSelect(p.slug)} 351 className={`items-start text-xs border-l-2 ${ 352 active ? "border-l-primary" : "border-l-transparent" 353 }`} 354 > 355 <div className="flex-1 min-w-0"> 356 <div className="flex items-center gap-1.5"> 357 <span className="font-medium truncate">{p.name}</span> 358 {p.is_current && <CurrentTag />} 359 </div> 360 <div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate"> 361 {p.slug} · {p.total_models ?? p.models?.length ?? 0} models 362 </div> 363 </div> 364 </ListItem> 365 ); 366 })} 367 </div> 368 ); 369 } 370 371 /* ------------------------------------------------------------------ */ 372 /* Model column */ 373 /* ------------------------------------------------------------------ */ 374 375 function ModelColumn({ 376 provider, 377 models, 378 allModels, 379 selectedModel, 380 currentModel, 381 currentProviderSlug, 382 onSelect, 383 onConfirm, 384 }: { 385 provider: ModelOptionProvider | null; 386 models: string[]; 387 allModels: string[]; 388 selectedModel: string; 389 currentModel: string; 390 currentProviderSlug: string; 391 onSelect(model: string): void; 392 onConfirm(model: string): void; 393 }) { 394 if (!provider) { 395 return ( 396 <div className="overflow-y-auto"> 397 <div className="p-4 text-xs text-muted-foreground italic"> 398 pick a provider → 399 </div> 400 </div> 401 ); 402 } 403 404 return ( 405 <div className="overflow-y-auto"> 406 {provider.warning && ( 407 <div className="p-3 text-xs text-destructive border-b border-border"> 408 {provider.warning} 409 </div> 410 )} 411 412 {models.length === 0 ? ( 413 <div className="p-4 text-xs text-muted-foreground italic"> 414 {allModels.length 415 ? "no models match your filter" 416 : "no models listed for this provider"} 417 </div> 418 ) : ( 419 models.map((m) => { 420 const active = m === selectedModel; 421 const isCurrent = 422 m === currentModel && provider.slug === currentProviderSlug; 423 424 return ( 425 <ListItem 426 key={m} 427 active={active} 428 onClick={() => onSelect(m)} 429 onDoubleClick={() => onConfirm(m)} 430 className="px-3 py-1.5 text-xs font-mono" 431 > 432 <Check 433 className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`} 434 /> 435 <span className="flex-1 truncate">{m}</span> 436 {isCurrent && <CurrentTag />} 437 </ListItem> 438 ); 439 }) 440 )} 441 </div> 442 ); 443 } 444 445 function CurrentTag() { 446 return ( 447 <span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0"> 448 current 449 </span> 450 ); 451 }