{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-picker",
  "type": "registry:ui",
  "title": "Model Picker",
  "description": "Searchable popover for choosing an AI model, grouped by provider, with capability icons, context size, speed and cost hints.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/popover.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/model-picker/index.ts",
      "type": "registry:ui",
      "target": "components/kura/model-picker/index.ts",
      "content": "export {\n  formatContextWindow,\n  type ModelCapability,\n  ModelPicker,\n  type ModelPickerModel,\n  type ModelPickerProps,\n} from \"./model-picker\";\n"
    },
    {
      "path": "registry/kura/model-picker/model-picker.tsx",
      "type": "registry:ui",
      "target": "components/kura/model-picker/model-picker.tsx",
      "content": "// ARIA listbox pattern: options are highlighted via aria-activedescendant from the focused\n// combobox/listbox, so they are intentionally not focusable and have no own key handlers.\n// biome-ignore-all lint/a11y/useSemanticElements: rich rows cannot be native <option>/<fieldset>.\n// biome-ignore-all lint/a11y/useFocusableInteractive: focus stays on the combobox (activedescendant).\n// biome-ignore-all lint/a11y/useKeyWithClickEvents: keyboard selection is handled by the combobox.\n\"use client\";\n\nimport {\n  AudioLines,\n  Brain,\n  Check,\n  ChevronDown,\n  Eye,\n  FileText,\n  Globe,\n  Search,\n  Wrench,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/kura/popover\";\n\nexport type ModelCapability = \"vision\" | \"tools\" | \"reasoning\" | \"web\" | \"audio\" | \"files\";\n\nexport interface ModelPickerModel {\n  /** Stable id passed to `onValueChange`, e.g. \"claude-opus-5-5\". */\n  id: string;\n  /** Display name, e.g. \"Claude Opus 5.5\". */\n  name: string;\n  /** Group heading, e.g. \"Anthropic\". Models keep their order within a provider. */\n  provider?: string;\n  /** One sentence shown in the detail footer while the model is highlighted. */\n  description?: string;\n  capabilities?: readonly ModelCapability[];\n  /** Context window in tokens, e.g. 200000 (shown as \"200K\"). */\n  contextWindow?: number;\n  /** 1 = slow/thorough, 2 = balanced, 3 = fast. */\n  speed?: 1 | 2 | 3;\n  /** 1 = cheap, 3 = expensive. */\n  cost?: 1 | 2 | 3;\n  /** Short tag next to the name, e.g. \"New\" or \"Beta\". */\n  badge?: string;\n  disabled?: boolean;\n  /** Provider or model mark (16px). Falls back to a monogram of the provider. */\n  icon?: React.ReactNode;\n}\n\nconst CAPABILITIES: Record<\n  ModelCapability,\n  { label: string; Icon: React.ComponentType<{ className?: string }> }\n> = {\n  vision: { label: \"Vision\", Icon: Eye },\n  tools: { label: \"Tool use\", Icon: Wrench },\n  reasoning: { label: \"Reasoning\", Icon: Brain },\n  web: { label: \"Web search\", Icon: Globe },\n  audio: { label: \"Audio\", Icon: AudioLines },\n  files: { label: \"Files\", Icon: FileText },\n};\n\nconst SPEED = [\"\", \"Thorough\", \"Balanced\", \"Fast\"] as const;\nconst COST = [\"\", \"Low cost\", \"Medium cost\", \"High cost\"] as const;\n\n/** 200000 → \"200K\", 1000000 → \"1M\". */\nexport function formatContextWindow(tokens: number): string {\n  if (tokens >= 1_000_000) {\n    const m = tokens / 1_000_000;\n    return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;\n  }\n  if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`;\n  return String(tokens);\n}\n\nfunction matches(model: ModelPickerModel, query: string): boolean {\n  const hay = [\n    model.name,\n    model.id,\n    model.provider,\n    model.description,\n    model.badge,\n    ...(model.capabilities ?? []).map((c) => CAPABILITIES[c].label),\n  ]\n    .filter(Boolean)\n    .join(\" \")\n    .toLowerCase();\n  return query\n    .toLowerCase()\n    .split(/\\s+/)\n    .filter(Boolean)\n    .every((word) => hay.includes(word));\n}\n\nfunction ProviderMark({ model, className }: { model: ModelPickerModel; className?: string }) {\n  if (model.icon) {\n    return (\n      <span\n        aria-hidden\n        className={cn(\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\", className)}\n      >\n        {model.icon}\n      </span>\n    );\n  }\n  const letter = (model.provider ?? model.name).trim().charAt(0).toUpperCase();\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"flex size-4 shrink-0 items-center justify-center rounded-kura-xs bg-kura-active font-semibold text-[9px] text-kura-fg-muted leading-none\",\n        className,\n      )}\n    >\n      {letter}\n    </span>\n  );\n}\n\nfunction Meter({ value, label, kind }: { value: number; label: string; kind: \"speed\" | \"cost\" }) {\n  return (\n    <span className=\"inline-flex items-center gap-1\" title={label}>\n      <span aria-hidden className=\"inline-flex items-end gap-[2px]\">\n        {[1, 2, 3].map((i) =>\n          kind === \"speed\" ? (\n            <span\n              key={i}\n              className={cn(\n                \"w-[3px] rounded-full\",\n                i === 1 ? \"h-1.5\" : i === 2 ? \"h-2\" : \"h-2.5\",\n                i <= value ? \"bg-kura-fg-muted\" : \"bg-kura-border-strong\",\n              )}\n            />\n          ) : (\n            <span\n              key={i}\n              className={cn(\n                \"font-kura-mono text-[10px] leading-none\",\n                i <= value ? \"text-kura-fg-muted\" : \"text-kura-border-strong\",\n              )}\n            >\n              $\n            </span>\n          ),\n        )}\n      </span>\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n\nexport interface ModelPickerProps\n  extends Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"children\"> {\n  /** Every selectable model. Grouped by `provider` in first-seen order. */\n  models: readonly ModelPickerModel[];\n  /** Controlled selected model id. */\n  value?: string;\n  /** Initial model id when uncontrolled. Default: the first enabled model. */\n  defaultValue?: string;\n  onValueChange?: (id: string, model: ModelPickerModel) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Show the search field. Default: true when there are more than 6 models. */\n  searchable?: boolean;\n  /** Trigger text when nothing is selected. Default \"Select model\". */\n  placeholder?: string;\n  /** Default \"Search models…\". */\n  searchPlaceholder?: string;\n  /** Default \"No models match\". */\n  emptyMessage?: string;\n  /** Show the description footer for the highlighted model. Default true. */\n  showDetails?: boolean;\n  /** Popover side. Use \"top\" inside a composer at the bottom of the screen. Default \"bottom\". */\n  side?: \"top\" | \"bottom\";\n  /** Default \"start\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Trigger size. Default \"sm\" (28px), for toolbars. */\n  size?: \"sm\" | \"md\";\n}\n\n/**\n * Model selector: a quiet trigger that opens a searchable, grouped list with capability icons,\n * context size, speed and cost hints. Full keyboard support (arrows, Home/End, Enter, Escape)\n * with focus kept in the search field.\n */\nexport function ModelPicker({\n  models,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  searchable,\n  placeholder = \"Select model\",\n  searchPlaceholder = \"Search models…\",\n  emptyMessage = \"No models match\",\n  showDetails = true,\n  side = \"bottom\",\n  align = \"start\",\n  size = \"sm\",\n  disabled,\n  className,\n  ...props\n}: ModelPickerProps) {\n  const firstEnabled = models.find((m) => !m.disabled)?.id ?? \"\";\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue: defaultValue ?? firstEnabled,\n    onChange: (id: string) => {\n      const model = models.find((m) => m.id === id);\n      if (model) onValueChange?.(id, model);\n    },\n  });\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  });\n  const [query, setQuery] = React.useState(\"\");\n  const [active, setActive] = React.useState<string | null>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const baseId = React.useId();\n  const listId = `${baseId}-list`;\n  const optionId = React.useCallback(\n    (id: string) => `${baseId}-opt-${id.replace(/[^a-zA-Z0-9_-]/g, \"_\")}`,\n    [baseId],\n  );\n  const showSearch = searchable ?? models.length > 6;\n\n  const selected = models.find((m) => m.id === value);\n  const filtered = React.useMemo(\n    () => (query.trim() ? models.filter((m) => matches(m, query)) : models),\n    [models, query],\n  );\n  const groups = React.useMemo(() => {\n    const map = new Map<string, ModelPickerModel[]>();\n    for (const m of filtered) {\n      const key = m.provider ?? \"\";\n      const list = map.get(key);\n      if (list) list.push(m);\n      else map.set(key, [m]);\n    }\n    return [...map.entries()];\n  }, [filtered]);\n  const flat = React.useMemo(() => groups.flatMap(([, list]) => list), [groups]);\n  const enabled = flat.filter((m) => !m.disabled);\n  const activeModel = flat.find((m) => m.id === active) ?? null;\n\n  // Reset the highlight to the selection when opening; keep it valid while filtering.\n  React.useEffect(() => {\n    if (!open) return;\n    setActive((current) => {\n      if (current && enabled.some((m) => m.id === current)) return current;\n      if (!query && enabled.some((m) => m.id === value)) return value;\n      return enabled[0]?.id ?? null;\n    });\n  }, [open, enabled, query, value]);\n\n  React.useEffect(() => {\n    if (!open || !active) return;\n    const el = typeof document === \"undefined\" ? null : document.getElementById(optionId(active));\n    el?.scrollIntoView?.({ block: \"nearest\" });\n  }, [active, open, optionId]);\n\n  const choose = (model: ModelPickerModel) => {\n    if (model.disabled) return;\n    setValue(model.id);\n    setOpen(false);\n  };\n\n  const move = (delta: number | \"first\" | \"last\") => {\n    if (enabled.length === 0) return;\n    const index = enabled.findIndex((m) => m.id === active);\n    let next: number;\n    if (delta === \"first\") next = 0;\n    else if (delta === \"last\") next = enabled.length - 1;\n    else next = index === -1 ? 0 : (index + delta + enabled.length) % enabled.length;\n    setActive(enabled[next]?.id ?? null);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.nativeEvent.isComposing) return;\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        move(1);\n        break;\n      case \"ArrowUp\":\n        e.preventDefault();\n        move(-1);\n        break;\n      case \"Home\":\n        if (showSearch && query) return;\n        e.preventDefault();\n        move(\"first\");\n        break;\n      case \"End\":\n        if (showSearch && query) return;\n        e.preventDefault();\n        move(\"last\");\n        break;\n      case \"Enter\": {\n        e.preventDefault();\n        if (activeModel) choose(activeModel);\n        break;\n      }\n    }\n  };\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) setQuery(\"\");\n      }}\n    >\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          data-slot=\"model-picker\"\n          data-state={open ? \"open\" : \"closed\"}\n          disabled={disabled}\n          aria-label={selected ? `Model: ${selected.name}` : placeholder}\n          className={cn(\n            \"group/model-picker inline-flex min-w-0 shrink cursor-pointer select-none items-center gap-1.5 rounded-kura-lg font-kura-sans font-medium text-kura-fg-muted outline-none\",\n            \"transition-[background-color,color,transform] duration-(--kura-duration-fast) ease-kura-out\",\n            \"hover:not-disabled:bg-kura-hover hover:not-disabled:text-kura-fg data-[state=open]:bg-kura-hover data-[state=open]:text-kura-fg\",\n            \"focus-visible:shadow-kura-focus active:not-disabled:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:active:not-disabled:scale-100\",\n            size === \"sm\" ? \"h-8 px-2 text-[13px]\" : \"h-9 px-2.5 text-sm\",\n            className,\n          )}\n          {...props}\n        >\n          {selected && <ProviderMark model={selected} />}\n          <span className=\"truncate\">{selected?.name ?? placeholder}</span>\n          <ChevronDown\n            aria-hidden\n            className=\"size-3.5 shrink-0 text-kura-fg-subtle transition-transform duration-(--kura-duration-base) ease-kura-out group-data-[state=open]/model-picker:rotate-180 motion-reduce:transition-none\"\n          />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        side={side}\n        align={align}\n        sideOffset={6}\n        data-slot=\"model-picker-content\"\n        aria-label=\"Choose a model\"\n        className=\"flex w-[340px] flex-col overflow-hidden p-0\"\n        onOpenAutoFocus={(e) => {\n          e.preventDefault();\n          (showSearch ? inputRef.current : listRef.current)?.focus();\n        }}\n        onKeyDown={showSearch ? undefined : onKeyDown}\n      >\n        {showSearch && (\n          <div\n            data-slot=\"model-picker-search\"\n            className=\"flex h-10 shrink-0 items-center gap-2 px-3 shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\"\n          >\n            <Search aria-hidden className=\"size-3.5 shrink-0 text-kura-fg-subtle\" />\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-expanded\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={active ? optionId(active) : undefined}\n              aria-label=\"Search models\"\n              value={query}\n              placeholder={searchPlaceholder}\n              onChange={(e) => setQuery(e.currentTarget.value)}\n              onKeyDown={onKeyDown}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[13px] text-kura-fg outline-none placeholder:text-kura-fg-subtle\"\n            />\n          </div>\n        )}\n        <div\n          ref={listRef}\n          id={listId}\n          role=\"listbox\"\n          aria-label=\"Models\"\n          tabIndex={showSearch ? -1 : 0}\n          aria-activedescendant={!showSearch && active ? optionId(active) : undefined}\n          className=\"max-h-[min(20rem,var(--radix-popover-content-available-height,20rem))] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none [scrollbar-width:thin]\"\n        >\n          {flat.length === 0 && (\n            <div className=\"px-3 py-6 text-center text-[13px] text-kura-fg-subtle\">\n              {emptyMessage}\n            </div>\n          )}\n          {groups.map(([provider, list]) => {\n            const headingId = `${baseId}-group-${provider.replace(/\\W/g, \"_\") || \"default\"}`;\n            return (\n              <div\n                key={provider || \"__default\"}\n                role=\"group\"\n                aria-labelledby={provider ? headingId : undefined}\n                data-slot=\"model-picker-group\"\n                className=\"not-first:mt-1\"\n              >\n                {provider && (\n                  <div\n                    id={headingId}\n                    className=\"select-none px-2 pt-2 pb-1 font-medium text-[11px] text-kura-fg-subtle\"\n                  >\n                    {provider}\n                  </div>\n                )}\n                {list.map((model) => {\n                  const isSelected = model.id === value;\n                  const isActive = model.id === active;\n                  return (\n                    <div\n                      key={model.id}\n                      id={optionId(model.id)}\n                      role=\"option\"\n                      aria-selected={isSelected}\n                      aria-disabled={model.disabled || undefined}\n                      data-slot=\"model-picker-item\"\n                      data-active={isActive || undefined}\n                      data-disabled={model.disabled || undefined}\n                      onPointerMove={() =>\n                        !model.disabled && active !== model.id && setActive(model.id)\n                      }\n                      onClick={() => choose(model)}\n                      className={cn(\n                        \"flex cursor-pointer select-none items-center gap-2.5 rounded-kura-md px-2 py-1.5\",\n                        \"data-active:bg-kura-hover data-disabled:cursor-not-allowed data-disabled:opacity-45\",\n                      )}\n                    >\n                      <ProviderMark model={model} className=\"self-start mt-0.5\" />\n                      <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                        <div className=\"flex min-w-0 items-center gap-1.5\">\n                          <span className=\"truncate font-medium text-[13px] text-kura-fg leading-4\">\n                            {model.name}\n                          </span>\n                          {model.badge && (\n                            <span className=\"shrink-0 rounded-full bg-kura-active px-1.5 py-px font-medium text-[10px] text-kura-fg-muted leading-3.5\">\n                              {model.badge}\n                            </span>\n                          )}\n                        </div>\n                        <ModelMeta model={model} />\n                      </div>\n                      <Check\n                        aria-hidden\n                        className={cn(\n                          \"size-3.5 shrink-0 text-kura-fg transition-opacity duration-(--kura-duration-fast)\",\n                          isSelected ? \"opacity-100\" : \"opacity-0\",\n                        )}\n                      />\n                    </div>\n                  );\n                })}\n              </div>\n            );\n          })}\n        </div>\n        {showDetails && (\n          <div\n            data-slot=\"model-picker-details\"\n            aria-live=\"polite\"\n            className=\"flex h-12 shrink-0 items-center px-3 text-kura-fg-muted text-xs leading-4 shadow-[inset_0_1px_0_var(--kura-border-subtle)]\"\n          >\n            <p className=\"line-clamp-2\">\n              {activeModel?.description ?? (activeModel ? activeModel.name : \"\")}\n            </p>\n          </div>\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nfunction ModelMeta({ model }: { model: ModelPickerModel }) {\n  const parts: React.ReactNode[] = [];\n  if (model.contextWindow) {\n    parts.push(\n      <span key=\"ctx\" title={`${model.contextWindow.toLocaleString(\"en-US\")} token context window`}>\n        <span aria-hidden>{formatContextWindow(model.contextWindow)}</span>\n        <span className=\"sr-only\">{`${formatContextWindow(model.contextWindow)} context`}</span>\n      </span>,\n    );\n  }\n  if (model.speed)\n    parts.push(<Meter key=\"speed\" kind=\"speed\" value={model.speed} label={SPEED[model.speed]} />);\n  if (model.cost)\n    parts.push(<Meter key=\"cost\" kind=\"cost\" value={model.cost} label={COST[model.cost]} />);\n  const caps = model.capabilities ?? [];\n  if (parts.length === 0 && caps.length === 0) return null;\n  return (\n    <div\n      data-slot=\"model-picker-meta\"\n      className=\"flex items-center gap-2 font-kura-mono text-[11px] text-kura-fg-subtle tabular-nums leading-3\"\n    >\n      {parts.map((p, i) => (\n        <React.Fragment key={i}>\n          {i > 0 && (\n            <span aria-hidden className=\"text-kura-border-strong\">\n              ·\n            </span>\n          )}\n          {p}\n        </React.Fragment>\n      ))}\n      {caps.length > 0 && (\n        <span className=\"ml-auto flex items-center gap-1.5 text-kura-fg-subtle\">\n          {caps.map((c) => {\n            const { Icon, label } = CAPABILITIES[c];\n            return (\n              <span key={c} title={label} data-capability={c} className=\"inline-flex\">\n                <Icon className=\"size-3\" aria-hidden />\n                <span className=\"sr-only\">{label}</span>\n              </span>\n            );\n          })}\n        </span>\n      )}\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "input",
    "model",
    "llm",
    "select",
    "combobox"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/model-picker · Markdown for agents: https://kuro.abstergo.fyi/components/model-picker.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Let the user switch the model in a chat composer toolbar or a chat header.",
        "Compare models at a glance: vision/tools/reasoning support, context window, speed and price tier.",
        "Any “choose one of N models/agents/providers” selector that benefits from search and grouping."
      ],
      "whenNotToUse": [
        "Running actions or navigating — use command-palette.",
        "Inserting commands while typing a prompt — use slash-command-menu.",
        "Two or three static options without metadata — a segmented control or plain select is enough."
      ]
    }
  }
}
