{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-select",
  "title": "ModelSelect",
  "description": "Model picker for agent chat input bars, grouped by provider.",
  "dependencies": [
    "motion",
    "@base-ui/react",
    "@hugeicons/core-free-icons",
    "@hugeicons/react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/select.json",
    "https://ui.neon.com/r/neon-tokens.json"
  ],
  "files": [
    {
      "path": "src/components/model-select/model-select.tsx",
      "content": "\"use client\";\n\nimport { Select as SelectPrimitive } from \"@base-ui/react/select\";\nimport { Search01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { domAnimation, LazyMotion, m } from \"motion/react\";\nimport type { ComponentProps, KeyboardEvent, ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nimport {\n  Select,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AiModel {\n  /** Gateway model id in short form, e.g. \"gpt-5-2\" or \"gemini-3-pro\". */\n  id: string;\n  /** Human-readable name, e.g. \"GPT-5.2\". */\n  name: string;\n  /** Provider name used for grouping, e.g. \"OpenAI\". */\n  provider: string;\n  /** Whether the model supports extended reasoning (reasoning_effort). */\n  reasoning?: boolean;\n  /** Mark a model as not yet available; it renders dimmed and unselectable. */\n  disabled?: boolean;\n  /** Optional short capability tag, e.g. \"fast\" or \"open\". */\n  tag?: string;\n}\n\nexport type ModelSelectSize = \"sm\" | \"md\" | \"lg\";\n\nexport type ModelSelectProps = Omit<\n  ComponentProps<typeof SelectTrigger>,\n  \"children\" | \"value\" | \"size\"\n> & {\n  /** Models to choose from; grouped by provider in listed order. */\n  models: AiModel[];\n  /** Controlled selected model id. */\n  value?: string;\n  /** Uncontrolled initial model id. */\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  placeholder?: ReactNode;\n  /** Trigger size: compact, default, or roomy. */\n  size?: ModelSelectSize;\n  /** Provider logos keyed by provider name; without a logo no mark is shown. */\n  logos?: Record<string, ReactNode>;\n  /** Model ids to hide from the list. */\n  excludeModels?: string[];\n  /**\n   * Controlled usage only: when the current value is not in `models`\n   * (e.g. a hardcoded default this gateway has not enabled), call\n   * `onValueChange` with the first available model instead of sitting\n   * on a placeholder.\n   */\n  fallbackToFirst?: boolean;\n  /** Extra content rendered after the model name in the trigger. */\n  valueSuffix?: ReactNode;\n  /** Pinned footer rendered below the scrolling list inside the popup. */\n  footer?: ReactNode;\n  /** Runs before the popup's built-in key handling; preventDefault to claim a key. */\n  onPopupKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;\n  /** Mount the popup inside a specific element, e.g. a themed subtree. */\n  portalContainer?: SelectPrimitive.Portal.Props[\"container\"];\n};\n\nconst TRIGGER_SIZE: Record<ModelSelectSize, string> = {\n  lg: \"h-10 gap-2 pl-3 text-sm\",\n  md: \"h-8\",\n  sm: \"h-7 gap-1 text-xs [&_[data-slot=model-select-logo]]:size-3.5\",\n};\n\nconst groupByProvider = (models: AiModel[]) => {\n  const groups = new Map<string, AiModel[]>();\n\n  for (const model of models) {\n    const group = groups.get(model.provider) ?? [];\n    group.push(model);\n    groups.set(model.provider, group);\n  }\n\n  return [...groups.entries()];\n};\n\n/* ─────────────────────────────────────────────────────────\n * HIGHLIGHT STORYBOARD\n *\n * One shared background glides behind whichever model row is\n * highlighted (pointer or keyboard) on a snappy spring, with a\n * primary edge on its left. It fades out when nothing is\n * highlighted, so opening feels calm and browsing feels alive.\n * ───────────────────────────────────────────────────────── */\n/** Snappy follow that settles quickly with no wobble. */\nconst GLIDE_SPRING = {\n  damping: 38,\n  stiffness: 520,\n  type: \"spring\" as const,\n};\n\nconst HighlightGlide = () => {\n  const ref = useRef<HTMLSpanElement>(null);\n  const [rect, setRect] = useState<{ height: number; top: number } | null>(\n    null\n  );\n\n  useEffect(() => {\n    const popup = ref.current?.closest('[data-slot=\"model-select-scroller\"]');\n\n    if (!(popup instanceof HTMLElement)) {\n      return;\n    }\n\n    const update = (target: EventTarget | null) => {\n      const item =\n        target instanceof Element\n          ? target.closest('[data-slot=\"select-item\"]')\n          : null;\n\n      if (item instanceof HTMLElement) {\n        setRect({ height: item.offsetHeight, top: item.offsetTop });\n      }\n    };\n\n    const clear = (event: FocusEvent) => {\n      if (\n        !(\n          event.relatedTarget instanceof Element &&\n          popup.contains(event.relatedTarget)\n        )\n      ) {\n        setRect(null);\n      }\n    };\n\n    const handleFocusIn = (event: FocusEvent) => update(event.target);\n    const handlePointerMove = (event: PointerEvent) => update(event.target);\n\n    popup.addEventListener(\"focusin\", handleFocusIn);\n    popup.addEventListener(\"pointermove\", handlePointerMove);\n    popup.addEventListener(\"focusout\", clear);\n\n    return () => {\n      popup.removeEventListener(\"focusin\", handleFocusIn);\n      popup.removeEventListener(\"pointermove\", handlePointerMove);\n      popup.removeEventListener(\"focusout\", clear);\n    };\n  }, []);\n\n  return (\n    <LazyMotion features={domAnimation} strict>\n      <m.span\n        animate={\n          rect\n            ? { height: rect.height, opacity: 1, top: rect.top }\n            : { opacity: 0 }\n        }\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-1 border-primary border-l-2 bg-accent\"\n        initial={false}\n        ref={ref}\n        transition={GLIDE_SPRING}\n      />\n    </LazyMotion>\n  );\n};\n\n/** Centers the selected model in the scroller when the popup opens. */\nconst ScrollToSelected = () => {\n  const ref = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const scroller = ref.current?.closest(\n      '[data-slot=\"model-select-scroller\"]'\n    );\n    const selected = scroller?.querySelector(\"[data-selected]\");\n\n    if (scroller instanceof HTMLElement && selected instanceof HTMLElement) {\n      scroller.scrollTop =\n        selected.offsetTop -\n        scroller.clientHeight / 2 +\n        selected.offsetHeight / 2;\n    }\n  }, []);\n\n  return <span hidden ref={ref} />;\n};\n\n/* ─────────────────────────────────────────────────────────\n * POPUP STORYBOARD\n *\n * Fixed search on top (focused on open), an optional pinned\n * footer on the bottom, and the model list scrolling between\n * them with a scroll-aware edge fade and a thin scrollbar.\n * Typing filters instantly; arrows still walk the list.\n * ───────────────────────────────────────────────────────── */\nconst SearchField = ({\n  inputRef,\n  onQueryChange,\n  query,\n}: {\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  onQueryChange: (query: string) => void;\n  query: string;\n}) => {\n  useEffect(() => {\n    const frame = requestAnimationFrame(() => inputRef.current?.focus());\n    return () => cancelAnimationFrame(frame);\n  }, [inputRef]);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Escape\" && query) {\n      event.stopPropagation();\n      onQueryChange(\"\");\n      return;\n    }\n\n    // Keep typing local: block the select's built-in type-ahead while\n    // letting arrows, Enter, Escape, and Tab reach the popup.\n    if (event.key.length === 1 || event.key === \"Backspace\") {\n      event.stopPropagation();\n    }\n  };\n\n  return (\n    <div className=\"flex shrink-0 items-center gap-2 border-border/60 border-b bg-popover px-3 transition-colors focus-within:border-primary/50 [&:focus-within_svg]:text-foreground\">\n      <HugeiconsIcon\n        aria-hidden=\"true\"\n        icon={Search01Icon}\n        strokeWidth={2}\n        className=\"size-3.5 shrink-0 text-muted-foreground transition-colors\"\n      />\n      <input\n        aria-label=\"Search models\"\n        className=\"h-9 w-full bg-transparent text-base outline-none placeholder:text-muted-foreground sm:text-sm\"\n        onChange={(event) => onQueryChange(event.target.value)}\n        onKeyDown={handleKeyDown}\n        placeholder=\"Search models…\"\n        ref={inputRef}\n        value={query}\n      />\n    </div>\n  );\n};\n\nconst matchesQuery = (model: AiModel, query: string) => {\n  const haystack = `${model.name} ${model.id} ${model.provider}`.toLowerCase();\n  return query\n    .toLowerCase()\n    .split(/\\s+/u)\n    .every((part) => haystack.includes(part));\n};\n\nconst ProviderLogo = ({ logo }: { logo: ReactNode | undefined }) => {\n  if (!logo) {\n    return null;\n  }\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_img]:size-full [&_svg]:size-full [&_span]:size-full\"\n      data-slot=\"model-select-logo\"\n    >\n      {logo}\n    </span>\n  );\n};\n\nexport const ModelSelect = ({\n  className,\n  defaultValue,\n  excludeModels,\n  fallbackToFirst,\n  footer,\n  logos,\n  models,\n  onPopupKeyDown,\n  onValueChange,\n  placeholder = \"Select model\",\n  portalContainer,\n  size = \"md\",\n  value,\n  valueSuffix,\n  ...props\n}: ModelSelectProps) => {\n  const [query, setQuery] = useState(\"\");\n  const searchRef = useRef<HTMLInputElement>(null);\n  const excluded = new Set(excludeModels);\n  const visible = excluded.size\n    ? models.filter((model) => !excluded.has(model.id))\n    : models;\n  const selected = visible.find(\n    (model) => model.id === (value ?? defaultValue)\n  );\n\n  // Opt-in guard: an unlisted selection snaps to the first available\n  // model, so the picker can never submit an id the gateway rejects.\n  useEffect(() => {\n    const [first] = visible;\n\n    if (fallbackToFirst && !selected && first) {\n      onValueChange?.(first.id);\n    }\n  });\n  const filtered = query\n    ? visible.filter((model) => matchesQuery(model, query))\n    : visible;\n\n  return (\n    <Select\n      defaultValue={defaultValue}\n      items={visible.map((model) => ({ label: model.name, value: model.id }))}\n      onOpenChange={(open) => {\n        if (!open) {\n          setQuery(\"\");\n        }\n      }}\n      onOpenChangeComplete={(open) => {\n        if (open) {\n          searchRef.current?.focus();\n        }\n      }}\n      onValueChange={(next) => {\n        if (typeof next === \"string\") {\n          onValueChange?.(next);\n        }\n      }}\n      value={value}\n    >\n      <SelectTrigger\n        aria-label=\"Model\"\n        className={cn(\n          \"border-border/60 shadow-none transition-colors hover:border-border\",\n          TRIGGER_SIZE[size],\n          className\n        )}\n        data-slot=\"model-select\"\n        {...props}\n      >\n        <SelectValue>\n          {selected ? (\n            <>\n              <ProviderLogo logo={logos?.[selected.provider]} />\n              <span className=\"truncate\" title={selected.name}>\n                {selected.name}\n              </span>\n              {valueSuffix}\n            </>\n          ) : (\n            placeholder\n          )}\n        </SelectValue>\n      </SelectTrigger>\n      <SelectPrimitive.Portal container={portalContainer}>\n        <SelectPrimitive.Positioner\n          align=\"start\"\n          alignItemWithTrigger={false}\n          className=\"isolate z-50\"\n          side=\"bottom\"\n          sideOffset={4}\n        >\n          <SelectPrimitive.Popup\n            className=\"relative isolate z-50 flex max-h-(--available-height) w-max min-w-80 max-w-[min(24rem,90vw)] origin-(--transform-origin) flex-col overflow-hidden rounded-md bg-popover text-popover-foreground shadow-none outline-none ring-1 ring-border/60 duration-100 data-closed:animate-out data-closed:fade-out-0 data-open:animate-in data-open:fade-in-0\"\n            data-slot=\"select-content\"\n            onKeyDown={(event) => {\n              onPopupKeyDown?.(event);\n\n              if (event.defaultPrevented) {\n                return;\n              }\n\n              // Typing anywhere routes back into the search: arrows walk\n              // the list, characters always filter.\n              const typing =\n                event.key.length === 1 &&\n                !(event.metaKey || event.ctrlKey || event.altKey);\n\n              if (\n                (typing || event.key === \"Backspace\") &&\n                document.activeElement !== searchRef.current\n              ) {\n                event.preventDefault();\n                event.stopPropagation();\n                searchRef.current?.focus();\n                setQuery(typing ? query + event.key : query.slice(0, -1));\n              }\n            }}\n          >\n            <SearchField\n              inputRef={searchRef}\n              onQueryChange={setQuery}\n              query={query}\n            />\n\n            <div\n              className=\"neon-scroll-fade relative h-72 min-h-0 shrink overflow-y-auto\"\n              data-slot=\"model-select-scroller\"\n            >\n              <HighlightGlide />\n              <ScrollToSelected />\n              <SelectPrimitive.List>\n                {filtered.length === 0 ? (\n                  <p className=\"px-3 py-6 text-center text-muted-foreground text-sm\">\n                    No models match “{query}”\n                  </p>\n                ) : (\n                  groupByProvider(filtered).map(([provider, group]) => (\n                    <SelectGroup key={provider}>\n                      <SelectLabel className=\"font-mono text-[10px] uppercase tracking-wide\">\n                        {provider}\n                      </SelectLabel>\n                      {group.map((model) => (\n                        <SelectItem\n                          className=\"focus:bg-transparent\"\n                          disabled={model.disabled}\n                          key={model.id}\n                          value={model.id}\n                        >\n                          <div className=\"flex w-full min-w-0 items-center gap-2\">\n                            <ProviderLogo logo={logos?.[model.provider]} />\n                            <span className=\"shrink-0\">{model.name}</span>\n                            {model.tag ? (\n                              <span className=\"shrink-0 border border-border/60 px-1 py-px font-mono text-[9px] text-muted-foreground uppercase leading-none tracking-wide\">\n                                {model.tag}\n                              </span>\n                            ) : null}\n                            <span\n                              className=\"ml-auto truncate pl-3 font-mono text-[10px] text-muted-foreground/70\"\n                              title={model.id}\n                            >\n                              {model.id}\n                            </span>\n                          </div>\n                        </SelectItem>\n                      ))}\n                    </SelectGroup>\n                  ))\n                )}\n              </SelectPrimitive.List>\n            </div>\n\n            {footer ? (\n              <div className=\"shrink-0 border-border/60 border-t\">{footer}</div>\n            ) : null}\n          </SelectPrimitive.Popup>\n        </SelectPrimitive.Positioner>\n      </SelectPrimitive.Portal>\n    </Select>\n  );\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}