{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-picker",
  "title": "Model Picker",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A searchable model selector grouped by provider, with capability icons, locked plans and a detail pane that follows the highlighted model with context window, cost and abilities.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/popover.json",
    "https://ui.ballmac.com/r/command.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/model-picker.tsx",
      "content": "// Ballmac UI: Model Picker. https://ui.ballmac.com/components/model-picker\n\"use client\"\n\nimport * as React from \"react\"\nimport { Brain, ChevronsUpDown, Eye, Lock, Sparkles, Wrench, Zap } from \"lucide-react\"\n\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ballmac/command\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ballmac/popover\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages, defineMessage, type Message } from \"@/lib/ballmac/i18n\"\n\ntype ModelCapability = \"vision\" | \"reasoning\" | \"tools\" | \"fast\"\n\ntype ModelOption = {\n  /** Stable id returned by `onValueChange`. */\n  id: string\n  /** Name shown in the list and on the trigger. */\n  name: string\n  /** Provider or family. Options are grouped under it. */\n  provider?: string\n  /** One sentence on what the model is best at. */\n  description?: string\n  /** What the model can do. Shown as icons in the list and in words in the detail pane. */\n  capabilities?: ModelCapability[]\n  /** Context window in tokens, for example 200000. */\n  contextWindow?: number\n  /** Relative cost: 1 is cheapest, 3 is most expensive. */\n  cost?: 1 | 2 | 3\n  /** Marks a recent release. */\n  isNew?: boolean\n  /** The model cannot be picked, for example because it needs a higher plan. */\n  locked?: boolean\n  /** Text shown when locked, such as \"Pro\". */\n  lockedLabel?: string\n}\n\nconst CAPABILITY_META: Record<ModelCapability, { label: Message; help: Message; icon: React.ComponentType<{ className?: string }> }> = {\n  vision: { label: defineMessage(\"model-picker.CAPABILITY_META.vision\", \"Vision\"), help: defineMessage(\"model-picker.CAPABILITY_META.vision.help\", \"Reads images and screenshots\"), icon: Eye },\n  reasoning: { label: defineMessage(\"model-picker.CAPABILITY_META.reasoning\", \"Reasoning\"), help: defineMessage(\"model-picker.CAPABILITY_META.reasoning.help\", \"Thinks before answering\"), icon: Brain },\n  tools: { label: defineMessage(\"model-picker.CAPABILITY_META.tools\", \"Tools\"), help: defineMessage(\"model-picker.CAPABILITY_META.tools.help\", \"Calls functions and apps\"), icon: Wrench },\n  fast: { label: defineMessage(\"model-picker.CAPABILITY_META.fast\", \"Fast\"), help: defineMessage(\"model-picker.CAPABILITY_META.fast.help\", \"Low latency replies\"), icon: Zap },\n}\n\nconst TONES = [\"bg-chart-1\", \"bg-chart-2\", \"bg-chart-3\", \"bg-chart-4\", \"bg-chart-5\"]\n\nfunction hash(text: string) {\n  let h = 0\n  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0\n  return h\n}\n\n/** 200000 → \"200k\", 1000000 → \"1M\". */\nfunction formatContext(tokens: number) {\n  if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`\n  if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`\n  return String(tokens)\n}\n\nfunction ProviderMark({ model, className }: { model: ModelOption; className?: string }) {\n  const name = model.provider ?? model.name\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"flex size-6 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold text-background uppercase\",\n        TONES[hash(name) % TONES.length],\n        className\n      )}\n    >\n      {name.charAt(0)}\n    </span>\n  )\n}\n\ntype ModelPickerProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"onChange\"> & {\n  /** The models to offer. Models that share a `provider` are grouped. */\n  models: ModelOption[]\n  /** Selected model id (controlled). */\n  value?: string\n  /** Initial model id when uncontrolled. */\n  defaultValue?: string\n  /** Called with the chosen id. */\n  onValueChange?: (id: string) => void\n  /** Shown on the trigger when nothing is selected. */\n  placeholder?: string\n  /** Placeholder of the search box. */\n  searchPlaceholder?: string\n  /** Show the search box. Defaults to on when there are more than six models. */\n  searchable?: boolean\n  /** Show the detail pane that follows the highlighted model on wider screens. */\n  showDetails?: boolean\n  /** Called when a locked model is chosen, for example to open an upgrade dialog. */\n  onLockedSelect?: (model: ModelOption) => void\n  /** Which side of the trigger the list aligns to. */\n  align?: \"start\" | \"center\" | \"end\"\n  /** Trigger style. \"compact\" hides the provider mark and is sized for a composer toolbar. */\n  variant?: \"default\" | \"compact\"\n}\n\nfunction ModelPicker({\n  models,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  placeholder,\n  searchPlaceholder,\n  searchable,\n  showDetails = true,\n  onLockedSelect,\n  align = \"start\",\n  variant = \"default\",\n  className,\n  disabled,\n  ...props\n}: ModelPickerProps) {\n  const msg = useMessages()\n  placeholder ??= msg(\"model-picker.placeholder\", \"Select a model\")\n  searchPlaceholder ??= msg(\"model-picker.searchPlaceholder\", \"Search models…\")\n  const [internal, setInternal] = React.useState(defaultValue)\n  const value = valueProp ?? internal\n  const [open, setOpen] = React.useState(false)\n  const selected = models.find((m) => m.id === value)\n  const [highlight, setHighlight] = React.useState<string>(\"\")\n  const hasSearch = searchable ?? models.length > 6\n  const listLabel = \"Models\"\n\n  const groups = React.useMemo(() => {\n    const map = new Map<string, ModelOption[]>()\n    for (const m of models) {\n      const key = m.provider ?? \"\"\n      map.set(key, [...(map.get(key) ?? []), m])\n    }\n    return [...map.entries()]\n  }, [models])\n\n  const detail = models.find((m) => m.id === highlight) ?? selected ?? models[0]\n\n  function pick(model: ModelOption) {\n    if (model.locked) {\n      onLockedSelect?.(model)\n      return\n    }\n    if (valueProp === undefined) setInternal(model.id)\n    onValueChange?.(model.id)\n    setOpen(false)\n  }\n\n  function handleOpenChange(next: boolean) {\n    setOpen(next)\n    if (next) setHighlight(selected && !selected.locked ? selected.id : (models.find((m) => !m.locked)?.id ?? \"\"))\n  }\n\n  return (\n    <Popover open={open} onOpenChange={handleOpenChange}>\n      <PopoverTrigger\n        data-slot=\"model-picker\"\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        aria-label={selected ? `Model: ${selected.name}` : placeholder}\n        disabled={disabled}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" && !open) {\n            e.preventDefault()\n            handleOpenChange(true)\n          }\n        }}\n        className={cn(\n          \"inline-flex items-center gap-2 rounded-lg border bg-background text-sm font-medium text-foreground shadow-xs outline-none transition-[color,background-color,border-color,box-shadow] duration-150 hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent motion-reduce:transition-none\",\n          variant === \"compact\" ? \"h-8 px-2.5 text-[13px]\" : \"h-10 min-w-56 px-2.5\",\n          className\n        )}\n        {...props}\n      >\n        {variant === \"default\" && selected && <ProviderMark model={selected} />}\n        <span className={cn(\"truncate\", !selected && \"text-muted-foreground\")}>{selected?.name ?? placeholder}</span>\n        <ChevronsUpDown aria-hidden=\"true\" className=\"ms-auto size-4 shrink-0 text-muted-foreground\" />\n      </PopoverTrigger>\n      <PopoverContent\n        label=\"Choose a model\"\n        align={align}\n        sideOffset={6}\n        className={cn(\n          \"w-[min(23rem,calc(100vw-1.5rem))] gap-0 overflow-hidden p-0\",\n          showDetails && \"sm:w-[min(40rem,calc(100vw-1.5rem))]\"\n        )}\n      >\n        <div className=\"flex\">\n          <Command\n            label={listLabel}\n            value={highlight}\n            onValueChange={setHighlight}\n            className=\"min-w-0 flex-1 rounded-none bg-transparent\"\n          >\n            {hasSearch ? (\n              <CommandInput placeholder={searchPlaceholder} />\n            ) : (\n              // Keeps focus inside the list so arrow keys and type-ahead still work without a visible search box.\n              <div className=\"sr-only\">\n                <CommandInput placeholder={searchPlaceholder} />\n              </div>\n            )}\n            <CommandList className=\"max-h-80\">\n              <CommandEmpty>No model matches.</CommandEmpty>\n              {groups.map(([provider, items]) => (\n                <CommandGroup key={provider || \"all\"} heading={provider || undefined}>\n                  {items.map((m) => (\n                    <CommandItem\n                      key={m.id}\n                      value={m.id}\n                      keywords={[m.name, m.provider ?? \"\", ...(m.capabilities ?? [])]}\n                      selected={m.id === value}\n                      onSelect={() => pick(m)}\n                      className=\"min-h-11 py-2\"\n                      icon={<ProviderMark model={m} />}\n                      description={m.description}\n                    >\n                      <span className=\"flex items-center gap-1.5\">\n                        <span className=\"truncate\">{m.name}</span>\n                        {m.isNew && (\n                          <span className=\"inline-flex shrink-0 items-center gap-0.5 rounded-full bg-muted px-1.5 py-px text-[10px] font-medium text-foreground\">\n                            <Sparkles aria-hidden=\"true\" className=\"size-2.5\" />\n                            {msg(\"model-picker.new\", \"New\")}\n                          </span>\n                        )}\n                        {m.locked && (\n                          <span className=\"inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-px text-[10px] font-medium text-foreground\">\n                            <Lock aria-hidden=\"true\" className=\"size-2.5\" />\n                            {m.lockedLabel ?? msg(\"model-picker.locked\", \"Locked\")}\n                          </span>\n                        )}\n                      </span>\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              ))}\n            </CommandList>\n            <p className=\"flex items-center gap-3 border-t px-3 py-2 text-xs text-muted-foreground\" aria-hidden=\"true\">\n              <span><kbd className=\"font-mono\">↑↓</kbd> move</span>\n              <span><kbd className=\"font-mono\">↵</kbd> select</span>\n              <span><kbd className=\"font-mono\">esc</kbd> close</span>\n            </p>\n          </Command>\n          {showDetails && detail && (\n            <aside\n              aria-live=\"polite\"\n              aria-label={msg(\"model-picker.modelDetails\", \"Model details\")}\n              className=\"hidden w-60 shrink-0 flex-col gap-4 border-s bg-muted/30 p-4 sm:flex\"\n            >\n              <div className=\"flex items-center gap-2.5\">\n                <ProviderMark model={detail} className=\"size-8 text-xs\" />\n                <div className=\"min-w-0\">\n                  <p className=\"truncate text-sm font-medium text-foreground\">{detail.name}</p>\n                  {detail.provider && <p className=\"truncate text-xs text-muted-foreground\">{detail.provider}</p>}\n                </div>\n              </div>\n              {detail.description && <p className=\"text-[13px] leading-5 text-muted-foreground\">{detail.description}</p>}\n              <dl className=\"grid gap-2 text-[13px]\">\n                {detail.contextWindow !== undefined && (\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <dt className=\"text-muted-foreground\">Context</dt>\n                    <dd className=\"font-medium text-foreground tabular-nums\">{msg(\"model-picker.contextTokens\", \"{size} tokens\", { size: formatContext(detail.contextWindow) })}</dd>\n                  </div>\n                )}\n                {detail.cost !== undefined && (\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <dt className=\"text-muted-foreground\">Cost</dt>\n                    <dd className=\"font-medium text-foreground\">\n                      <span aria-hidden=\"true\" className=\"tracking-widest\">\n                        {\"$\".repeat(detail.cost)}\n                        <span className=\"text-border\">{\"$\".repeat(3 - detail.cost)}</span>\n                      </span>\n                      <span className=\"sr-only\">{msg(\"model-picker.costLevel\", \"{level} cost\", { level: [msg(\"model-picker.costLow\", \"Low\"), msg(\"model-picker.costMedium\", \"Medium\"), msg(\"model-picker.costHigh\", \"High\")][detail.cost - 1] ?? \"\" })}</span>\n                    </dd>\n                  </div>\n                )}\n              </dl>\n              {detail.capabilities && detail.capabilities.length > 0 && (\n                <ul className=\"grid gap-2\">\n                  {detail.capabilities.map((c) => {\n                    const meta = CAPABILITY_META[c]\n                    const Icon = meta.icon\n                    return (\n                      <li key={c} className=\"flex items-start gap-2 text-[13px]\">\n                        <Icon aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground\" />\n                        <span>\n                          <span className=\"font-medium text-foreground\">{msg.of(meta.label)}</span>\n                          <span className=\"text-muted-foreground\"> · {msg.of(meta.help)}</span>\n                        </span>\n                      </li>\n                    )\n                  })}\n                </ul>\n              )}\n              {detail.locked && (\n                <p className=\"mt-auto flex items-center gap-1.5 rounded-lg border bg-background px-2.5 py-2 text-xs text-foreground\">\n                  <Lock aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                  {msg(\"model-picker.availableWith\", \"Available with {plan}\", { plan: detail.lockedLabel ?? msg(\"model-picker.higherPlan\", \"a higher plan\") })}\n                </p>\n              )}\n            </aside>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport { ModelPicker, formatContext, type ModelPickerProps, type ModelOption, type ModelCapability }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/model-picker.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "model",
      "select",
      "combobox",
      "llm",
      "picker"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "models is an array of { id, name, provider?, description?, capabilities?, contextWindow?, cost?, isNew?, locked? }. value/onValueChange or defaultValue. Locked models call onLockedSelect. variant default | compact.",
      "whenToUse": [
        "Choosing the model for a chat or an agent",
        "Any picker where options have facts worth comparing"
      ],
      "whenNotToUse": [
        "A plain short list of choices; use select",
        "Settings with many fields; use settings-panel"
      ],
      "composesWith": [
        "prompt-input",
        "ai-chat",
        "token-meter"
      ],
      "a11y": [
        {
          "keys": "Enter / Space / ArrowDown",
          "action": "Opens the list from the trigger"
        },
        {
          "keys": "ArrowUp / ArrowDown",
          "action": "Moves the highlight; the detail pane updates"
        },
        {
          "keys": "Enter",
          "action": "Selects the model and returns focus to the trigger"
        },
        {
          "keys": "Escape",
          "action": "Closes without changing"
        }
      ],
      "customization": [
        "searchable",
        "showDetails",
        "variant",
        "align",
        "onLockedSelect",
        "lockedLabel per model"
      ]
    },
    "examples": [
      "model-picker-demo",
      "model-picker-compact"
    ],
    "url": "https://ui.ballmac.com/components/model-picker"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}