{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reasoning",
  "type": "registry:ui",
  "title": "Reasoning",
  "description": "Thinking shown as one timed line that opens to the stream and folds itself away.",
  "categories": [
    "ai-chat"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/shimmer-text.json"
  ],
  "files": [
    {
      "path": "components/ui/reasoning.tsx",
      "content": "\"use client\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { ShimmerText } from \"@/components/ui/shimmer-text\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronRight } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/** Seconds since `running` last became true, ticking while it stays true and frozen after. */\nexport function useThinkingTimer(running: boolean) {\n  const [seconds, setSeconds] = useState(0);\n  const started = useRef(0);\n\n  useEffect(() => {\n    if (!running) return;\n    started.current = performance.now();\n    // Measured from the start, not counted, so a throttled background tab stays accurate.\n    const tick = () => setSeconds((performance.now() - started.current) / 1000);\n    const id = window.setInterval(tick, 250);\n    const raf = requestAnimationFrame(tick);\n    return () => {\n      window.clearInterval(id);\n      cancelAnimationFrame(raf);\n      tick();\n    };\n  }, [running]);\n\n  return seconds;\n}\n\nexport type ReasoningProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The reasoning text, or a node that renders it (a streaming text component). Leave empty when the model shows none. */\n  children?: React.ReactNode;\n  /** The model is thinking right now. */\n  streaming?: boolean;\n  /** Seconds it thought for, when you already know (a message from history). Otherwise it is measured. */\n  duration?: number;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Open while thinking and fold away when it finishes, unless the person has opened, closed or scrolled it themselves. */\n  autoCollapse?: boolean;\n  /** Label while thinking. */\n  thinkingLabel?: string;\n};\n\nconst formatSpoken = (s: number) => {\n  const m = Math.floor(s / 60);\n  const r = s % 60;\n  return m ? `${m} minute${m === 1 ? \"\" : \"s\"}${r ? ` ${r} second${r === 1 ? \"\" : \"s\"}` : \"\"}` : `${r} second${r === 1 ? \"\" : \"s\"}`;\n};\n\n/**\n * A model's thinking, collapsed to one line: \"Thinking…\" with a timer while it\n * runs, \"Thought for 12s\" after. It opens to show the reasoning stream and folds\n * itself away when the answer starts, unless the reader took it over.\n */\nexport function Reasoning({\n  children,\n  streaming = false,\n  duration,\n  open: openProp,\n  defaultOpen,\n  onOpenChange,\n  autoCollapse = true,\n  thinkingLabel = \"Thinking…\",\n  className,\n  ...rest\n}: ReasoningProps) {\n  const reduce = useReducedMotion();\n  const hasContent = children != null && children !== false && children !== \"\";\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen ?? (autoCollapse && streaming && hasContent), onChange: onOpenChange });\n  const measured = useThinkingTimer(streaming);\n  const seconds = duration ?? measured;\n  const whole = streaming ? Math.floor(seconds) : Math.max(1, Math.round(seconds));\n  const known = streaming || duration != null || measured > 0;\n  const showTimer = known && (!streaming || whole >= 2);\n\n  // Who is in charge of open: us, until the reader toggles or scrolls it. A new\n  // round of thinking hands control back.\n  const [touched, setTouched] = useState(false);\n  const [prevStreaming, setPrevStreaming] = useState(streaming);\n  if (prevStreaming !== streaming) {\n    setPrevStreaming(streaming);\n    if (streaming) setTouched(false);\n  }\n  const auto = autoCollapse && !touched;\n  const want = streaming && hasContent;\n  useEffect(() => {\n    if (auto && open !== want) setOpen(want);\n  }, [auto, want, open, setOpen]);\n\n  const label = streaming ? thinkingLabel : known ? \"Thought for\" : \"Reasoning\";\n  const spoken = streaming ? `${thinkingLabel.replace(/…$/, \"\")}${showTimer ? `, ${formatSpoken(whole)}` : \"\"}` : known ? `Thought for ${formatSpoken(whole)}` : \"Reasoning\";\n\n  // Label and timer share a baseline; the digits' box is taller than the text's.\n  const line = (\n    <span className=\"inline-flex min-w-0 items-baseline gap-1.5\">\n      <ShimmerText active={streaming} className={cn(\"min-w-0 transition-colors duration-150\", streaming ? \"text-fg-2\" : \"text-fg-3 group-hover/reasoning:text-fg-2\")}>\n        {label}\n      </ShimmerText>\n      <AnimatePresence initial={false}>\n        {showTimer && (\n          <motion.span\n            key=\"timer\"\n            aria-hidden\n            initial={reduce ? { opacity: 0 } : { opacity: 0, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.24, ease: ease.out }}\n            className={cn(\"tabular transition-colors duration-150\", streaming ? \"text-fg-3\" : \"text-fg-3 group-hover/reasoning:text-fg-2\")}\n          >\n            <Elapsed seconds={whole} />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n\n  return (\n    <Collapsible.Root\n      open={hasContent && open}\n      onOpenChange={(next) => {\n        setTouched(true);\n        setOpen(next);\n      }}\n      disabled={!hasContent}\n      data-state={streaming ? \"thinking\" : \"done\"}\n      className={cn(\"flex min-w-0 flex-col\", className)}\n      {...rest}\n    >\n      {hasContent ? (\n        <Collapsible.Trigger\n          aria-label={spoken}\n          className={cn(\n            \"group/reasoning relative -mx-1.5 inline-flex h-7 max-w-full cursor-default select-none items-center gap-1.5 self-start rounded-md px-1.5 text-[13px]\",\n            \"outline-none transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.98] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          {line}\n          <ChevronRight\n            size={14}\n            className={cn(\n              \"shrink-0 text-fg-4 transition-[rotate,color] duration-200 ease-out-expo group-hover/reasoning:text-fg-3 motion-reduce:transition-none\",\n              \"group-data-panel-open/reasoning:rotate-90\",\n            )}\n          />\n        </Collapsible.Trigger>\n      ) : (\n        // The model shared nothing to read: just the elapsed line, nothing to open.\n        <div className=\"inline-flex h-7 max-w-full items-center gap-1.5 self-start text-[13px]\">\n          <span aria-hidden className=\"contents\">\n            {line}\n          </span>\n          <span className=\"sr-only\">{spoken}</span>\n        </div>\n      )}\n\n      {hasContent && (\n        <Collapsible.Panel\n          keepMounted\n          className={cn(\n            \"h-(--collapsible-panel-height) overflow-hidden [&[hidden]:not([hidden='until-found'])]:hidden\",\n            \"transition-[height,opacity] duration-240 ease-in-out-quart data-ending-style:duration-180\",\n            \"data-starting-style:h-0 data-starting-style:opacity-0 data-ending-style:h-0 data-ending-style:opacity-0\",\n            \"motion-reduce:transition-[opacity] motion-reduce:duration-150\",\n          )}\n        >\n          <ReasoningBody streaming={streaming} onReaderScroll={() => setTouched(true)}>\n            {children}\n          </ReasoningBody>\n        </Collapsible.Panel>\n      )}\n\n      <span role=\"status\" className=\"sr-only\">\n        {!streaming && measured > 0 && duration == null ? `Finished thinking after ${formatSpoken(whole)}` : \"\"}\n      </span>\n    </Collapsible.Root>\n  );\n}\n\n/** \"12s\", \"1m 4s\". Digits roll as they change. */\nfunction Elapsed({ seconds }: { seconds: number }) {\n  const m = Math.floor(seconds / 60);\n  const s = seconds % 60;\n  return (\n    <NumberFlowGroup>\n      <span className=\"inline-flex items-baseline gap-[0.25em]\">\n        {m > 0 && <NumberFlow value={m} suffix=\"m\" />}\n        <NumberFlow value={s} suffix=\"s\" />\n      </span>\n    </NumberFlowGroup>\n  );\n}\n\n// The reasoning reads as a quiet margin note under the line. While it streams it\n// keeps a short window pinned to the newest line; scroll up and it stops\n// following you and stays put.\nfunction ReasoningBody({ children, streaming, onReaderScroll }: { children: React.ReactNode; streaming: boolean; onReaderScroll: () => void }) {\n  const scroller = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const pinned = useRef(true);\n\n  useEffect(() => {\n    const el = scroller.current;\n    const inner = content.current;\n    if (!el || !inner || !streaming) return;\n    pinned.current = true;\n    const follow = () => {\n      if (pinned.current) el.scrollTop = el.scrollHeight;\n    };\n    follow();\n    const ro = new ResizeObserver(follow);\n    ro.observe(inner);\n    return () => ro.disconnect();\n  }, [streaming]);\n\n  return (\n    <div className=\"pt-1 pb-2\">\n      <div\n        ref={scroller}\n        data-streaming={streaming || undefined}\n        tabIndex={0}\n        onScroll={(e) => {\n          const el = e.currentTarget;\n          const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;\n          el.toggleAttribute(\"data-scrolled\", el.scrollTop > 2);\n          // Following the stream only ever scrolls to the bottom, so leaving the\n          // bottom is always the reader's doing.\n          if (streaming && pinned.current && !atBottom) {\n            pinned.current = false;\n            onReaderScroll();\n          } else if (atBottom) pinned.current = true;\n        }}\n        className={cn(\n          \"relative overflow-y-auto overscroll-contain border-s-2 border-line ps-3 pe-1 outline-none\",\n          \"text-[12.5px] leading-5 text-fg-2 [scrollbar-width:thin]\",\n          \"focus-visible:rounded-e-md focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"data-streaming:max-h-[140px] max-h-[280px]\",\n          // A soft top edge once there is text above, so the window reads as a window.\n          \"data-scrolled:[mask-image:linear-gradient(to_bottom,transparent,black_28px)]\",\n        )}\n      >\n        <div ref={content} className={cn(\"[overflow-wrap:anywhere]\", typeof children === \"string\" && \"whitespace-pre-wrap\")}>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/reasoning"
}
