Something went wrong. Try again.
The agent that grows with you
Something went wrong. Try again.
14 kB · 451 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452import { Button } from "@nous-research/ui/ui/components/button";import { ListItem } from "@nous-research/ui/ui/components/list-item";import { Spinner } from "@nous-research/ui/ui/components/spinner";import { Input } from "@/components/ui/input";import type { GatewayClient } from "@/lib/gatewayClient";import { Check, Search, X } from "lucide-react";import { useEffect, useMemo, useRef, useState } from "react";
/** * Two-stage model picker modal. * * Mirrors ui-tui/src/components/modelPicker.tsx: * Stage 1: pick provider (authenticated providers only) * Stage 2: pick model within that provider * * Two invocation modes: * * 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker * loads options via `model.options` JSON-RPC and emits the result as a * slash command string (`/model <model> --provider <slug> [--global]`) * through `onSubmit`, which the ChatPage pipes to `slashExec`. * * 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and * `onApply`. The picker fetches options via the REST endpoint and calls * `onApply(provider, model, persistGlobal)` instead of emitting a slash * command. This lets the Models page reuse the same UI without * requiring an open chat PTY. */
interface ModelOptionProvider { name: string; slug: string; models?: string[]; total_models?: number; is_current?: boolean; warning?: string;}
interface ModelOptionsResponse { model?: string; provider?: string; providers?: ModelOptionProvider[];}
interface Props { /** Chat-mode: when present, picker emits a slash command via onSubmit. */ gw?: GatewayClient; sessionId?: string; onSubmit?(slashCommand: string): void;
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */ loader?(): Promise<ModelOptionsResponse>; onApply?(args: { provider: string; model: string; persistGlobal: boolean; }): Promise<void> | void;
onClose(): void; title?: string; /** If true, hides "Persist globally" checkbox — always saves to config.yaml. */ alwaysGlobal?: boolean;}
export function ModelPickerDialog(props: Props) { const { gw, sessionId, onSubmit, loader, onApply, onClose, title = "Switch Model", alwaysGlobal = false, } = props; const standalone = !!loader && !!onApply;
const [providers, setProviders] = useState<ModelOptionProvider[]>([]); const [currentModel, setCurrentModel] = useState(""); const [currentProviderSlug, setCurrentProviderSlug] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [selectedSlug, setSelectedSlug] = useState(""); const [selectedModel, setSelectedModel] = useState(""); const [query, setQuery] = useState(""); const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal); const [applying, setApplying] = useState(false); const closedRef = useRef(false);
// Load providers + models on open. useEffect(() => { closedRef.current = false;
const promise = standalone ? (loader as () => Promise<ModelOptionsResponse>)() : (gw as GatewayClient).request<ModelOptionsResponse>( "model.options", sessionId ? { session_id: sessionId } : {}, );
promise .then((r) => { if (closedRef.current) return; const next = r?.providers ?? []; setProviders(next); setCurrentModel(String(r?.model ?? "")); setCurrentProviderSlug(String(r?.provider ?? "")); setSelectedSlug( (next.find((p) => p.is_current) ?? next[0])?.slug ?? "", ); setSelectedModel(""); setLoading(false); }) .catch((e) => { if (closedRef.current) return; setError(e instanceof Error ? e.message : String(e)); setLoading(false); });
return () => { closedRef.current = true; }; // Deliberately omit props from deps — stable for the dialog's lifetime. // eslint-disable-next-line react-hooks/exhaustive-deps }, []);
// Esc closes. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]);
const selectedProvider = useMemo( () => providers.find((p) => p.slug === selectedSlug) ?? null, [providers, selectedSlug], );
const models = useMemo( () => selectedProvider?.models ?? [], [selectedProvider], );
const needle = query.trim().toLowerCase();
const filteredProviders = useMemo( () => !needle ? providers : providers.filter( (p) => p.name.toLowerCase().includes(needle) || p.slug.toLowerCase().includes(needle) || (p.models ?? []).some((m) => m.toLowerCase().includes(needle)), ), [providers, needle], );
const filteredModels = useMemo( () => !needle ? models : models.filter((m) => m.toLowerCase().includes(needle)), [models, needle], );
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const confirm = async () => { if (!canConfirm || !selectedProvider) return; if (standalone && onApply) { setApplying(true); try { await onApply({ provider: selectedProvider.slug, model: selectedModel, persistGlobal, }); onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setApplying(false); } } else if (onSubmit) { const global = persistGlobal ? " --global" : ""; onSubmit( `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`, ); onClose(); } };
return ( <div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true" aria-labelledby="model-picker-title" > <div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col"> <Button ghost size="icon" onClick={onClose} className="absolute right-2 top-2 text-muted-foreground hover:text-foreground" aria-label="Close" > <X /> </Button>
<header className="p-5 pb-3 border-b border-border"> <h2 id="model-picker-title" className="font-display text-base tracking-wider uppercase" > {title} </h2> <p className="text-xs text-muted-foreground mt-1 font-mono"> current: {currentModel || "(unknown)"} {currentProviderSlug && ` · ${currentProviderSlug}`} </p> </header>
<div className="px-5 pt-3 pb-2 border-b border-border"> <div className="relative"> <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Input autoFocus placeholder="Filter providers and models…" value={query} onChange={(e) => setQuery(e.target.value)} className="pl-7 h-8 text-sm" /> </div> </div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden"> <ProviderColumn loading={loading} error={error} providers={filteredProviders} total={providers.length} selectedSlug={selectedSlug} query={needle} onSelect={(slug) => { setSelectedSlug(slug); setSelectedModel(""); }} />
<ModelColumn provider={selectedProvider} models={filteredModels} allModels={models} selectedModel={selectedModel} currentModel={currentModel} currentProviderSlug={currentProviderSlug} onSelect={setSelectedModel} onConfirm={(m) => { setSelectedModel(m); // Confirm on next tick so state settles. window.setTimeout(confirm, 0); }} /> </div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap"> {alwaysGlobal ? ( <span className="text-xs text-muted-foreground"> Saves to config.yaml — applies to new sessions. </span> ) : ( <label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none"> <input type="checkbox" checked={persistGlobal} onChange={(e) => setPersistGlobal(e.target.checked)} className="cursor-pointer" /> Persist globally (otherwise this session only) </label> )}
<div className="flex items-center gap-2 ml-auto"> <Button outlined onClick={onClose} disabled={applying}> Cancel </Button> <Button onClick={confirm} disabled={!canConfirm}> {applying ? <Spinner /> : "Switch"} </Button> </div> </footer> </div> </div> );}
/* ------------------------------------------------------------------ *//* Provider column *//* ------------------------------------------------------------------ */
function ProviderColumn({ loading, error, providers, total, selectedSlug, query, onSelect,}: { loading: boolean; error: string | null; providers: ModelOptionProvider[]; total: number; selectedSlug: string; query: string; onSelect(slug: string): void;}) { return ( <div className="border-r border-border overflow-y-auto"> {loading && ( <div className="flex items-center gap-2 p-4 text-xs text-muted-foreground"> <Spinner className="text-xs" /> loading… </div> )}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && ( <div className="p-4 text-xs text-muted-foreground italic"> {query ? "no matches" : total === 0 ? "no authenticated providers" : "no matches"} </div> )}
{providers.map((p) => { const active = p.slug === selectedSlug; return ( <ListItem key={p.slug} active={active} onClick={() => onSelect(p.slug)} className={`items-start text-xs border-l-2 ${ active ? "border-l-primary" : "border-l-transparent" }`} > <div className="flex-1 min-w-0"> <div className="flex items-center gap-1.5"> <span className="font-medium truncate">{p.name}</span> {p.is_current && <CurrentTag />} </div> <div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate"> {p.slug} · {p.total_models ?? p.models?.length ?? 0} models </div> </div> </ListItem> ); })} </div> );}
/* ------------------------------------------------------------------ *//* Model column *//* ------------------------------------------------------------------ */
function ModelColumn({ provider, models, allModels, selectedModel, currentModel, currentProviderSlug, onSelect, onConfirm,}: { provider: ModelOptionProvider | null; models: string[]; allModels: string[]; selectedModel: string; currentModel: string; currentProviderSlug: string; onSelect(model: string): void; onConfirm(model: string): void;}) { if (!provider) { return ( <div className="overflow-y-auto"> <div className="p-4 text-xs text-muted-foreground italic"> pick a provider → </div> </div> ); }
return ( <div className="overflow-y-auto"> {provider.warning && ( <div className="p-3 text-xs text-destructive border-b border-border"> {provider.warning} </div> )}
{models.length === 0 ? ( <div className="p-4 text-xs text-muted-foreground italic"> {allModels.length ? "no models match your filter" : "no models listed for this provider"} </div> ) : ( models.map((m) => { const active = m === selectedModel; const isCurrent = m === currentModel && provider.slug === currentProviderSlug;
return ( <ListItem key={m} active={active} onClick={() => onSelect(m)} onDoubleClick={() => onConfirm(m)} className="px-3 py-1.5 text-xs font-mono" > <Check className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`} /> <span className="flex-1 truncate">{m}</span> {isCurrent && <CurrentTag />} </ListItem> ); }) )} </div> );}
function CurrentTag() { return ( <span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0"> current </span> );}