import { Button } from "@nous-research/ui/ui/components/button"; import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import type { GatewayClient } from "@/lib/gatewayClient"; import { Check, Search, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; import { fuzzyRank } from "@/lib/fuzzy"; /** * 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 applies the choice via * `config.set`, so expensive-model confirmation can happen before switch. * * 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 ExpensiveModelConfirmResponse { confirm_message?: string; confirm_required?: boolean; warning?: string; } interface ConfigSetResponse extends ExpensiveModelConfirmResponse { value?: string; } interface PendingExpensiveConfirm { message: string; model: string; persistGlobal: boolean; provider: string; } 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; onApply?(args: { confirmExpensiveModel?: boolean; provider: string; model: string; persistGlobal: boolean; }): | Promise | ExpensiveModelConfirmResponse | 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([]); const [currentModel, setCurrentModel] = useState(""); const [currentProviderSlug, setCurrentProviderSlug] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedSlug, setSelectedSlug] = useState(""); const [selectedModel, setSelectedModel] = useState(""); const [query, setQuery] = useState(""); const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal); const [applying, setApplying] = useState(false); const [pendingConfirm, setPendingConfirm] = useState(null); const closedRef = useRef(false); // Load providers + models on open. useEffect(() => { closedRef.current = false; const promise = standalone ? (loader as () => Promise)() : (gw as GatewayClient).request( "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 trimmedQuery = query.trim(); // Fuzzy-ranked providers: match on name + slug + the provider's model ids so // typing a model name surfaces its provider (preserves the prior behaviour // where a model match also revealed its provider). const filteredProviders = useMemo( () => fuzzyRank( providers, trimmedQuery, (p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`, ).map((r) => r.item), [providers, trimmedQuery], ); // Fuzzy-ranked models carrying the matched character positions so the model // list can highlight why each entry matched. const filteredModels = useMemo( () => fuzzyRank(models, trimmedQuery, (m) => m).map((r) => ({ model: r.item, positions: r.positions, })), [models, trimmedQuery], ); const canConfirm = !!selectedProvider && !!selectedModel && !applying; const applySelection = async ( confirmExpensiveModel = false, forced?: PendingExpensiveConfirm, ) => { const providerSlug = forced?.provider ?? selectedProvider?.slug ?? ""; const model = forced?.model ?? selectedModel; const shouldPersistGlobal = forced?.persistGlobal ?? persistGlobal; if (!providerSlug || !model || applying) return; if (standalone && onApply) { setApplying(true); try { const result = await onApply({ confirmExpensiveModel, provider: providerSlug, model, persistGlobal: shouldPersistGlobal, }); if (result?.confirm_required) { setPendingConfirm({ provider: providerSlug, model, persistGlobal: shouldPersistGlobal, message: result.confirm_message || result.warning || "This model has unusually high known pricing.", }); return; } onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setApplying(false); } } else if (gw && sessionId) { setApplying(true); try { const global = shouldPersistGlobal ? " --global" : ""; const result = await gw.request("config.set", { confirm_expensive_model: confirmExpensiveModel, key: "model", session_id: sessionId, value: `${model} --provider ${providerSlug}${global}`, }); if (result?.confirm_required) { setPendingConfirm({ provider: providerSlug, model, persistGlobal: shouldPersistGlobal, message: result.confirm_message || result.warning || "This model has unusually high known pricing.", }); return; } onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setApplying(false); } } else if (onSubmit) { const global = shouldPersistGlobal ? " --global" : ""; onSubmit(`/model ${model} --provider ${providerSlug}${global}`); onClose(); } }; const confirm = () => { if (!canConfirm) return; void applySelection(); }; // Portal to document.body: the main dashboard column in App.tsx is // `relative z-2`, which creates a stacking context that traps fixed // descendants below the app sidebar (z-50). Without the portal this // modal's z-[100] is scoped to z-2 and the sidebar covers its left // edge — visible especially in the Large theme variants where the // larger root font widens the dialog into the sidebar's column. See // Toast.tsx for the same pattern. return createPortal(
e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true" aria-labelledby="model-picker-title" >

{title}

current: {currentModel || "(unknown)"} {currentProviderSlug && ` · ${currentProviderSlug}`}

setQuery(e.target.value)} className="pl-7 h-8 text-sm" />
{ setSelectedSlug(slug); setSelectedModel(""); }} /> { setSelectedModel(m); void applySelection(false, { provider: selectedProvider?.slug ?? "", model: m, persistGlobal, message: "", }); }} />
{alwaysGlobal ? ( Saves to config.yaml — applies to new sessions. ) : (
setPersistGlobal(checked === true) } />
)}
setPendingConfirm(null)} onConfirm={() => { const pending = pendingConfirm; if (!pending) return; setPendingConfirm(null); void applySelection(true, pending); }} />
, document.body, ); } /* ------------------------------------------------------------------ */ /* 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 (
{loading && (
loading…
)} {error &&
{error}
} {!loading && !error && providers.length === 0 && (
{query ? "no matches" : total === 0 ? "no authenticated providers" : "no matches"}
)} {providers.map((p) => { const active = p.slug === selectedSlug; return ( onSelect(p.slug)} className={`items-start text-xs border-l-2 ${ active ? "border-l-primary" : "border-l-transparent" }`} >
{p.name} {p.is_current && }
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
); })}
); } /* ------------------------------------------------------------------ */ /* Model column */ /* ------------------------------------------------------------------ */ function ModelColumn({ provider, models, allModels, selectedModel, currentModel, currentProviderSlug, onSelect, onConfirm, }: { provider: ModelOptionProvider | null; models: { model: string; positions: number[] }[]; allModels: string[]; selectedModel: string; currentModel: string; currentProviderSlug: string; onSelect(model: string): void; onConfirm(model: string): void; }) { if (!provider) { return (
pick a provider →
); } return (
{provider.warning && (
{provider.warning}
)} {models.length === 0 ? (
{allModels.length ? "no models match your filter" : "no models listed for this provider"}
) : ( models.map(({ model: m, positions }) => { const active = m === selectedModel; const isCurrent = m === currentModel && provider.slug === currentProviderSlug; return ( onSelect(m)} onDoubleClick={() => onConfirm(m)} className="px-3 py-1.5 text-xs font-mono" > {isCurrent && } ); }) )}
); } function CurrentTag() { return ( current ); } /** * Render `text` with the characters at `positions` emphasised, so users can * see which characters their fuzzy query matched. Positions are indices into * `text`; out-of-range indices are ignored. */ function HighlightedText({ text, positions, }: { text: string; positions: number[]; }) { if (!positions.length) { return <>{text}; } const hit = new Set(positions); return ( <> {Array.from(text).map((ch, i) => hit.has(i) ? ( {ch} ) : ( {ch} ), )} ); }