{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-picker",
  "type": "registry:component",
  "title": "Model Picker",
  "description": "A select for the model a message goes to, searched by name, filtered by provider and starred.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [
    "https://panelui.dev/r/web/tooltip.json"
  ],
  "files": [
    {
      "path": "components/panelui/model-picker.tsx",
      "type": "registry:component",
      "target": "@components/panelui/model-picker.tsx",
      "content": "'use client';\n\n/**\n * ModelPicker — chooses the model a message goes to.\n *\n * ```tsx\n * <ModelPicker items={models} providers={providers} defaultValue=\"claude-sonnet\" hotkey=\"alt+m\">\n *   <ModelPicker.Trigger />\n *   <ModelPicker.Content />\n * </ModelPicker>\n * ```\n *\n * ## A select that can be searched\n *\n * It behaves like a select: the trigger shows the chosen model, pressing it\n * opens a list, and choosing one closes it. The list opens with the search\n * field focused, because with more than a handful of models typing a few\n * letters is quicker than reading. Typing searches every model, whatever\n * filter is on.\n *\n * ## Keys\n *\n * The arrow keys move through the list and Enter chooses. ⌥1 to ⌥9 (Alt on\n * other systems) choose the row with that number, and ⌥S stars the\n * highlighted one. The numbers are on ⌥ because the browser keeps ⌘1 to ⌘9,\n * and Ctrl, for its tabs. `hotkey` opens it from anywhere on the page; opened\n * from the keyboard it appears at once, without growing, since a shortcut is\n * for being quick.\n *\n * ## Five layouts\n *\n * `rail` puts the providers in a column of icons beside the list, with\n * starred models first. `list` is one list under provider headings, with the\n * context size at the end of each row. `cards` shows each model as a card\n * with what it is good at, under a row of provider filters. `compare` sets\n * speed, intelligence and price side by side as small meters. `detail` keeps\n * a pane beside the list describing the highlighted model.\n *\n * ## Icons\n *\n * Each provider takes an `icon`, and a model shows its provider's unless it\n * has one of its own. Nothing is drawn by the component, so a provider's\n * mark is whatever the app has the right to show.\n */\nimport { Autocomplete } from '@base-ui/react/autocomplete';\nimport { Popover } from '@base-ui/react/popover';\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { Tooltip } from '@/components/panelui/tooltip';\n\nexport type ModelPickerVariant = 'rail' | 'list' | 'cards' | 'compare' | 'detail';\n\nexport interface ModelPickerProvider {\n  /** The provider's id, which each model's `provider` refers to. */\n  value: string;\n  /** Its name, as the list and the rail's tooltips write it. */\n  label: string;\n  /** Its mark. Drawn at 16px, or 20px in the rail. */\n  icon?: ReactNode;\n  /** A mark on its rail icon: `new` for a model just added, `limited` for one that is rate-limited. */\n  status?: 'new' | 'limited';\n}\n\nexport interface ModelPickerItem {\n  /** The model's id: what `value` and `onValueChange` carry. */\n  value: string;\n  /** Its name. */\n  label: string;\n  /** The `value` of the provider it comes from. */\n  provider: string;\n  /** Its own icon, in place of its provider's. */\n  icon?: ReactNode;\n  /** What it is good for, in a sentence. Shown by `cards` and `detail`. */\n  description?: string;\n  /** A short flag after its name, such as `New` or `Beta`. */\n  badge?: ReactNode;\n  /** Whether it can be chosen. A disabled model stays in the list, greyed out, with its `note`. */\n  disabled?: boolean;\n  /** Why it is disabled, or anything else to say in its second line: `Rate-limited until 14:00`. */\n  note?: ReactNode;\n  /** How many tokens it reads at once. Shown by `list`, `compare` and `detail`. */\n  contextWindow?: number;\n  /** From 1 to 5. */\n  speed?: number;\n  /** From 1 to 5. */\n  intelligence?: number;\n  /** From 1, cheapest, to 4. */\n  price?: number;\n  /** What it is good at, as chips: `Code`, `Vision`, `Long context`. */\n  tags?: readonly string[];\n}\n\nconst pickerVariants = tv({\n  slots: {\n    trigger: [\n      'group/model-trigger inline-flex h-8 min-w-0 cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 text-[0.8125rem] font-medium text-foreground outline-none',\n      'transition-[background-color,scale] duration-150 ease-out active:scale-[0.97] motion-reduce:active:scale-100',\n      'focus-visible:ring-[3px] focus-visible:ring-ring/30 data-popup-open:bg-accent',\n      '[&_svg]:pointer-events-none [&_svg]:shrink-0',\n    ],\n    triggerIcon: 'flex size-4 shrink-0 items-center justify-center [&_svg]:size-4',\n    triggerLabel: 'truncate',\n    chevron:\n      'size-3.5 text-muted-foreground transition-transform duration-200 group-data-popup-open/model-trigger:rotate-180 motion-reduce:transition-none',\n    positioner: 'z-50 outline-none',\n    popup: [\n      '@container flex max-h-[min(30rem,var(--available-height))] max-w-[calc(100vw-1rem)] origin-(--transform-origin) overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-xl shadow-black/10 outline-none',\n      'transition-[opacity,scale] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)]',\n      'data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.96] data-ending-style:opacity-0',\n      'data-instant:transition-none motion-reduce:transition-none',\n    ],\n    rail: 'flex w-14 shrink-0 flex-col items-center gap-1 overflow-y-auto border-e bg-muted/40 py-2 [scrollbar-width:none]',\n    railButton: [\n      'relative flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground outline-none',\n      'transition-[background-color,color,scale] duration-150 hover:bg-accent hover:text-foreground active:scale-95',\n      'focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs',\n      '[&_svg]:size-5',\n    ],\n    // The bar that marks the chosen filter, on the rail's edge beside it.\n    railMark: 'absolute -end-2 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-full bg-info',\n    railStatus: 'absolute end-1 top-1 flex size-2.5 items-center justify-center rounded-full ring-2 ring-muted',\n    railSeparator: 'my-1 h-px w-6 shrink-0 bg-border',\n    main: 'flex min-w-0 flex-1 flex-col',\n    searchRow: 'p-2 pb-1',\n    search: [\n      'flex h-10 items-center gap-2 rounded-xl border bg-background px-3 text-sm',\n      'transition-[border-color,box-shadow] duration-150 focus-within:border-info focus-within:ring-[3px] focus-within:ring-info/20 dark:bg-input/30',\n      '[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground',\n    ],\n    searchInput: 'h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground/80',\n    list: 'min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5 pt-1 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] scroll-py-1.5',\n    groupLabel: 'flex items-center gap-2 px-2.5 pb-1 pt-3 text-xs font-medium text-muted-foreground first:pt-1.5 [&_svg]:size-3.5',\n    item: [\n      'group/model relative flex cursor-default select-none items-center gap-3 rounded-xl px-2.5 outline-none',\n      'data-highlighted:bg-accent data-disabled:opacity-50',\n      'scroll-my-1.5',\n    ],\n    itemIcon: 'flex size-4 shrink-0 items-center justify-center [&_svg]:size-4',\n    itemBody: 'flex min-w-0 flex-1 flex-col',\n    itemName: 'flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground',\n    itemNameText: 'truncate',\n    itemMeta: 'flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground [&_svg]:size-3.5',\n    itemEnd: 'ms-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground tabular-nums',\n    badge: 'inline-flex h-[1.125rem] shrink-0 items-center rounded-md bg-info/12 px-1.5 text-[0.6875rem] font-medium text-info',\n    check: 'size-4 text-foreground',\n    star: [\n      'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60',\n      'transition-[color,scale] duration-150 ease-out hover:text-foreground active:scale-75 motion-reduce:active:scale-100',\n      'data-on:text-warning [&_svg]:size-4',\n    ],\n    kbd: 'inline-flex h-5 min-w-5 items-center justify-center rounded-md border bg-muted/60 px-1 font-sans text-[0.6875rem] font-medium text-muted-foreground',\n    empty: 'px-3 py-8 text-center text-sm text-muted-foreground',\n    chips: 'flex gap-1.5 overflow-x-auto px-2 pb-2 [scrollbar-width:none]',\n    chip: [\n      'inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium text-muted-foreground outline-none',\n      'transition-[background-color,color,border-color] duration-150 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/30',\n      'aria-pressed:border-foreground/20 aria-pressed:bg-foreground aria-pressed:text-background [&_svg]:size-3.5',\n    ],\n    cardRow: 'grid grid-cols-2 gap-1.5',\n    card: [\n      'flex min-w-0 cursor-default select-none flex-col gap-2 rounded-xl border bg-background p-3 outline-none dark:bg-input/20',\n      'transition-[border-color,background-color] duration-150 data-highlighted:border-foreground/25 data-highlighted:bg-accent',\n      'data-selected:border-info data-selected:ring-1 data-selected:ring-info data-disabled:opacity-50',\n    ],\n    cardHead: 'flex items-center gap-2 [&_svg]:size-4',\n    cardText: 'line-clamp-2 text-xs leading-4 text-muted-foreground',\n    tags: 'mt-auto flex flex-wrap gap-1',\n    tag: 'inline-flex h-5 items-center rounded-md bg-muted px-1.5 text-[0.6875rem] text-muted-foreground',\n    tableHead: 'flex items-center gap-3 border-b px-4 pb-2 pt-1 text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground',\n    column: 'w-12 shrink-0 text-start @[30rem]:w-16',\n    // Price is the column left out when the list is narrow.\n    priceColumn: 'hidden w-16 shrink-0 text-start @[30rem]:block',\n    meter: 'flex w-12 shrink-0 items-center gap-[2px] @[30rem]:w-16 @[30rem]:gap-[3px]',\n    meterCell: 'h-2.5 flex-1 rounded-[2px] bg-muted-foreground/20 data-on:bg-foreground/80',\n    price: 'w-16 shrink-0 font-medium tracking-wider',\n    priceCell: 'hidden @[30rem]:block',\n    pane: 'hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto border-s bg-muted/30 p-4 [scrollbar-width:thin] @[34rem]:flex',\n    paneIcon: 'flex size-10 items-center justify-center rounded-xl border bg-background [&_svg]:size-5',\n    paneTitle: 'text-base font-semibold text-foreground',\n    paneText: 'text-[0.8125rem] leading-5 text-muted-foreground',\n    stats: 'flex flex-col gap-2.5 text-[0.8125rem]',\n    stat: 'flex items-center justify-between gap-3',\n    statLabel: 'text-muted-foreground',\n    footer: 'mt-auto flex items-center gap-3 text-xs text-muted-foreground',\n  },\n  variants: {\n    variant: {\n      rail: { popup: 'min-h-[18rem] w-[25rem]', item: 'min-h-14 py-2' },\n      list: { popup: 'w-[21rem]', item: 'h-9' },\n      cards: {\n        popup: 'w-[34rem]',\n        list: 'flex flex-col gap-1.5 p-2 pt-0',\n        // On a narrow card the badge drops under the name rather than splitting it.\n        itemName: 'flex-wrap gap-y-1',\n        itemNameText: 'overflow-visible whitespace-normal',\n      },\n      compare: { popup: 'w-[33rem]', item: 'h-11' },\n      // A fixed height: the pane's text changes with the highlight, and a popup\n      // that grew with it moved the rows under the pointer, which moved the\n      // highlight again.\n      detail: { popup: 'h-[24rem] w-[38rem]', item: 'h-9' },\n    },\n    appearance: {\n      ghost: { trigger: 'text-muted-foreground hover:bg-accent hover:text-foreground data-popup-open:text-foreground' },\n      outline: { trigger: 'border bg-background shadow-xs hover:bg-accent dark:bg-input/30' },\n    },\n  },\n  defaultVariants: { variant: 'rail', appearance: 'ghost' },\n});\n\n/* -------------------------------------------------------------------------- */\n/* Keys                                                                       */\n/* -------------------------------------------------------------------------- */\n\nfunction isApple() {\n  return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);\n}\n\ninterface Hotkey {\n  key: string;\n  mod: boolean;\n  shift: boolean;\n  alt: boolean;\n}\n\nfunction parseHotkey(text: string): Hotkey {\n  const parts = text.toLowerCase().split('+').map((part) => part.trim());\n  return {\n    key: parts[parts.length - 1] ?? '',\n    mod: parts.includes('mod') || parts.includes('meta') || parts.includes('cmd') || parts.includes('ctrl'),\n    shift: parts.includes('shift'),\n    alt: parts.includes('alt') || parts.includes('option'),\n  };\n}\n\n/** Each key of a hotkey as it is written on a key cap, in the order a Mac writes them. */\nfunction hotkeyCaps(hotkey: Hotkey): string[] {\n  const apple = isApple();\n  const caps: string[] = [];\n  if (hotkey.alt) caps.push(apple ? '⌥' : 'Alt');\n  if (hotkey.shift) caps.push(apple ? '⇧' : 'Shift');\n  if (hotkey.mod) caps.push(apple ? '⌘' : 'Ctrl');\n  caps.push(hotkey.key.length === 1 ? hotkey.key.toUpperCase() : hotkey.key);\n  return caps;\n}\n\nconst modOf = (event: { metaKey: boolean; ctrlKey: boolean }) => (isApple() ? event.metaKey : event.ctrlKey);\n\n/**\n * Whether an event is the given key. Letters and digits are matched by where\n * they are on the keyboard, because with ⌥ held a Mac reports the character\n * the key types instead (⌥M is `µ`).\n */\nfunction isKey(event: { key: string; code: string }, key: string) {\n  if (/^[a-z]$/.test(key)) return event.code === `Key${key.toUpperCase()}`;\n  if (/^[0-9]$/.test(key)) return event.code === `Digit${key}`;\n  return event.key.toLowerCase() === key;\n}\n\n/** The number a row's shortcut is pressed with: ⌥1 on a Mac, Alt+1 elsewhere. ⌘1 and Ctrl+1 belong to the browser's tabs. */\nconst rowCap = (number: number) => (isApple() ? `⌥${number}` : `Alt+${number}`);\n\n/* -------------------------------------------------------------------------- */\n/* Icons                                                                      */\n/* -------------------------------------------------------------------------- */\n\nconst STAR = 'M11.48 3.5a.56.56 0 0 1 1.04 0l2.13 5.11a.56.56 0 0 0 .48.35l5.52.44c.5.04.7.66.32.99l-4.2 3.6a.56.56 0 0 0-.18.56l1.28 5.39a.56.56 0 0 1-.84.61l-4.73-2.89a.56.56 0 0 0-.58 0l-4.73 2.89a.56.56 0 0 1-.84-.61l1.28-5.39a.56.56 0 0 0-.18-.56l-4.2-3.6a.56.56 0 0 1 .32-.99l5.52-.44a.56.56 0 0 0 .48-.35l2.13-5.11Z';\n\nfunction StarIcon({ filled }: { filled: boolean }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill={filled ? 'currentColor' : 'none'} stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinejoin=\"round\">\n      <path d={STAR} />\n    </svg>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n      <circle cx=\"11\" cy=\"11\" r=\"7\" />\n      <path d=\"m20 20-3.5-3.5\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon({ className }: { className?: string }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.25\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\nfunction ClockIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.25\" strokeLinecap=\"round\">\n      <circle cx=\"12\" cy=\"12\" r=\"9\" />\n      <path d=\"M12 7v5l3 2\" />\n    </svg>\n  );\n}\n\nfunction ChevronIcon({ className }: { className?: string }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                       */\n/* -------------------------------------------------------------------------- */\n\ninterface PickerContextValue {\n  items: readonly ModelPickerItem[];\n  providers: ReadonlyMap<string, ModelPickerProvider>;\n  providerList: readonly ModelPickerProvider[];\n  value: string | null;\n  choose: (value: string) => void;\n  favorites: ReadonlySet<string>;\n  toggleFavorite: (value: string) => void;\n  variant: ModelPickerVariant;\n  hotkey: Hotkey | null;\n  instant: boolean;\n  open: boolean;\n  label: string;\n}\n\nconst PickerContext = createContext<PickerContextValue | null>(null);\n\nfunction usePicker(part: string) {\n  const picker = useContext(PickerContext);\n  if (!picker) throw new Error(`${part} must be used inside <ModelPicker>.`);\n  return picker;\n}\n\nexport interface ModelPickerProps {\n  /** The models to choose from, in the order they are listed. */\n  items: readonly ModelPickerItem[];\n  /** The providers the models come from, in the order the rail and the headings show them. */\n  providers: readonly ModelPickerProvider[];\n  /** The chosen model, when the caller holds it. */\n  value?: string | null;\n  /** The model chosen to begin with, when the caller does not hold it. */\n  defaultValue?: string | null;\n  /** Called with the model's `value` when another is chosen. */\n  onValueChange?: (value: string) => void;\n  /** The starred models, when the caller holds them. */\n  favorites?: readonly string[];\n  /** The models starred to begin with. */\n  defaultFavorites?: readonly string[];\n  /** Called with every starred model when one is starred or unstarred. */\n  onFavoritesChange?: (favorites: string[]) => void;\n  /** The layout of the list: `rail`, `list`, `cards`, `compare` or `detail`. Default `rail`. */\n  variant?: ModelPickerVariant;\n  /**\n   * Keys that open it from anywhere on the page, such as `alt+m`. `mod` is ⌘ on a Mac and Ctrl elsewhere.\n   * Avoid keys the browser keeps for itself, such as ⌘1 to ⌘9, ⌘T and ⌘⇧M, which a page never receives.\n   */\n  hotkey?: string;\n  /** Whether the list is open, when the caller holds it. */\n  open?: boolean;\n  /** Whether the list is open to begin with. */\n  defaultOpen?: boolean;\n  /** Called when the list opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** What a screen reader calls the trigger and the list. Default `Model`. */\n  label?: string;\n  children?: ReactNode;\n}\n\n/** Holds the models, the chosen one and the starred ones. */\nfunction ModelPickerRoot({\n  items,\n  providers,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  favorites: favoritesProp,\n  defaultFavorites = [],\n  onFavoritesChange,\n  variant = 'rail',\n  hotkey: hotkeyText,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  label = 'Model',\n  children,\n}: ModelPickerProps) {\n  const [ownValue, setOwnValue] = useState<string | null>(defaultValue);\n  const [ownFavorites, setOwnFavorites] = useState<readonly string[]>(defaultFavorites);\n  const [ownOpen, setOwnOpen] = useState(defaultOpen);\n  const [instant, setInstant] = useState(false);\n  const value = valueProp !== undefined ? valueProp : ownValue;\n  const favoriteList = favoritesProp ?? ownFavorites;\n  const open = openProp ?? ownOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setOwnOpen(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange]\n  );\n\n  const hotkey = useMemo(() => (hotkeyText ? parseHotkey(hotkeyText) : null), [hotkeyText]);\n\n  useEffect(() => {\n    if (!hotkey) return;\n    function onKeyDown(event: globalThis.KeyboardEvent) {\n      if (!isKey(event, hotkey!.key)) return;\n      if (hotkey!.mod !== modOf(event) || hotkey!.shift !== event.shiftKey || hotkey!.alt !== event.altKey) return;\n      event.preventDefault();\n      // Opened from the keyboard: no growing in.\n      setInstant(true);\n      setOpen(!open);\n    }\n    window.addEventListener('keydown', onKeyDown);\n    return () => window.removeEventListener('keydown', onKeyDown);\n  }, [hotkey, open, setOpen]);\n\n  const context = useMemo<PickerContextValue>(() => {\n    const favorites = new Set(favoriteList);\n    return {\n      items,\n      providers: new Map(providers.map((provider) => [provider.value, provider])),\n      providerList: providers,\n      value,\n      choose(next) {\n        if (valueProp === undefined) setOwnValue(next);\n        onValueChange?.(next);\n        setOpen(false);\n      },\n      favorites,\n      toggleFavorite(model) {\n        const next = favorites.has(model) ? favoriteList.filter((entry) => entry !== model) : [...favoriteList, model];\n        if (favoritesProp === undefined) setOwnFavorites(next);\n        onFavoritesChange?.(next);\n      },\n      variant,\n      hotkey,\n      instant,\n      open,\n      label,\n    };\n  }, [items, providers, value, valueProp, onValueChange, setOpen, favoriteList, favoritesProp, onFavoritesChange, variant, hotkey, instant, open, label]);\n\n  return (\n    <PickerContext.Provider value={context}>\n      <Popover.Root\n        open={open}\n        onOpenChange={(next) => {\n          setInstant(false);\n          setOpen(next);\n        }}\n      >\n        {children}\n      </Popover.Root>\n    </PickerContext.Provider>\n  );\n}\nModelPickerRoot.displayName = 'ModelPicker';\n\n/* -------------------------------------------------------------------------- */\n/* Trigger                                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport interface ModelPickerTriggerProps extends Omit<Popover.Trigger.Props, 'children'> {\n  /** `ghost` has no box until hovered, for a composer's footer. `outline` has an edge, for a toolbar. Default `ghost`. */\n  appearance?: 'ghost' | 'outline';\n  /** What it says before a model is chosen. Default `Choose a model`. */\n  placeholder?: ReactNode;\n  className?: string;\n}\n\n/** The button that opens the list, showing the chosen model's icon and name. */\nfunction ModelPickerTrigger({ appearance, placeholder = 'Choose a model', className, ...props }: ModelPickerTriggerProps) {\n  const picker = usePicker('ModelPicker.Trigger');\n  const slots = pickerVariants({ appearance });\n  const chosen = picker.items.find((item) => item.value === picker.value);\n  const icon = chosen ? (chosen.icon ?? picker.providers.get(chosen.provider)?.icon) : null;\n  const button = (\n    <Popover.Trigger\n      data-slot=\"model-picker-trigger\"\n      aria-label={`${picker.label}: ${chosen?.label ?? 'none chosen'}`}\n      className={slots.trigger({ className })}\n      {...props}\n    >\n      {icon ? (\n        <span aria-hidden=\"true\" className={slots.triggerIcon()}>\n          {icon}\n        </span>\n      ) : null}\n      <span className={slots.triggerLabel()}>{chosen?.label ?? placeholder}</span>\n      <ChevronIcon className={slots.chevron()} />\n    </Popover.Trigger>\n  );\n  if (!picker.hotkey) return button;\n  // With a hotkey, the trigger's tooltip says what it is.\n  return (\n    <Tooltip.Provider>\n      <Tooltip>\n        <Tooltip.Trigger render={button} />\n        <Tooltip.Content variant=\"shortcut\">\n          Switch model\n          <Tooltip.Shortcut keys={hotkeyCaps(picker.hotkey)} />\n        </Tooltip.Content>\n      </Tooltip>\n    </Tooltip.Provider>\n  );\n}\nModelPickerTrigger.displayName = 'ModelPicker.Trigger';\n\n/* -------------------------------------------------------------------------- */\n/* Content                                                                    */\n/* -------------------------------------------------------------------------- */\n\ntype Filter = 'favorites' | 'all' | string;\n\ninterface Group {\n  value: string;\n  label: string;\n  icon?: ReactNode;\n  items: ModelPickerItem[];\n}\n\nfunction matches(item: ModelPickerItem, provider: ModelPickerProvider | undefined, query: string) {\n  const text = `${item.label} ${provider?.label ?? ''} ${item.description ?? ''} ${(item.tags ?? []).join(' ')}`.toLowerCase();\n  return query\n    .toLowerCase()\n    .split(/\\s+/)\n    .filter(Boolean)\n    .every((word) => text.includes(word));\n}\n\nfunction chunk<T>(list: readonly T[], size: number): T[][] {\n  const rows: T[][] = [];\n  for (let index = 0; index < list.length; index += size) rows.push(list.slice(index, index + size));\n  return rows;\n}\n\nconst compactTokens = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\n\n/* -------------------------------------------------------------------------- */\n/* Rows                                                                       */\n/* -------------------------------------------------------------------------- */\n\ninterface RowContextValue {\n  picker: PickerContextValue;\n  slots: ReturnType<typeof pickerVariants>;\n  ordered: readonly ModelPickerItem[];\n}\n\n/** What every row needs from the list it is in, so the rows can live at module scope and keep their state. */\nconst RowContext = createContext<RowContextValue | null>(null);\n\nfunction useRow() {\n  return useContext(RowContext)!;\n}\n\nconst iconOf = (picker: PickerContextValue, item: ModelPickerItem) => item.icon ?? picker.providers.get(item.provider)?.icon;\n\nfunction Star({ item }: { item: ModelPickerItem }) {\n  const { picker, slots } = useRow();\n  const on = picker.favorites.has(item.value);\n  return (\n    // Pressed with a pointer only; ⌥S stars the highlighted row from the\n    // keyboard, since a button inside an option cannot be reached by Tab.\n    <span\n      aria-hidden=\"true\"\n      data-on={on ? '' : undefined}\n      className={slots.star()}\n      onPointerDown={(event) => event.preventDefault()}\n      onClick={(event: MouseEvent) => {\n        event.stopPropagation();\n        event.preventDefault();\n        picker.toggleFavorite(item.value);\n      }}\n    >\n      <StarIcon filled={on} />\n    </span>\n  );\n}\n\nfunction End({ item, showNumber = true }: { item: ModelPickerItem; showNumber?: boolean }) {\n  const { picker, slots, ordered } = useRow();\n  const index = ordered.indexOf(item);\n  const number = index >= 0 && index < 9 ? index + 1 : null;\n  return (\n    <span className={slots.itemEnd()}>\n      {item.value === picker.value ? <CheckIcon className={slots.check()} /> : null}\n      {showNumber && number ? <kbd className={slots.kbd()}>{rowCap(number)}</kbd> : null}\n    </span>\n  );\n}\n\nfunction Name({ item }: { item: ModelPickerItem }) {\n  const { slots } = useRow();\n  return (\n    <span className={slots.itemName()}>\n      <span className={slots.itemNameText()}>{item.label}</span>\n      {item.badge ? <span className={slots.badge()}>{item.badge}</span> : null}\n    </span>\n  );\n}\n\nfunction Meta({ item }: { item: ModelPickerItem }) {\n  const { picker, slots } = useRow();\n  const provider = picker.providers.get(item.provider);\n  if (item.note) {\n    return (\n      <span className={slots.itemMeta()}>\n        {item.disabled ? <ClockIcon /> : null}\n        <span className=\"truncate\">{item.note}</span>\n      </span>\n    );\n  }\n  return (\n    <span className={slots.itemMeta()}>\n      {provider?.icon ? (\n        <span aria-hidden=\"true\" className=\"flex [&_svg]:size-3.5\">\n          {provider.icon}\n        </span>\n      ) : null}\n      <span className=\"truncate\">{provider?.label}</span>\n    </span>\n  );\n}\n\nfunction Meter({ value = 0, max = 5, label }: { value?: number; max?: number; label: string }) {\n  const { slots } = useRow();\n  return (\n    <span role=\"img\" aria-label={`${label} ${value} of ${max}`} className={slots.meter()}>\n      {Array.from({ length: max }, (_, index) => (\n        <span key={index} data-on={index < value ? '' : undefined} className={slots.meterCell()} />\n      ))}\n    </span>\n  );\n}\n\nfunction Price({ value = 0 }: { value?: number }) {\n  const { slots } = useRow();\n  return (\n    <span role=\"img\" aria-label={`Price ${value} of 4`} className={slots.price()}>\n      {Array.from({ length: 4 }, (_, index) => (\n        <span key={index} className={index < value ? 'text-foreground' : 'text-muted-foreground/35'}>\n          $\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction ItemFrame({ item, children }: { item: ModelPickerItem; children: ReactNode }) {\n  const { picker, slots } = useRow();\n  return (\n    <Autocomplete.Item\n      value={item}\n      disabled={item.disabled}\n      data-slot=\"model-picker-item\"\n      data-selected={item.value === picker.value ? '' : undefined}\n      className={slots.item()}\n      onClick={() => picker.choose(item.value)}\n    >\n      {children}\n    </Autocomplete.Item>\n  );\n}\n\nfunction Row({ item }: { item: ModelPickerItem }) {\n  const { picker, slots } = useRow();\n  const { variant } = picker;\n  const icon = iconOf(picker, item);\n  if (variant === 'rail') {\n    return (\n      <ItemFrame item={item}>\n        <Star item={item} />\n        <span className={slots.itemBody()}>\n          <Name item={item} />\n          <Meta item={item} />\n        </span>\n        <End item={item} />\n      </ItemFrame>\n    );\n  }\n  if (variant === 'compare') {\n    return (\n      <ItemFrame item={item}>\n        <span aria-hidden=\"true\" className={slots.itemIcon()}>\n          {icon}\n        </span>\n        <span className={slots.itemBody()}>\n          <Name item={item} />\n        </span>\n        <Meter value={item.speed} label=\"Speed\" />\n        <Meter value={item.intelligence} label=\"Intelligence\" />\n        <span className={slots.priceCell()}>\n          <Price value={item.price} />\n        </span>\n        <span className=\"flex w-4 justify-end\">\n          {item.value === picker.value ? <CheckIcon className={slots.check()} /> : null}\n        </span>\n      </ItemFrame>\n    );\n  }\n  // list and detail: one line.\n  return (\n    <ItemFrame item={item}>\n      <span aria-hidden=\"true\" className={slots.itemIcon()}>\n        {icon}\n      </span>\n      <span className={slots.itemBody()}>\n        <Name item={item} />\n      </span>\n      {variant === 'list' && item.contextWindow ? (\n        <span className=\"text-xs text-muted-foreground tabular-nums\">{compactTokens.format(item.contextWindow)}</span>\n      ) : null}\n      <End item={item} showNumber={variant === 'list'} />\n    </ItemFrame>\n  );\n}\n\nfunction Card({ item }: { item: ModelPickerItem }) {\n  const { picker, slots } = useRow();\n  return (\n    <Autocomplete.Item\n      value={item}\n      disabled={item.disabled}\n      data-slot=\"model-picker-item\"\n      data-selected={item.value === picker.value ? '' : undefined}\n      className={slots.card()}\n      onClick={() => picker.choose(item.value)}\n    >\n      <span className={slots.cardHead()}>\n        <span aria-hidden=\"true\" className=\"flex\">\n          {iconOf(picker, item)}\n        </span>\n        <Name item={item} />\n        <span className=\"ms-auto flex items-center gap-1\">\n          {item.value === picker.value ? <CheckIcon className={slots.check()} /> : null}\n          <Star item={item} />\n        </span>\n      </span>\n      {item.note || item.description ? <span className={slots.cardText()}>{item.note ?? item.description}</span> : null}\n      {item.tags?.length ? <Tags tags={item.tags} /> : null}\n    </Autocomplete.Item>\n  );\n}\n\nfunction Tags({ tags }: { tags: readonly string[] }) {\n  const { slots } = useRow();\n  return (\n    <span className={slots.tags()}>\n      {tags.map((tag) => (\n        <span key={tag} className={slots.tag()}>\n          {tag}\n        </span>\n      ))}\n    </span>\n  );\n}\n\n/** The pane beside the `detail` list: the highlighted model, or the chosen one. */\nfunction Pane({ item }: { item: ModelPickerItem }) {\n  const { picker, slots } = useRow();\n  return (\n    <aside className={slots.pane()}>\n      <span aria-hidden=\"true\" className={slots.paneIcon()}>\n        {iconOf(picker, item)}\n      </span>\n      <div className=\"flex flex-col gap-1\">\n        <span className={slots.paneTitle()}>{item.label}</span>\n        <span className={slots.paneText()}>{item.note ?? item.description ?? picker.providers.get(item.provider)?.label}</span>\n      </div>\n      <div className={slots.stats()}>\n        {item.contextWindow ? (\n          <div className={slots.stat()}>\n            <span className={slots.statLabel()}>Context</span>\n            <span className=\"tabular-nums\">{compactTokens.format(item.contextWindow)} tokens</span>\n          </div>\n        ) : null}\n        {item.speed ? (\n          <div className={slots.stat()}>\n            <span className={slots.statLabel()}>Speed</span>\n            <Meter value={item.speed} label=\"Speed\" />\n          </div>\n        ) : null}\n        {item.intelligence ? (\n          <div className={slots.stat()}>\n            <span className={slots.statLabel()}>Intelligence</span>\n            <Meter value={item.intelligence} label=\"Intelligence\" />\n          </div>\n        ) : null}\n        {item.price ? (\n          <div className={slots.stat()}>\n            <span className={slots.statLabel()}>Price</span>\n            <Price value={item.price} />\n          </div>\n        ) : null}\n      </div>\n      {item.tags?.length ? <Tags tags={item.tags} /> : null}\n      <div className={slots.footer()}>\n        <kbd className={slots.kbd()}>↵</kbd>\n        {item.value === picker.value ? 'In use' : 'Use this model'}\n      </div>\n    </aside>\n  );\n}\n\nexport interface ModelPickerContentProps extends Omit<Popover.Popup.Props, 'children'> {\n  /** Which side of the trigger it opens on. Default `bottom`. */\n  side?: Popover.Positioner.Props['side'];\n  /** How it lines up with the trigger along that side. Default `start`. */\n  align?: Popover.Positioner.Props['align'];\n  /** The gap between the trigger and the list, in pixels. Default `8`. */\n  sideOffset?: number;\n  /** What the search field says while empty. Default `Search models…`. */\n  placeholder?: string;\n  /** What the list says when nothing matches. Default `No models match`. */\n  emptyText?: ReactNode;\n  /** Where in the document it is put. Default `document.body`. */\n  container?: Popover.Portal.Props['container'];\n  className?: string;\n}\n\n/** The list, in the layout the root's `variant` names. */\nfunction ModelPickerContent({\n  side = 'bottom',\n  align = 'start',\n  sideOffset = 8,\n  placeholder = 'Search models…',\n  emptyText = 'No models match',\n  container,\n  className,\n  ...props\n}: ModelPickerContentProps) {\n  const picker = usePicker('ModelPicker.Content');\n  const { variant } = picker;\n  const slots = useMemo(() => pickerVariants({ variant }), [variant]);\n  const input = useRef<HTMLInputElement>(null);\n  const [query, setQuery] = useState('');\n  const firstFilter = (): Filter =>\n    variant === 'rail' ? (picker.favorites.size > 0 ? 'favorites' : (picker.providerList[0]?.value ?? 'all')) : 'all';\n  const [filter, setFilter] = useState<Filter>(firstFilter);\n  const [highlighted, setHighlighted] = useState<ModelPickerItem | undefined>(undefined);\n\n  // Each opening starts afresh: no search left over from the last one.\n  useEffect(() => {\n    if (!picker.open) return;\n    setQuery('');\n    setFilter(firstFilter());\n    setHighlighted(undefined);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [picker.open]);\n\n  // Searching looks through every model, whatever the filter.\n  const visible = useMemo(() => {\n    if (query.trim()) return picker.items.filter((item) => matches(item, picker.providers.get(item.provider), query));\n    if (filter === 'favorites') return picker.items.filter((item) => picker.favorites.has(item.value));\n    if (filter === 'all') return [...picker.items];\n    return picker.items.filter((item) => item.provider === filter);\n    // The favourites are read when the filter changes, not as they are starred,\n    // so starring a row does not pull it out from under the pointer.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [query, filter, picker.items, picker.providers]);\n\n  const groups = useMemo<Group[]>(() => {\n    if (variant !== 'list') return [];\n    const result: Group[] = [];\n    const starred = query.trim() ? [] : visible.filter((item) => picker.favorites.has(item.value));\n    if (starred.length > 0) result.push({ value: 'favorites', label: 'Starred', icon: <StarIcon filled />, items: starred });\n    for (const provider of picker.providerList) {\n      const own = visible.filter((item) => item.provider === provider.value && !starred.includes(item));\n      if (own.length > 0) result.push({ value: provider.value, label: provider.label, icon: provider.icon, items: own });\n    }\n    return result;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [variant, visible, query, picker.providerList]);\n\n  // The order the rows are shown in, which ⌘1 to ⌘9 count along.\n  const ordered = useMemo(() => (variant === 'list' ? groups.flatMap((group) => group.items) : visible), [variant, groups, visible]);\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const digit = /^Digit([1-9])$/.exec(event.code);\n    if (digit && event.altKey && !event.shiftKey && !event.metaKey && !event.ctrlKey) {\n      const item = ordered[Number(digit[1]) - 1];\n      event.preventDefault();\n      if (item && !item.disabled) picker.choose(item.value);\n      return;\n    }\n    if (event.altKey && !event.metaKey && !event.ctrlKey && isKey(event, 's') && highlighted) {\n      event.preventDefault();\n      picker.toggleFavorite(highlighted.value);\n    }\n  }\n\n  const list =\n    variant === 'list' ? (\n      <Autocomplete.List className={slots.list()}>\n        {(group: Group) => (\n          <Autocomplete.Group key={group.value} items={group.items}>\n            <Autocomplete.GroupLabel className={slots.groupLabel()}>\n              <span aria-hidden=\"true\" className=\"flex\">\n                {group.icon}\n              </span>\n              {group.label}\n            </Autocomplete.GroupLabel>\n            <Autocomplete.Collection>{(item: ModelPickerItem) => <Row key={item.value} item={item} />}</Autocomplete.Collection>\n          </Autocomplete.Group>\n        )}\n      </Autocomplete.List>\n    ) : variant === 'cards' ? (\n      <Autocomplete.List className={slots.list()}>\n        {chunk(visible, 2).map((row, index) => (\n          <Autocomplete.Row key={index} className={slots.cardRow()}>\n            {row.map((item) => (\n              <Card key={item.value} item={item} />\n            ))}\n          </Autocomplete.Row>\n        ))}\n      </Autocomplete.List>\n    ) : (\n      <Autocomplete.List className={slots.list()}>{(item: ModelPickerItem) => <Row key={item.value} item={item} />}</Autocomplete.List>\n    );\n\n  const source = (variant === 'list' ? groups : visible) as unknown as ModelPickerItem[];\n  // Held steady across highlight changes, so moving the pointer re-renders the pane and not every row.\n  const rowContext = useMemo(() => ({ picker, slots, ordered }), [picker, slots, ordered]);\n  const shown = highlighted ?? picker.items.find((item) => item.value === picker.value);\n\n  return (\n    <Popover.Portal container={container}>\n      <Popover.Positioner side={side} align={align} sideOffset={sideOffset} collisionPadding={8} className={slots.positioner()}>\n        <Popover.Popup\n          data-slot=\"model-picker-content\"\n          data-variant={variant}\n          data-instant={picker.instant ? '' : undefined}\n          aria-label={`Choose a ${picker.label.toLowerCase()}`}\n          initialFocus={input}\n          className={slots.popup({ className })}\n          onKeyDown={onKeyDown}\n          {...props}\n        >\n          <Autocomplete.Root\n            inline\n            open\n            // Grouped for `list`, flat for the rest; the two shapes are one prop.\n            items={source}\n            filteredItems={source}\n            grid={variant === 'cards'}\n            filter={null}\n            itemToStringValue={(item: ModelPickerItem) => item.label}\n            value={query}\n            onValueChange={(next, details) => {\n              if (details.reason !== 'item-press') setQuery(next);\n            }}\n            onItemHighlighted={(item) => setHighlighted(item as ModelPickerItem | undefined)}\n            autoHighlight=\"always\"\n            keepHighlight\n          >\n            <RowContext.Provider value={rowContext}>\n            {variant === 'rail' ? (\n              <div role=\"toolbar\" aria-orientation=\"vertical\" aria-label=\"Filter by provider\" className={slots.rail()}>\n                <Tooltip.Provider>\n                  <RailButton label=\"Starred\" on={filter === 'favorites' && !query} onPress={() => setFilter('favorites')}>\n                    <StarIcon filled />\n                  </RailButton>\n                  <span aria-hidden=\"true\" className={slots.railSeparator()} />\n                  {picker.providerList.map((provider) => (\n                    <RailButton\n                      key={provider.value}\n                      label={provider.label}\n                      status={provider.status}\n                      on={filter === provider.value && !query}\n                      onPress={() => setFilter(provider.value)}\n                    >\n                      {provider.icon}\n                    </RailButton>\n                  ))}\n                </Tooltip.Provider>\n              </div>\n            ) : null}\n\n            <div className={slots.main()}>\n              <div className={slots.searchRow()}>\n                <Autocomplete.InputGroup className={slots.search()}>\n                  <SearchIcon />\n                  <Autocomplete.Input\n                    ref={input}\n                    aria-label={`Search ${picker.label.toLowerCase()}s`}\n                    placeholder={placeholder}\n                    className={slots.searchInput()}\n                  />\n                </Autocomplete.InputGroup>\n              </div>\n              {variant === 'cards' ? (\n                <div role=\"toolbar\" aria-label=\"Filter by provider\" className={slots.chips()}>\n                  {[{ value: 'all', label: 'All', icon: null as ReactNode }, ...picker.providerList].map((provider) => (\n                    <button\n                      key={provider.value}\n                      type=\"button\"\n                      aria-pressed={filter === provider.value && !query}\n                      className={slots.chip()}\n                      onClick={() => setFilter(provider.value)}\n                    >\n                      {provider.icon ? <span aria-hidden=\"true\" className=\"flex\">{provider.icon}</span> : null}\n                      {provider.label}\n                    </button>\n                  ))}\n                </div>\n              ) : null}\n              {variant === 'compare' ? (\n                <div aria-hidden=\"true\" className={slots.tableHead()}>\n                  <span className=\"flex-1\">Model</span>\n                  <span className={slots.column()}>Speed</span>\n                  <span className={slots.column()}>Smarts</span>\n                  <span className={slots.priceColumn()}>Price</span>\n                  <span className=\"w-4\" />\n                </div>\n              ) : null}\n              {/* Always in the tree, so the padding goes on what it holds while the list is empty. */}\n              <Autocomplete.Empty>\n                <div className={slots.empty()}>{emptyText}</div>\n              </Autocomplete.Empty>\n              {list}\n            </div>\n\n            {variant === 'detail' && shown ? <Pane item={shown} /> : null}\n            </RowContext.Provider>\n          </Autocomplete.Root>\n        </Popover.Popup>\n      </Popover.Positioner>\n    </Popover.Portal>\n  );\n}\nModelPickerContent.displayName = 'ModelPicker.Content';\n\nfunction RailButton({\n  label,\n  status,\n  on,\n  onPress,\n  children,\n}: {\n  label: string;\n  status?: ModelPickerProvider['status'];\n  on: boolean;\n  onPress: () => void;\n  children: ReactNode;\n}) {\n  const slots = pickerVariants();\n  return (\n    <Tooltip>\n      <Tooltip.Trigger\n        render={<button type=\"button\" aria-pressed={on} aria-label={label} className={slots.railButton()} onClick={onPress} />}\n      >\n        {children}\n        {on ? <span aria-hidden=\"true\" className={slots.railMark()} /> : null}\n        {status === 'new' ? <span aria-hidden=\"true\" className={slots.railStatus({ className: 'bg-info' })} /> : null}\n        {status === 'limited' ? (\n          <span aria-hidden=\"true\" className={slots.railStatus({ className: 'bg-muted text-muted-foreground [&_svg]:size-2.5' })}>\n            <ClockIcon />\n          </span>\n        ) : null}\n      </Tooltip.Trigger>\n      <Tooltip.Content side=\"right\">\n        {label}\n        {status === 'new' ? ' · New' : status === 'limited' ? ' · Rate-limited' : ''}\n      </Tooltip.Content>\n    </Tooltip>\n  );\n}\n\nexport const ModelPicker = Object.assign(ModelPickerRoot, {\n  Trigger: ModelPickerTrigger,\n  Content: ModelPickerContent,\n});\n\nexport { ModelPickerTrigger, ModelPickerContent };\n"
    }
  ]
}
