| 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 { Label } from "@/components/ui/label";
|
| 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";
|
| import { createPortal } from "react-dom";
|
| import { cn, themedBody } from "@/lib/utils";
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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;
|
| onSubmit?(slashCommand: string): void;
|
|
|
|
|
| loader?(): Promise<ModelOptionsResponse>;
|
| onApply?(args: {
|
| provider: string;
|
| model: string;
|
| persistGlobal: boolean;
|
| }): Promise<void> | void;
|
|
|
| onClose(): void;
|
| title?: string;
|
|
|
| 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);
|
|
|
|
|
| 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;
|
| };
|
|
|
|
|
| }, []);
|
|
|
|
|
| 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 createPortal(
|
| <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={cn(themedBody, "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-mondwest text-display text-base tracking-wider"
|
| >
|
| {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>
|
| ) : (
|
| <div className="flex items-center gap-2">
|
| <Checkbox
|
| checked={persistGlobal}
|
| id="model-picker-persist-global"
|
| onCheckedChange={(checked) =>
|
| setPersistGlobal(checked === true)
|
| }
|
| />
|
|
|
| <Label
|
| className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
|
| htmlFor="model-picker-persist-global"
|
| >
|
| Persist globally (otherwise this session only)
|
| </Label>
|
| </div>
|
| )}
|
|
|
| <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>,
|
| document.body,
|
| );
|
| }
|
|
|
|
|
|
|
|
|
|
|
| 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-xs text-text-secondary font-mono truncate">
|
| {p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
| </div>
|
| </div>
|
| </ListItem>
|
| );
|
| })}
|
| </div>
|
| );
|
| }
|
|
|
|
|
|
|
|
|
|
|
| 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-display text-xs tracking-wider text-primary shrink-0">
|
| current
|
| </span>
|
| );
|
| }
|
|
|