import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { GatewayClient } from "@/lib/gatewayClient"; import { Check, Loader2, 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 * * On confirm, emits `/model --provider [--global]` through * the parent callback so ChatPage can dispatch it via the existing slash * pipeline. That keeps persistence + actual switch logic in one place. */ 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 { gw: GatewayClient; sessionId: string; onClose(): void; /** Parent runs the resulting slash command through slashExec. */ onSubmit(slashCommand: string): void; } export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) { 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(false); const closedRef = useRef(false); // Load providers + models on open. useEffect(() => { closedRef.current = false; gw.request( "model.options", sessionId ? { session_id: sessionId } : {}, ) .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; }; }, [gw, sessionId]); // 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; const confirm = () => { if (!canConfirm) return; const global = persistGlobal ? " --global" : ""; onSubmit( `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`, ); onClose(); }; return (
e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true" aria-labelledby="model-picker-title" >

Switch Model

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

setQuery(e.target.value)} className="pl-7 h-8 text-sm" />
{ setSelectedSlug(slug); setSelectedModel(""); }} /> { setSelectedModel(m); // Confirm on next tick so state settles. window.setTimeout(confirm, 0); }} />
); } /* ------------------------------------------------------------------ */ /* 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 ( ); })}
); } /* ------------------------------------------------------------------ */ /* 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 (
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((m) => { const active = m === selectedModel; const isCurrent = m === currentModel && provider.slug === currentProviderSlug; return ( ); }) )}
); } function CurrentTag() { return ( current ); }