{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-switcher",
  "type": "registry:component",
  "title": "Model Switcher",
  "description": "A model menu that helps you choose: hover a model to compare speed, quality and cost, turn on thinking to set the effort, and watch latency and price count to their new values, inline or from a pill trigger.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/popover.json",
    "https://coss.com/ui/r/slider.json",
    "https://coss.com/ui/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/devl/model-switcher.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { BrainIcon, CheckIcon, ChevronDownIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useId, useRef, useState } from \"react\";\nimport { Popover, PopoverPopup, PopoverTrigger } from \"@/components/ui/popover\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SwitcherModel {\n  id: string;\n  name: string;\n  tagline: string;\n  speed: number;\n  quality: number;\n  cost: number;\n  latency: number;\n  price: number;\n  thinking?: boolean;\n  badge?: string;\n}\n\nexport interface ModelSwitcherValue {\n  model: string;\n  thinking: boolean;\n  effort: number;\n}\n\nexport interface ModelSwitcherPanelProps {\n  models?: SwitcherModel[];\n  value?: ModelSwitcherValue;\n  defaultValue?: Partial<ModelSwitcherValue>;\n  onValueChange?: (value: ModelSwitcherValue) => void;\n  className?: string;\n}\n\nconst defaultModels: SwitcherModel[] = [\n  {\n    id: \"quill-4\",\n    name: \"Quill 4\",\n    tagline: \"Everyday writing and code\",\n    speed: 0.72,\n    quality: 0.78,\n    cost: 0.45,\n    latency: 380,\n    price: 3,\n    thinking: true,\n    badge: \"Default\",\n  },\n  {\n    id: \"quill-4-lite\",\n    name: \"Quill 4 Lite\",\n    tagline: \"Snappy answers, tiny bills\",\n    speed: 0.95,\n    quality: 0.55,\n    cost: 0.12,\n    latency: 160,\n    price: 0.4,\n  },\n  {\n    id: \"meridian-ultra\",\n    name: \"Meridian Ultra\",\n    tagline: \"Hard problems, long context\",\n    speed: 0.38,\n    quality: 0.97,\n    cost: 0.9,\n    latency: 820,\n    price: 15,\n    thinking: true,\n  },\n];\n\nconst efforts = [\"Low\", \"Medium\", \"High\", \"Max\"];\nconst spring = { type: \"spring\", stiffness: 420, damping: 36 } as const;\nconst easeOut = [0.23, 1, 0.32, 1] as const;\n\nconst resolve = (models: SwitcherModel[], v?: Partial<ModelSwitcherValue>): ModelSwitcherValue => ({\n  model: v?.model ?? models[0]?.id ?? \"\",\n  thinking: v?.thinking ?? false,\n  effort: v?.effort ?? 1,\n});\n\nexport function ModelSwitcherPanel({\n  models = defaultModels,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  className,\n}: ModelSwitcherPanelProps) {\n  const reduce = useReducedMotion();\n  const groupId = useId();\n  const [inner, setInner] = useState(() => resolve(models, defaultValue));\n  const value = valueProp ?? inner;\n  const [hovered, setHovered] = useState<string | null>(null);\n  const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const selected = models.find((m) => m.id === value.model) ?? models[0];\n  const shown = models.find((m) => m.id === hovered) ?? selected;\n  const canThink = Boolean(selected?.thinking);\n  const thinking = canThink && value.thinking;\n\n  const update = (patch: Partial<ModelSwitcherValue>) => {\n    const next = { ...value, ...patch };\n    if (!valueProp) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const latency = (selected?.latency ?? 0) * (thinking ? 1.6 + value.effort * 0.9 : 1);\n  const price = selected?.price ?? 0;\n\n  const onKey = (e: KeyboardEvent, i: number) => {\n    const dir = e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowUp\" ? -1 : 0;\n    if (!dir) return;\n    e.preventDefault();\n    const next = (i + dir + models.length) % models.length;\n    const m = models[next];\n    if (m) update({ model: m.id });\n    rowRefs.current[next]?.focus();\n  };\n\n  return (\n    <div className={cn(\"w-80 text-sm\", className)}>\n      <div\n        aria-label=\"Model\"\n        className=\"flex flex-col gap-0.5 p-1.5\"\n        id={groupId}\n        onPointerLeave={() => setHovered(null)}\n        role=\"radiogroup\"\n      >\n        {models.map((m, i) => {\n          const on = m.id === value.model;\n          return (\n            <button\n              aria-checked={on}\n              className=\"relative flex items-center gap-3 rounded-lg px-2.5 py-2 text-start outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              key={m.id}\n              onClick={() => update({ model: m.id })}\n              onFocus={() => setHovered(m.id)}\n              onKeyDown={(e) => onKey(e, i)}\n              onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(m.id)}\n              ref={(el) => {\n                rowRefs.current[i] = el;\n              }}\n              role=\"radio\"\n              tabIndex={on ? 0 : -1}\n              type=\"button\"\n            >\n              {(hovered ?? value.model) === m.id && (\n                <motion.span\n                  className=\"absolute inset-0 -z-0 rounded-lg bg-accent\"\n                  layoutId={`${groupId}-hl`}\n                  transition={reduce ? { duration: 0 } : spring}\n                />\n              )}\n              <span className=\"relative min-w-0 flex-1\">\n                <span className=\"flex items-center gap-1.5 font-medium\">\n                  {m.name}\n                  {m.badge && (\n                    <span className=\"rounded-full bg-muted px-1.5 py-px font-normal text-[10px] text-muted-foreground\">\n                      {m.badge}\n                    </span>\n                  )}\n                  {m.thinking && <BrainIcon aria-label=\"Supports extended thinking\" className=\"size-3 text-muted-foreground\" />}\n                </span>\n                <span className=\"block truncate text-muted-foreground text-xs\">{m.tagline}</span>\n              </span>\n              <span className=\"relative grid size-5 place-items-center\">\n                <AnimatePresence initial={false}>\n                  {on && (\n                    <motion.span\n                      animate={{ scale: 1, opacity: 1 }}\n                      className=\"grid size-5 place-items-center rounded-full bg-primary text-primary-foreground\"\n                      exit={{ scale: 0.6, opacity: 0, transition: { duration: 0.12 } }}\n                      initial={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n                      transition={{ duration: 0.2, ease: easeOut }}\n                    >\n                      <CheckIcon className=\"size-3\" />\n                    </motion.span>\n                  )}\n                </AnimatePresence>\n              </span>\n            </button>\n          );\n        })}\n      </div>\n\n      <div className=\"mx-1.5 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 rounded-lg bg-muted/50 px-2.5 py-2.5 text-xs\">\n        <Bar label=\"Speed\" tone=\"bg-sky-500\" value={shown?.speed ?? 0} />\n        <Bar label=\"Quality\" tone=\"bg-violet-500\" value={shown?.quality ?? 0} />\n        <Bar label=\"Cost\" tone=\"bg-amber-500\" value={shown?.cost ?? 0} />\n      </div>\n\n      <div className=\"mx-4 mt-2 border-t pt-2\">\n        <label className={cn(\"flex items-center gap-2.5 py-1.5\", !canThink && \"opacity-60\")}>\n          <BrainIcon className=\"size-4 text-muted-foreground\" />\n          <span className=\"flex-1\">\n            <span className=\"block font-medium\">Extended thinking</span>\n            <span className=\"block text-muted-foreground text-xs\">\n              {canThink ? \"Reasons it through before answering\" : `Not available on ${selected?.name}`}\n            </span>\n          </span>\n          <Switch checked={thinking} disabled={!canThink} onCheckedChange={(c) => update({ thinking: c })} />\n        </label>\n        <AnimatePresence initial={false}>\n          {thinking && (\n            <motion.div\n              animate={{ height: \"auto\", opacity: 1 }}\n              className=\"overflow-hidden\"\n              exit={{ height: 0, opacity: 0 }}\n              initial={{ height: 0, opacity: 0 }}\n              transition={reduce ? { duration: 0 } : spring}\n            >\n              <div className=\"pt-1 pb-2 ps-6.5 pe-0.5\">\n                <div className=\"mb-2 flex items-center justify-between text-xs\">\n                  <span className=\"text-muted-foreground\">Effort</span>\n                  <span className=\"relative inline-flex h-4 min-w-14 justify-end overflow-hidden font-medium\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.span\n                        animate={{ y: 0, opacity: 1 }}\n                        exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0 }}\n                        initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0 }}\n                        key={value.effort}\n                        transition={{ duration: 0.2, ease: easeOut }}\n                      >\n                        {efforts[value.effort]}\n                      </motion.span>\n                    </AnimatePresence>\n                  </span>\n                </div>\n                <Slider\n                  getAriaLabel={() => \"Thinking effort\"}\n                  max={efforts.length - 1}\n                  min={0}\n                  onValueChange={(v) => update({ effort: Array.isArray(v) ? (v[0] ?? 0) : v })}\n                  step={1}\n                  value={value.effort}\n                />\n                <div className=\"mt-1.5 flex justify-between text-[10px] text-muted-foreground\">\n                  {efforts.map((e) => (\n                    <span key={e}>{e}</span>\n                  ))}\n                </div>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"mt-1 flex items-center justify-between gap-3 border-t px-4 py-2.5 text-muted-foreground text-xs\">\n        <span>\n          First token{\" \"}\n          <span className=\"font-medium font-mono text-foreground tabular-nums\">\n            ~<Rolling format={(v) => (v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`)} value={latency} />\n          </span>\n        </span>\n        <span>\n          <span className=\"font-medium font-mono text-foreground tabular-nums\">\n            <Rolling format={(v) => `$${v.toFixed(2)}`} value={price} />\n          </span>{\" \"}\n          / 1M tokens\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction Bar({ label, value, tone }: { label: string; value: number; tone: string }) {\n  return (\n    <>\n      <span className=\"text-muted-foreground\">{label}</span>\n      <span\n        aria-label={`${label} ${Math.round(value * 5)} of 5`}\n        className=\"relative grid h-1.5 grid-cols-5 gap-0.5\"\n        role=\"img\"\n      >\n        {Array.from({ length: 5 }, (_, i) => {\n          const fill = Math.max(0, Math.min(1, value * 5 - i));\n          return (\n            <span className=\"relative overflow-hidden rounded-full bg-muted-foreground/15\" key={i}>\n              <motion.span\n                animate={{ scaleX: fill }}\n                className={cn(\"absolute inset-0 origin-left rounded-full\", tone)}\n                initial={false}\n                transition={{ type: \"spring\", stiffness: 380, damping: 36, delay: i * 0.025 }}\n              />\n            </span>\n          );\n        })}\n      </span>\n    </>\n  );\n}\n\nfunction Rolling({ value, format }: { value: number; format: (v: number) => string }) {\n  const reduce = useReducedMotion();\n  const mv = useSpring(value, { stiffness: 200, damping: 30 });\n  const text = useTransform(mv, format);\n  useEffect(() => {\n    if (reduce) mv.jump(value);\n    else mv.set(value);\n  }, [value, reduce, mv]);\n  return <motion.span>{text}</motion.span>;\n}\n\nexport interface ModelSwitcherProps extends ModelSwitcherPanelProps {\n  defaultOpen?: boolean;\n}\n\nexport function ModelSwitcher({ models = defaultModels, defaultOpen, value, defaultValue, onValueChange, className }: ModelSwitcherProps) {\n  const [inner, setInner] = useState(() => resolve(models, defaultValue));\n  const current = value ?? inner;\n  const model = models.find((m) => m.id === current.model);\n  return (\n    <Popover defaultOpen={defaultOpen}>\n      <PopoverTrigger\n        className={cn(\n          \"inline-flex h-8 items-center gap-1.5 rounded-full border bg-card px-3 font-medium text-sm outline-none transition-[background-color,scale] duration-150 ease-out hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] data-popup-open:bg-accent\",\n          className,\n        )}\n      >\n        {model?.name}\n        {current.thinking && model?.thinking && <BrainIcon className=\"size-3.5 text-muted-foreground\" />}\n        <ChevronDownIcon className=\"size-3.5 text-muted-foreground transition-transform duration-200 ease-out in-data-popup-open:rotate-180\" />\n      </PopoverTrigger>\n      <PopoverPopup\n        align=\"start\"\n        className=\"rounded-2xl before:rounded-[calc(var(--radius-2xl)-1px)] [&_[data-slot=popover-viewport]]:p-0\"\n      >\n        <ModelSwitcherPanel\n          models={models}\n          onValueChange={(v) => {\n            if (!value) setInner(v);\n            onValueChange?.(v);\n          }}\n          value={current}\n        />\n      </PopoverPopup>\n    </Popover>\n  );\n}\n"
    }
  ]
}