"use client"; import { Brain, Check, ChevronDown, Eye, Search, Wrench, Zap } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState, } from "react"; import { Badge } from "@/components/motion/badge"; import { Kbd } from "@/components/motion/kbd"; import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger, } from "@/components/motion/popover-morph"; import { ScrollArea } from "@/components/motion/scroll-area"; import { SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type ModelCapability = "vision" | "tools" | "reasoning" | "fast"; export type ProviderGlyphShape = | "circle" | "square" | "triangle" | "hexagon" | "diamond" | "spark"; export interface ModelProvider { id: string; name: string; /** Generic geometric glyph. Defaults by provider order. */ shape?: ProviderGlyphShape; /** Replaces the generic glyph entirely (e.g. your own logo). */ icon?: ReactNode; } export interface ModelOption { value: string; name: string; /** `ModelProvider.id` the model is grouped under. */ provider: string; description?: string; capabilities?: ModelCapability[]; /** Context window in tokens. */ contextWindow?: number; /** Relative cost from 1 (cheapest) to 4. */ cost?: 1 | 2 | 3 | 4; isNew?: boolean; disabled?: boolean; } export interface ModelSelectorProps { models: ModelOption[]; /** Provider order and naming. Providers missing here are derived from `models`. */ providers?: ModelProvider[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; placeholder?: string; searchPlaceholder?: string; emptyMessage?: ReactNode; side?: "top" | "bottom"; align?: "start" | "end"; disabled?: boolean; /** Hide the keyboard hint footer. */ hideHints?: boolean; className?: string; triggerClassName?: string; contentClassName?: string; "aria-label"?: string; } const SHAPES: ProviderGlyphShape[] = [ "spark", "hexagon", "circle", "diamond", "square", "triangle", ]; const CAPABILITIES: Record< ModelCapability, { label: string; icon: ReactNode } > = { vision: { label: "Vision", icon: }, tools: { label: "Tools", icon: }, reasoning: { label: "Reasoning", icon: }, fast: { label: "Fast", icon: }, }; /** 200000 → "200k", 1048576 → "1M". */ export function formatContextWindow(tokens: number) { if (tokens >= 1_000_000) { const m = tokens / 1_000_000; return `${Number.isInteger(m) ? m : m.toFixed(1)}M`; } if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`; return String(tokens); } function GlyphShape({ shape }: { shape: ProviderGlyphShape }) { switch (shape) { case "circle": return ; case "square": return ; case "triangle": return ; case "hexagon": return ; case "diamond": return ; default: return ( ); } } /** Generic provider mark: a geometric shape on a tinted tile, colored by `--chart-*`. */ export function ProviderGlyph({ provider, index = 0, className, }: { provider?: ModelProvider; index?: number; className?: string; }) { const shape = provider?.shape ?? SHAPES[index % SHAPES.length]; return ( ); } function CostDots({ cost }: { cost: number }) { return ( Relative cost {cost} of 4 {[1, 2, 3, 4].map((dot) => ( ); } const optionId = (uid: string, value: string) => `${uid}-opt-${value.replace(/\W/g, "_")}`; function matches(model: ModelOption, providerName: string, query: string) { const haystack = [ model.name, providerName, model.description ?? "", ...(model.capabilities ?? []), ] .join(" ") .toLowerCase(); return query .toLowerCase() .split(/\s+/) .filter(Boolean) .every((token) => haystack.includes(token)); } export function ModelSelector({ models, providers, value, defaultValue, onValueChange, open, defaultOpen = false, onOpenChange, placeholder = "Choose a model", searchPlaceholder = "Search models…", emptyMessage = "No models match your search.", side = "bottom", align = "start", disabled = false, hideHints = false, className, triggerClassName, contentClassName, "aria-label": ariaLabel = "Model", }: ModelSelectorProps) { const reduce = useReducedMotion() ?? false; const uid = useId().replace(/:/g, ""); const triggerRef = useRef(null); const [internalValue, setInternalValue] = useState(defaultValue); const [internalOpen, setInternalOpen] = useState(defaultOpen); const current = value ?? internalValue; const isOpen = open ?? internalOpen; // Provider order: explicit list first, then any provider only seen in models. const providerList = useMemo(() => { const list = [...(providers ?? [])]; for (const model of models) { if (!list.some((provider) => provider.id === model.provider)) { list.push({ id: model.provider, name: model.provider }); } } return list; }, [models, providers]); const providerIndex = (id: string) => Math.max( 0, providerList.findIndex((provider) => provider.id === id), ); const selected = models.find((model) => model.value === current); const setOpen = (next: boolean) => { if (open === undefined) setInternalOpen(next); onOpenChange?.(next); }; const select = (model: ModelOption) => { if (model.disabled) return; if (value === undefined) setInternalValue(model.value); onValueChange?.(model.value); setOpen(false); triggerRef.current?.focus({ preventScroll: true }); }; return ( ); } function ModelList({ uid, models, providerList, providerIndex, current, reduce, searchPlaceholder, emptyMessage, hideHints, ariaLabel, onSelect, }: { uid: string; models: ModelOption[]; providerList: ModelProvider[]; providerIndex: (id: string) => number; current: string | undefined; reduce: boolean; searchPlaceholder: string; emptyMessage: ReactNode; hideHints: boolean; ariaLabel: string; onSelect: (model: ModelOption) => void; }) { const inputRef = useRef(null); const [query, setQuery] = useState(""); const listId = `${uid}-list`; const groups = useMemo( () => providerList .map((provider) => ({ provider, models: models.filter( (model) => model.provider === provider.id && matches(model, provider.name, query), ), })) .filter((group) => group.models.length > 0), [models, providerList, query], ); const enabled = useMemo( () => groups.flatMap((group) => group.models).filter((model) => !model.disabled), [groups], ); const [active, setActive] = useState(() => enabled.some((model) => model.value === current) ? current : enabled[0]?.value, ); // Keep the active row valid as the filter narrows the list. const activeValue = enabled.some((model) => model.value === active) ? active : enabled[0]?.value; // The panel mounts with the popover; hand focus to the search field once it // is laid out (the morph keeps it hidden until measured). useEffect(() => { const frame = requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }), ); return () => cancelAnimationFrame(frame); }, []); useEffect(() => { if (!activeValue) return; document .getElementById(optionId(uid, activeValue)) ?.scrollIntoView({ block: "nearest" }); }, [activeValue, uid]); const move = (delta: number) => { if (!enabled.length) return; const index = enabled.findIndex((model) => model.value === activeValue); const next = (index + delta + enabled.length) % enabled.length; setActive(enabled[next].value); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); move(1); } else if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } else if (event.key === "PageDown" || (event.key === "End" && event.ctrlKey)) { event.preventDefault(); setActive(enabled.at(-1)?.value); } else if (event.key === "PageUp" || (event.key === "Home" && event.ctrlKey)) { event.preventDefault(); setActive(enabled[0]?.value); } else if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); const model = enabled.find((item) => item.value === activeValue); if (model) onSelect(model); } }; return (
{groups.map((group) => { const index = providerIndex(group.provider.id); return (
{group.provider.name} {group.models.map((model) => { const isActive = model.value === activeValue; const isSelected = model.value === current; return ( ); })}
); })} {groups.length === 0 ? (

{emptyMessage}

) : null}
{hideHints ? null : (
up down Navigate enter Select esc Close
)}
); }