{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Collapsible tool invocation whose status badge is one ring that evolves — breathing, spinning, then drawing a check or a cross.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useState } from \"react\";\n\nconst SPRING_DEFAULT = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst BADGE_VIEWBOX = 24;\nconst BADGE_CENTER = BADGE_VIEWBOX / 2;\nconst RING_RADIUS = 8;\nconst CHECK_PATH = \"M 8.5 12.2 L 11 14.8 L 15.8 9.6\";\nconst CROSS_PATHS = [\"M 9 9 L 15 15\", \"M 15 9 L 9 15\"] as const;\nconst SPIN_SECONDS = 0.9;\nconst PENDING_PULSE_SECONDS = 1.6;\n\nexport type AIToolCallStatus = \"pending\" | \"running\" | \"success\" | \"error\";\n\nexport type AIToolCallProps = {\n  /** Arguments the tool was called with. Rendered as-is. */\n  args?: ReactNode;\n  className?: string;\n  defaultOpen?: boolean;\n  /** Tool name, e.g. `search_web`. */\n  name: string;\n  /** What the tool returned. */\n  result?: ReactNode;\n  status?: AIToolCallStatus;\n  /** Short right-aligned note, e.g. \"3 files\" or \"1.2s\". */\n  summary?: string;\n};\n\nconst STATUS_LABEL: Record<AIToolCallStatus, string> = {\n  error: \"Failed\",\n  pending: \"Queued\",\n  running: \"Running\",\n  success: \"Done\",\n};\n\n/**\n * The status badge is **one ring that changes behaviour**, not four icons that\n * swap places.\n *\n * `pending` breathes, `running` spins as a gap in the same ring, `success` keeps\n * the ring and draws a check inside it, `error` keeps the ring and draws a\n * cross. Because the ring never unmounts, the eye tracks a single object through\n * the whole lifecycle instead of watching icons pop in and out.\n */\nconst AIToolCallBadge = ({\n  status,\n  shouldReduceMotion,\n}: {\n  shouldReduceMotion: boolean;\n  status: AIToolCallStatus;\n}) => {\n  const isRunning = status === \"running\";\n  const isPending = status === \"pending\";\n\n  const ringColor = (() => {\n    if (status === \"success\") {\n      return \"oklch(72% 0.17 150)\";\n    }\n    if (status === \"error\") {\n      return \"oklch(63% 0.21 25)\";\n    }\n    return \"currentColor\";\n  })();\n\n  return (\n    <span className=\"relative flex size-5 shrink-0 items-center justify-center text-muted-foreground\">\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-5 overflow-visible\"\n        viewBox={`0 0 ${BADGE_VIEWBOX} ${BADGE_VIEWBOX}`}\n      >\n        <motion.g\n          animate={\n            shouldReduceMotion || !isRunning ? undefined : { rotate: 360 }\n          }\n          style={{ transformBox: \"view-box\", transformOrigin: \"center\" }}\n          transition={{\n            duration: SPIN_SECONDS,\n            ease: \"linear\",\n            repeat: Number.POSITIVE_INFINITY,\n          }}\n        >\n          <motion.circle\n            animate={{\n              stroke: ringColor,\n              // Running opens a gap in the ring; everything else closes it.\n              strokeDasharray: isRunning ? \"0.68 0.32\" : \"1 0\",\n              strokeOpacity:\n                isPending && !shouldReduceMotion ? [0.35, 1, 0.35] : 1,\n            }}\n            cx={BADGE_CENTER}\n            cy={BADGE_CENTER}\n            fill=\"none\"\n            pathLength={1}\n            r={RING_RADIUS}\n            strokeLinecap=\"round\"\n            strokeWidth={2}\n            transition={{\n              stroke: shouldReduceMotion\n                ? { duration: 0 }\n                : { duration: 0.25, ease: EASE_OUT },\n              strokeDasharray: shouldReduceMotion\n                ? { duration: 0 }\n                : { duration: 0.25, ease: EASE_OUT },\n              strokeOpacity: shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    duration: PENDING_PULSE_SECONDS,\n                    repeat: Number.POSITIVE_INFINITY,\n                  },\n            }}\n          />\n        </motion.g>\n\n        <AnimatePresence initial={false}>\n          {status === \"success\" && (\n            <motion.path\n              animate={{ opacity: 1, pathLength: 1 }}\n              d={CHECK_PATH}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              fill=\"none\"\n              initial={\n                shouldReduceMotion\n                  ? { opacity: 1, pathLength: 1 }\n                  : { opacity: 1, pathLength: 0 }\n              }\n              key=\"check\"\n              stroke={ringColor}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={2.2}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : { duration: 0.22, ease: EASE_OUT }\n              }\n            />\n          )}\n          {status === \"error\" &&\n            CROSS_PATHS.map((path, index) => (\n              <motion.path\n                animate={{ opacity: 1, pathLength: 1 }}\n                d={path}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                fill=\"none\"\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 1, pathLength: 1 }\n                    : { opacity: 1, pathLength: 0 }\n                }\n                key={path}\n                stroke={ringColor}\n                strokeLinecap=\"round\"\n                strokeWidth={2.2}\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0 }\n                    : { delay: index * 0.06, duration: 0.16, ease: EASE_OUT }\n                }\n              />\n            ))}\n        </AnimatePresence>\n      </svg>\n    </span>\n  );\n};\n\n/**\n * A single tool invocation, collapsed by default.\n *\n * Arguments and results are the kind of thing people want available but not\n * in their face, so the row stays one line until asked.\n */\nconst AIToolCall = ({\n  args,\n  className,\n  defaultOpen = false,\n  name,\n  result,\n  status = \"pending\",\n  summary,\n}: AIToolCallProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n  const hasDetail = Boolean(args || result);\n\n  return (\n    <div\n      className={cn(\n        \"w-full overflow-hidden rounded-xl border border-border bg-background\",\n        className\n      )}\n    >\n      <button\n        aria-expanded={hasDetail ? isOpen : undefined}\n        className=\"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left\"\n        disabled={!hasDetail}\n        onClick={() => setIsOpen((current) => !current)}\n        type=\"button\"\n      >\n        <AIToolCallBadge\n          shouldReduceMotion={Boolean(shouldReduceMotion)}\n          status={status}\n        />\n\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate font-medium font-mono text-foreground text-xs\">\n            {name}\n          </span>\n        </span>\n\n        {summary ? (\n          <span className=\"shrink-0 text-muted-foreground text-xs\">\n            {summary}\n          </span>\n        ) : null}\n        <span className=\"sr-only\">{STATUS_LABEL[status]}</span>\n\n        {hasDetail && (\n          <motion.span\n            animate={{ rotate: isOpen ? 90 : 0 }}\n            className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\"\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n          >\n            <ChevronRight aria-hidden=\"true\" size={14} />\n          </motion.span>\n        )}\n      </button>\n\n      <AnimatePresence initial={false}>\n        {isOpen && hasDetail ? (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { height: 0, opacity: 0 }\n            }\n            initial={\n              shouldReduceMotion\n                ? { height: \"auto\", opacity: 1 }\n                : { height: 0, opacity: 0 }\n            }\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    height: SPRING_DEFAULT,\n                    opacity: { duration: 0.18, ease: EASE_OUT },\n                  }\n            }\n          >\n            <div className=\"space-y-2 border-border border-t px-3 py-2.5 text-xs\">\n              {args ? (\n                <div>\n                  <p className=\"mb-1 text-[10px] text-muted-foreground uppercase tracking-wide\">\n                    Arguments\n                  </p>\n                  <div className=\"overflow-x-auto font-mono text-foreground\">\n                    {args}\n                  </div>\n                </div>\n              ) : null}\n              {result ? (\n                <div>\n                  <p className=\"mb-1 text-[10px] text-muted-foreground uppercase tracking-wide\">\n                    Result\n                  </p>\n                  <div className=\"overflow-x-auto text-foreground\">\n                    {result}\n                  </div>\n                </div>\n              ) : null}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n};\n\nexport default AIToolCall;\n","path":"index.tsx","target":"components/smoothui/ai-tool-call/index.tsx","type":"registry:ui"}],"name":"ai-tool-call","registryDependencies":[],"title":"Ai Tool Call","type":"registry:ui"}