{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tool-call","type":"registry:component","title":"Tool call","description":"One tool invocation from arguments to result: preparing, running, done, failed or denied.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/tool-call.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, Ban, ChevronDown, RotateCcw, Wrench } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * TOOL CALL: one tool invocation, from arguments to result\n *\n *   preparing  the model is still writing the arguments; the\n *              panel eases taller as each one arrives\n *   running    a spinner opens in and the label morphs to\n *              \"Running\"\n *   done       the spinner blurs into a check that draws itself\n *              and the run time rolls up from zero; the output\n *              rises in as the panel eases to fit it\n *   error      the label morphs to \"Failed\" in red, with the\n *              message and a retry\n *   denied     the user said no\n *\n * One status pill throughout: its icon slot opens and closes,\n * icons swap through a blur and the label morphs, so it never\n * swaps out. Arguments render as readable fields; nested\n * values as JSON.\n * Pass renderOutput to show a result your own way.\n * ───────────────────────────────────────────────────────── */\n\nexport type ToolCallStatus = \"preparing\" | \"running\" | \"done\" | \"error\" | \"denied\";\n\nexport interface ToolCallProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  /** The tool's name as the model calls it, e.g. \"search_flights\". */\n  name: string;\n  /** A human label, e.g. \"Search flights\". */\n  title?: string;\n  status: ToolCallStatus;\n  /** Arguments, possibly partial while preparing. */\n  input?: unknown;\n  /** The result, once done. */\n  output?: unknown;\n  /** Render the result yourself, e.g. as a small card. */\n  renderOutput?: (output: unknown) => React.ReactNode;\n  errorText?: string;\n  onRetry?: () => void;\n  /** How long it ran (ms). */\n  duration?: number;\n  icon?: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\nconst LABELS: Record<ToolCallStatus, string> = { preparing: \"Preparing\", running: \"Running\", done: \"Done\", error: \"Failed\", denied: \"Denied\" };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\nfunction DrawnCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${MORPH} 120ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\n/** The run time, in the unit that reads best; it rolls up from zero when the call has just finished. */\nfunction Duration({ ms, rollIn }: { ms: number; rollIn: boolean }) {\n  const from = rollIn ? 0 : undefined;\n  if (ms < 1000) return <NumberRoll value={Math.round(ms)} suffix=\"ms\" from={from} />;\n  if (ms < 60000) return <NumberRoll value={Math.round(ms / 100) / 10} format={ONE_DECIMAL} suffix=\"s\" from={from} />;\n  return (\n    <>\n      <NumberRoll value={Math.floor(ms / 60000)} suffix=\"m\" from={from} />\n      &nbsp;\n      <NumberRoll value={Math.round((ms % 60000) / 1000)} suffix=\"s\" from={from} />\n    </>\n  );\n}\nconst ONE_DECIMAL = { minimumFractionDigits: 1, maximumFractionDigits: 1 };\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n  return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction Value({ value }: { value: unknown }) {\n  if (typeof value === \"string\") return <span className=\"break-words text-foreground\">{value}</span>;\n  if (typeof value === \"number\" || typeof value === \"boolean\" || value === null)\n    return <span className=\"font-mono text-[12px] text-foreground\">{String(value)}</span>;\n  return <span className=\"break-all font-mono text-[11.5px] text-muted-foreground\">{JSON.stringify(value)}</span>;\n}\n\nfunction Fields({ value }: { value: unknown }) {\n  if (isPlainObject(value)) {\n    const entries = Object.entries(value);\n    if (entries.length === 0) return <p className=\"text-[12.5px] text-muted-foreground\">No arguments</p>;\n    return (\n      <dl className=\"grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1.5 text-[12.5px]\">\n        {entries.map(([key, v]) => (\n          <React.Fragment key={key}>\n            <dt className=\"truncate font-mono text-[11.5px] leading-5 text-muted-foreground\">{key}</dt>\n            <dd className=\"min-w-0 leading-5\">\n              <Value value={v} />\n            </dd>\n          </React.Fragment>\n        ))}\n      </dl>\n    );\n  }\n  if (typeof value === \"string\") return <p className=\"whitespace-pre-wrap text-[12.5px] text-foreground\">{value}</p>;\n  return (\n    <pre className=\"max-h-56 overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-[11.5px] leading-5 text-foreground\">\n      {JSON.stringify(value, null, 2)}\n    </pre>\n  );\n}\n\nfunction Label({ children }: { children: React.ReactNode }) {\n  return <p className=\"mb-1.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground\">{children}</p>;\n}\n\nexport function ToolCall({\n  name,\n  title,\n  status,\n  input,\n  output,\n  renderOutput,\n  errorText,\n  onRetry,\n  duration,\n  icon,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  className = \"\",\n  ...props\n}: ToolCallProps) {\n  const reduced = useReducedMotion();\n  const [ownOpen, setOwnOpen] = React.useState(defaultOpen);\n  const [errorSeen, setErrorSeen] = React.useState(false);\n  const panelId = React.useId();\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const [bodyHeight, setBodyHeight] = React.useState<number | null>(null);\n\n  // Each time it finishes while you watch, the run time rolls up from zero (a finished call in history just sits).\n  const [prevStatus, setPrevStatus] = React.useState(status);\n  const [finishes, setFinishes] = React.useState(0);\n  if (prevStatus !== status) {\n    setPrevStatus(status);\n    if (status === \"done\") setFinishes((n) => n + 1);\n  }\n\n  // The panel takes the height of what's in it, so arguments and output arriving ease it taller instead of jumping.\n  React.useLayoutEffect(() => {\n    const el = bodyRef.current;\n    if (!el) return;\n    const measure = () => setBodyHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Failures open themselves once, so the reason is visible without a click.\n  React.useEffect(() => {\n    if (status === \"error\" && !errorSeen) {\n      setErrorSeen(true);\n      if (open === undefined) setOwnOpen(true);\n    }\n  }, [status, errorSeen, open]);\n\n  const expanded = open ?? ownOpen;\n  const toggle = () => {\n    const next = !expanded;\n    if (open === undefined) setOwnOpen(next);\n    onOpenChange?.(next);\n  };\n\n  const showTime = status === \"done\" && duration !== undefined;\n  const icons: Partial<Record<ToolCallStatus, React.ReactNode>> = {\n    // Spins only while running, so a hidden spinner never keeps the page busy.\n    running: (\n      <span className={`size-3 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${status === \"running\" ? \"animate-spin\" : \"\"}`} />\n    ),\n    done: (\n      <span className=\"text-emerald-600 dark:text-emerald-400\">\n        <DrawnCheck drawn={status === \"done\"} reduced={reduced} />\n      </span>\n    ),\n    error: <AlertCircle className=\"size-3.5\" />,\n    denied: <Ban className=\"size-3.5\" />,\n  };\n  const badge = (\n    <span\n      className={`flex items-center text-[12px] transition-colors duration-300 ${status === \"error\" ? \"font-medium text-red-500\" : status === \"preparing\" ? \"font-medium\" : \"text-muted-foreground\"}`}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"relative flex h-3.5 shrink-0 items-center justify-center\"\n        style={{\n          width: status === \"preparing\" ? 0 : 14,\n          marginRight: status === \"preparing\" ? 0 : 5,\n          transition: reduced ? \"none\" : `width 380ms ${MORPH}, margin 380ms ${MORPH}`,\n        }}\n      >\n        {(Object.keys(icons) as ToolCallStatus[]).map((s) => (\n          <span key={s} className=\"absolute inset-0 flex items-center justify-center\" style={swap(s === status, reduced)}>\n            {icons[s]}\n          </span>\n        ))}\n      </span>\n      <span className={status === \"preparing\" ? SHEEN : \"\"}>\n        <TextMorph>{showTime ? \"\" : LABELS[status]}</TextMorph>\n      </span>\n      {duration !== undefined && (\n        <span\n          aria-hidden={!showTime || undefined}\n          className=\"grid\"\n          style={{\n            gridTemplateColumns: showTime ? \"1fr\" : \"0fr\",\n            opacity: showTime ? 1 : 0,\n            transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}`,\n          }}\n        >\n          <span className=\"flex min-w-0 whitespace-nowrap tabular-nums [clip-path:inset(-4px_0)]\">\n            <Duration key={finishes} ms={duration} rollIn={finishes > 0 && !reduced} />\n          </span>\n        </span>\n      )}\n    </span>\n  );\n\n  return (\n    <div className={`overflow-hidden rounded-xl border border-border bg-background ${className}`} {...props}>\n      <button\n        type=\"button\"\n        aria-expanded={expanded}\n        aria-controls={panelId}\n        onClick={toggle}\n        className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-accent/60 ${FOCUS}`}\n      >\n        <span aria-hidden=\"true\" className=\"flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n          {icon ?? <Wrench className=\"size-3.5\" />}\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate text-[13px] font-medium text-foreground\">{title ?? name}</span>\n          {title && <span className=\"block truncate font-mono text-[11px] text-muted-foreground\">{name}</span>}\n        </span>\n        <span role=\"status\" aria-live=\"polite\" className=\"shrink-0\">\n          {badge}\n        </span>\n        <ChevronDown aria-hidden=\"true\" className={`size-3.5 shrink-0 text-muted-foreground transition-transform duration-300 motion-reduce:transition-none ${expanded ? \"rotate-180\" : \"\"}`} />\n      </button>\n\n      <div\n        id={panelId}\n        inert={!expanded}\n        className=\"overflow-hidden\"\n        style={{\n          height: expanded ? (bodyHeight ?? \"auto\") : 0,\n          opacity: expanded ? 1 : 0,\n          transition: reduced || bodyHeight === null ? \"none\" : `height 420ms ${MORPH}, opacity 300ms ${MORPH}`,\n        }}\n      >\n        <div ref={bodyRef}>\n          <div className=\"flex flex-col gap-4 border-t border-border px-3 py-3\">\n            {input !== undefined && (\n              <section>\n                <Label>Input</Label>\n                <Fields value={input} />\n              </section>\n            )}\n\n            {status === \"done\" && output !== undefined && (\n              <section className=\"animate-[ui-fade-up_300ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none\">\n                <Label>Output</Label>\n                {renderOutput ? renderOutput(output) : <Fields value={output} />}\n              </section>\n            )}\n\n            {status === \"error\" && (\n              <section className=\"flex items-start justify-between gap-3 animate-[ui-fade-up_300ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none\">\n                <p className=\"text-[12.5px] leading-5 text-red-500\">{errorText ?? \"The tool failed.\"}</p>\n                {onRetry && (\n                  <button\n                    type=\"button\"\n                    onClick={onRetry}\n                    className={`flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-accent ${FOCUS}`}\n                  >\n                    <RotateCcw aria-hidden=\"true\" className=\"size-3\" />\n                    Retry\n                  </button>\n                )}\n              </section>\n            )}\n\n            {status === \"denied\" && <p className=\"text-[12.5px] text-muted-foreground\">You declined this action, so it didn’t run.</p>}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/tool-call"}