/* * Copyright (c) 2026 Pawan Osman * * This file is part of OpenCursor — AI coding agent chat inside VS Code. * https://github.com/PawanOsman/OpenCursor * * Licensed under the MIT License. See LICENSE file in the project root. */ import * as React from "react"; /** Minimal structural shape shared by sidebar ModelDef and settings ModelDef. */ export interface ModelSelectItem { id: string; name: string; kind?: string | string[]; providerName?: string; } /** Custom entries pinned above the model list (e.g. "First enabled model", "(inherit chat model)"). */ export interface ModelSelectCustom { value: string; label: string; desc?: string; } const groupOf = (m: ModelSelectItem): string => { const k = Array.isArray(m.kind) ? m.kind[0] : m.kind; if (k === "llamacpp") return "Local · llama.cpp"; if (k === "ollama") return "Local · Ollama"; return m.providerName || "Other"; }; /** * Unified model selector: a trigger button that opens a modal dialog with * search, provider filter chips, and the enabled models grouped by provider. * `customItems` render pinned at the top (judge "first enabled", subagent * "inherit chat model", etc.). */ export function ModelSelect({ models, value, onChange, customItems, style, }: { models: ModelSelectItem[]; value: string; onChange: (id: string) => void; customItems?: ModelSelectCustom[]; style?: React.CSSProperties; }) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const [provider, setProvider] = React.useState(null); // null = all const groups = React.useMemo(() => { const map = new Map(); for (const m of models) { const g = groupOf(m); (map.get(g) ?? map.set(g, []).get(g)!).push(m); } return [...map.entries()].sort((a, b) => Number(a[0].startsWith("Local ")) - Number(b[0].startsWith("Local "))); }, [models]); const q = query.trim().toLowerCase(); const visibleGroups = groups .filter(([g]) => provider === null || g === provider) .map(([g, list]) => [g, q ? list.filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) : list] as const) .filter(([, list]) => list.length > 0); const visibleCustom = (customItems || []).filter( (c) => (provider === null) && (!q || c.label.toLowerCase().includes(q)) ); const current = customItems?.find((c) => c.value === value)?.label ?? models.find((m) => m.id === value)?.name ?? (value || customItems?.[0]?.label || "Select model"); const close = () => { setOpen(false); setQuery(""); setProvider(null); }; const pick = (v: string) => { onChange(v); close(); }; // Esc closes the dialog. React.useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); return ( <> {open && (
e.target === e.currentTarget && close()}>
setQuery(e.target.value)} />
{groups.map(([g]) => ( ))}
{visibleCustom.map((c) => (
pick(c.value)} > {c.label} {c.desc && {c.desc}} {value === c.value && }
))} {visibleCustom.length > 0 && visibleGroups.length > 0 &&
} {visibleGroups.length === 0 && visibleCustom.length === 0 &&
No matches
} {visibleGroups.map(([g, list]) => (
{g}
{list.map((m) => (
pick(m.id)}> {m.name} {m.id !== m.name && {m.id}} {value === m.id && }
))}
))}
)} ); }