{
  "name": "ai-tool-approval",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @kokonutui\n * @description: AI Tool Approval\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  ChevronRight,\n  FilePen,\n  Globe,\n  Send,\n  SquareTerminal,\n  Wrench,\n} from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * The same states the AI SDK gives a tool part, so a part can be passed\n * straight through: `approval-requested` asks, `approval-responded` runs,\n * and the `output-*` states are the result.\n */\nexport type ToolCallState =\n  | \"approval-requested\"\n  | \"approval-responded\"\n  | \"output-available\"\n  | \"output-denied\"\n  | \"output-error\";\n\nexport interface ToolCall {\n  id: string;\n  /** The tool's name, such as `bash` or `write_file`. Picks the icon. */\n  tool: string;\n  /** What the agent wants to do, in plain words: \"Run a command\". */\n  title: string;\n  /** A command or text shows as code; an object shows as fields. */\n  input: string | Record<string, string | number | boolean>;\n  /** Why the agent wants this, shown under the input. */\n  reason?: string;\n  /** Destructive calls need a press and hold, and are never auto-approved. */\n  destructive?: boolean;\n  state: ToolCallState;\n  /** Output lines, streamed while running and kept once done. */\n  output?: string[];\n  icon?: ReactNode;\n}\n\ninterface AIToolApprovalProps {\n  /**\n   * The conversation's tool calls, oldest first. When omitted, a demo run\n   * plays with three calls to approve.\n   */\n  calls?: ToolCall[];\n  /**\n   * Called when the user allows or denies a call. `always` is true when they\n   * chose to stop asking for this tool.\n   */\n  onRespond?: (id: string, approved: boolean, always: boolean) => void;\n  className?: string;\n}\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst HOLD_MS = 900;\n\nconst DEMO_CALLS: Omit<ToolCall, \"state\">[] = [\n  {\n    id: \"test\",\n    tool: \"bash\",\n    title: \"Run a command\",\n    input: \"bun test --filter checkout\",\n    reason: \"Check the checkout tests before changing the form.\",\n    output: [\n      \"bun test v1.3.0\",\n      \"checkout/cart.test.ts  ✓ 6 passed\",\n      \"checkout/form.test.ts  ✗ 1 failed\",\n      \"7 tests, 1 failure (412ms)\",\n    ],\n  },\n  {\n    id: \"edit\",\n    tool: \"write_file\",\n    title: \"Edit a file\",\n    input: {\n      path: \"app/checkout/form.tsx\",\n      changes: \"+4 −2\",\n    },\n    reason: \"Validate the postcode before submitting.\",\n    output: [\"Wrote app/checkout/form.tsx (+4 −2)\"],\n  },\n  {\n    id: \"clean\",\n    tool: \"bash\",\n    title: \"Run a command\",\n    input: \"rm -rf .next && bun run build\",\n    reason: \"Clear the stale build cache, then rebuild.\",\n    destructive: true,\n    output: [\n      \"removed .next\",\n      \"▲ Next.js 16.2.10\",\n      \"Creating an optimized production build\",\n      \"✓ Compiled in 8.4s\",\n    ],\n  },\n];\n\nconst OUTPUT_TICK_MS = 380;\n\nconst TERMINAL_TOOL = /bash|shell|terminal|exec|command/;\nconst FILE_TOOL = /write|edit|file|patch/;\nconst WEB_TOOL = /fetch|web|browse|http|search/;\nconst SEND_TOOL = /mail|send|message|post/;\n\n/**\n * Plays DEMO_CALLS: each waits for a decision, then streams its output. Tools\n * the user always allows skip the question, unless the call is destructive.\n */\nfunction useDemoCalls(enabled: boolean) {\n  const [calls, setCalls] = useState<ToolCall[]>(() =>\n    DEMO_CALLS.slice(0, 1).map((call) => ({\n      ...call,\n      state: \"approval-requested\",\n      output: [],\n    }))\n  );\n  const alwaysRef = useRef(new Set<string>());\n\n  const update = useCallback((id: string, patch: Partial<ToolCall>) => {\n    setCalls((list) =>\n      list.map((call) => (call.id === id ? { ...call, ...patch } : call))\n    );\n  }, []);\n\n  const next = useCallback(() => {\n    setCalls((list) => {\n      const source = DEMO_CALLS[list.length];\n      if (!source) {\n        return list;\n      }\n      const auto = alwaysRef.current.has(source.tool) && !source.destructive;\n      return [\n        ...list,\n        {\n          ...source,\n          state: auto ? \"approval-responded\" : \"approval-requested\",\n          output: [],\n        },\n      ];\n    });\n  }, []);\n\n  // Streams the output of whichever call is running, then moves on.\n  const running = calls.find((call) => call.state === \"approval-responded\");\n  useEffect(() => {\n    if (!(enabled && running)) {\n      return;\n    }\n    const lines = DEMO_CALLS.find((call) => call.id === running.id)?.output;\n    const shown = running.output?.length ?? 0;\n    const id = setTimeout(() => {\n      if (lines && shown < lines.length) {\n        update(running.id, { output: lines.slice(0, shown + 1) });\n        return;\n      }\n      update(running.id, { state: \"output-available\" });\n      next();\n    }, OUTPUT_TICK_MS);\n    return () => clearTimeout(id);\n  }, [enabled, running, update, next]);\n\n  const respond = useCallback(\n    (id: string, approved: boolean, always: boolean) => {\n      const call = DEMO_CALLS.find((item) => item.id === id);\n      if (always && call) {\n        alwaysRef.current.add(call.tool);\n      }\n      update(id, { state: approved ? \"approval-responded\" : \"output-denied\" });\n      if (!approved) {\n        setTimeout(next, 240);\n      }\n    },\n    [update, next]\n  );\n\n  const reset = useCallback(() => {\n    alwaysRef.current.clear();\n    setCalls(\n      DEMO_CALLS.slice(0, 1).map((call) => ({\n        ...call,\n        state: \"approval-requested\",\n        output: [],\n      }))\n    );\n  }, []);\n\n  return { calls, respond, reset };\n}\n\nfunction ToolIcon({ call }: { call: ToolCall }) {\n  if (call.icon) {\n    return <>{call.icon}</>;\n  }\n  const name = call.tool.toLowerCase();\n  const className = \"size-4\";\n  if (TERMINAL_TOOL.test(name)) {\n    return <SquareTerminal aria-hidden=\"true\" className={className} />;\n  }\n  if (FILE_TOOL.test(name)) {\n    return <FilePen aria-hidden=\"true\" className={className} />;\n  }\n  if (WEB_TOOL.test(name)) {\n    return <Globe aria-hidden=\"true\" className={className} />;\n  }\n  if (SEND_TOOL.test(name)) {\n    return <Send aria-hidden=\"true\" className={className} />;\n  }\n  return <Wrench aria-hidden=\"true\" className={className} />;\n}\n\nconst inputSummary = (input: ToolCall[\"input\"]) =>\n  typeof input === \"string\" ? input : Object.values(input)[0]?.toString();\n\nfunction ToolInput({ input }: { input: ToolCall[\"input\"] }) {\n  if (typeof input === \"string\") {\n    return (\n      <pre className=\"overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-zinc-50 px-3 py-2.5 font-mono text-[13px] text-zinc-900 leading-relaxed ring-1 ring-black/5 dark:bg-white/5 dark:text-zinc-100 dark:ring-white/10\">\n        <span aria-hidden=\"true\" className=\"select-none text-zinc-400\">\n          ${\" \"}\n        </span>\n        {input}\n      </pre>\n    );\n  }\n  return (\n    <dl className=\"grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 rounded-lg bg-zinc-50 px-3 py-2.5 text-[13px] ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10\">\n      {Object.entries(input).map(([key, value]) => (\n        <div className=\"contents\" key={key}>\n          <dt className=\"text-zinc-500 dark:text-zinc-400\">{key}</dt>\n          <dd className=\"min-w-0 truncate font-mono text-zinc-900 dark:text-zinc-100\">\n            {String(value)}\n          </dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n\n/**\n * Allow for destructive calls. The red fill wipes across while held and\n * runs back quickly when let go early, so a slip never confirms.\n */\nfunction HoldToAllow({ onConfirm }: { onConfirm: () => void }) {\n  const [holding, setHolding] = useState(false);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const start = () => {\n    setHolding(true);\n    timer.current = setTimeout(() => {\n      setHolding(false);\n      onConfirm();\n    }, HOLD_MS);\n  };\n  const stop = () => {\n    setHolding(false);\n    if (timer.current) {\n      clearTimeout(timer.current);\n    }\n  };\n  useEffect(\n    () => () => {\n      if (timer.current) {\n        clearTimeout(timer.current);\n      }\n    },\n    []\n  );\n\n  const label = \"Hold to allow\";\n  return (\n    <button\n      aria-label=\"Allow. Press and hold to confirm\"\n      className=\"relative h-8 select-none overflow-hidden rounded-lg bg-red-50 px-3 font-medium text-red-700 text-sm outline-hidden ring-1 ring-red-600/15 focus-visible:ring-2 focus-visible:ring-red-500 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-400/20\"\n      onContextMenu={(event) => event.preventDefault()}\n      onKeyDown={(event) => {\n        if ((event.key === \" \" || event.key === \"Enter\") && !event.repeat) {\n          event.preventDefault();\n          start();\n        }\n      }}\n      onKeyUp={(event) => {\n        if (event.key === \" \" || event.key === \"Enter\") {\n          stop();\n        }\n      }}\n      onPointerCancel={stop}\n      onPointerDown={start}\n      onPointerLeave={stop}\n      onPointerUp={stop}\n      type=\"button\"\n    >\n      {label}\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 flex items-center justify-center bg-red-600 text-white\"\n        style={{\n          clipPath: holding ? \"inset(0 0 0 0)\" : \"inset(0 100% 0 0)\",\n          transition: holding\n            ? `clip-path ${HOLD_MS}ms linear`\n            : \"clip-path 200ms cubic-bezier(0.23, 1, 0.32, 1)\",\n        }}\n      >\n        {label}\n      </span>\n    </button>\n  );\n}\n\nfunction ApprovalCard({\n  call,\n  onRespond,\n}: {\n  call: ToolCall;\n  onRespond: (approved: boolean, always: boolean) => void;\n}) {\n  const [always, setAlways] = useState(false);\n  const alwaysId = useId();\n  const titleId = useId();\n\n  return (\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Escape bubbles up from the card's own buttons and checkbox\n    <section\n      aria-labelledby={titleId}\n      className=\"rounded-xl bg-white p-3 shadow-[0_1px_2px_rgb(0_0_0/0.04)] ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10\"\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") {\n          onRespond(false, false);\n        }\n      }}\n    >\n      <header className=\"mb-2.5 flex items-center gap-2\">\n        <span className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-300\">\n          <ToolIcon call={call} />\n        </span>\n        <h3\n          className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\"\n          id={titleId}\n        >\n          {call.title}\n        </h3>\n        <span className=\"font-mono text-xs text-zinc-400 dark:text-zinc-500\">\n          {call.tool}\n        </span>\n        {call.destructive && (\n          <span className=\"ml-auto rounded-md bg-red-50 px-1.5 py-0.5 font-medium text-[11px] text-red-600 dark:bg-red-500/10 dark:text-red-400\">\n            Destructive\n          </span>\n        )}\n      </header>\n      <ToolInput input={call.input} />\n      {call.reason && (\n        <p className=\"mt-2 text-xs text-zinc-500 leading-relaxed dark:text-zinc-400\">\n          {call.reason}\n        </p>\n      )}\n      <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n        {call.destructive ? (\n          <span className=\"text-xs text-zinc-400 dark:text-zinc-500\">\n            Asks every time\n          </span>\n        ) : (\n          <label\n            className=\"flex cursor-pointer select-none items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400\"\n            htmlFor={alwaysId}\n          >\n            <input\n              checked={always}\n              className=\"size-3.5 cursor-pointer accent-zinc-900 dark:accent-zinc-100\"\n              id={alwaysId}\n              onChange={(event) => setAlways(event.target.checked)}\n              type=\"checkbox\"\n            />\n            Don't ask again for <code className=\"font-mono\">{call.tool}</code>\n          </label>\n        )}\n        <div className=\"ml-auto flex items-center gap-1.5\">\n          <button\n            className=\"h-8 rounded-lg px-3 text-sm text-zinc-600 outline-hidden transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400/50 dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white\"\n            onClick={() => onRespond(false, false)}\n            type=\"button\"\n          >\n            Deny\n          </button>\n          {call.destructive ? (\n            <HoldToAllow onConfirm={() => onRespond(true, false)} />\n          ) : (\n            <button\n              className=\"h-8 rounded-lg bg-zinc-900 px-3 font-medium text-sm text-white outline-hidden transition-colors duration-150 hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-zinc-400/60 focus-visible:ring-offset-1 dark:bg-white dark:text-zinc-900 dark:focus-visible:ring-offset-zinc-900 dark:hover:bg-zinc-200\"\n              onClick={() => onRespond(true, always)}\n              type=\"button\"\n            >\n              Allow\n            </button>\n          )}\n        </div>\n      </div>\n    </section>\n  );\n}\n\nfunction StatusIcon({ state }: { state: ToolCallState }) {\n  if (state === \"approval-responded\") {\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-3.5 motion-safe:animate-spin\"\n        fill=\"none\"\n        style={{ animationDuration: \"0.8s\" }}\n        viewBox=\"0 0 16 16\"\n      >\n        <circle\n          className=\"stroke-zinc-200 dark:stroke-zinc-700\"\n          cx=\"8\"\n          cy=\"8\"\n          r=\"6.25\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          className=\"stroke-zinc-700 dark:stroke-zinc-200\"\n          d=\"M8 1.75a6.25 6.25 0 0 1 6.25 6.25\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </svg>\n    );\n  }\n  if (state === \"output-available\") {\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-3.5 text-zinc-700 dark:text-zinc-200\"\n        fill=\"none\"\n        viewBox=\"0 0 16 16\"\n      >\n        <motion.path\n          animate={{ pathLength: 1 }}\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          initial={{ pathLength: 0 }}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.75\"\n          transition={{ duration: 0.3, ease: EASE_OUT }}\n        />\n      </svg>\n    );\n  }\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-3.5\",\n        state === \"output-error\" ? \"text-red-500\" : \"text-zinc-400\"\n      )}\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <path\n        d=\"M4.5 4.5l7 7m0-7-7 7\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"1.75\"\n      />\n    </svg>\n  );\n}\n\n/** What the trace line says, as [while running, once done]. */\nfunction verbsFor(tool: string): [string, string] {\n  const name = tool.toLowerCase();\n  if (TERMINAL_TOOL.test(name)) {\n    return [\"Running\", \"Ran\"];\n  }\n  if (FILE_TOOL.test(name)) {\n    return [\"Editing\", \"Edited\"];\n  }\n  if (WEB_TOOL.test(name)) {\n    return [\"Fetching\", \"Fetched\"];\n  }\n  if (SEND_TOOL.test(name)) {\n    return [\"Sending\", \"Sent\"];\n  }\n  return [\"Using\", \"Used\"];\n}\n\nfunction verbOf(call: ToolCall) {\n  const [running, done] = verbsFor(call.tool);\n  switch (call.state) {\n    case \"approval-responded\":\n      return running;\n    case \"output-denied\":\n      return \"Denied\";\n    case \"output-error\":\n      return \"Failed\";\n    default:\n      return done;\n  }\n}\n\n/**\n * A decided call, folded to one line. Running calls show their output as it\n * streams; finished ones open it on click.\n */\nfunction TraceLine({ call }: { call: ToolCall }) {\n  const state = call.state as Exclude<ToolCallState, \"approval-requested\">;\n  const isRunning = state === \"approval-responded\";\n  const hasOutput = Boolean(call.output?.length) && state !== \"output-denied\";\n  const [open, setOpen] = useState(false);\n  const outputId = useId();\n  const showOutput = hasOutput && (isRunning || open);\n\n  return (\n    <div>\n      <button\n        aria-controls={hasOutput ? outputId : undefined}\n        aria-expanded={hasOutput ? showOutput : undefined}\n        className=\"group/line flex w-full items-center gap-2.5 rounded-md py-1.5 text-left text-[13px] leading-5 outline-hidden focus-visible:ring-2 focus-visible:ring-zinc-400/40 disabled:cursor-default\"\n        disabled={!hasOutput || isRunning}\n        onClick={() => setOpen((value) => !value)}\n        type=\"button\"\n      >\n        <span className=\"flex size-4 shrink-0 items-center justify-center\">\n          <StatusIcon state={state} />\n        </span>\n        <span\n          className={cn(\n            \"shrink-0\",\n            state === \"output-denied\"\n              ? \"text-zinc-400 dark:text-zinc-500\"\n              : \"text-zinc-600 dark:text-zinc-300\"\n          )}\n        >\n          {verbOf(call)}\n        </span>\n        <code\n          className={cn(\n            \"min-w-0 truncate font-mono text-[12.5px]\",\n            state === \"output-denied\"\n              ? \"text-zinc-400 line-through decoration-zinc-300 dark:text-zinc-500 dark:decoration-zinc-600\"\n              : \"text-zinc-900 dark:text-zinc-100\"\n          )}\n        >\n          {inputSummary(call.input)}\n        </code>\n        {hasOutput && !isRunning && (\n          <ChevronRight\n            aria-hidden=\"true\"\n            className={cn(\n              \"ml-auto size-3.5 shrink-0 text-zinc-400 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/line:text-zinc-600 dark:group-hover/line:text-zinc-300\",\n              open && \"rotate-90\"\n            )}\n          />\n        )}\n      </button>\n      <AnimatePresence initial={false}>\n        {showOutput && (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            id={outputId}\n            initial={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.22, ease: EASE_OUT }}\n          >\n            <pre className=\"mb-1.5 ml-6.5 overflow-x-auto rounded-lg bg-zinc-50 px-3 py-2 font-mono text-xs text-zinc-600 leading-5 ring-1 ring-black/5 dark:bg-white/5 dark:text-zinc-400 dark:ring-white/10\">\n              {call.output?.join(\"\\n\")}\n            </pre>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport default function AIToolApproval({\n  calls: callsProp,\n  onRespond,\n  className,\n}: AIToolApprovalProps) {\n  const demo = useDemoCalls(callsProp === undefined);\n  const calls = callsProp ?? demo.calls;\n  const respond = (id: string, approved: boolean, always: boolean) => {\n    if (callsProp === undefined) {\n      demo.respond(id, approved, always);\n    }\n    onRespond?.(id, approved, always);\n  };\n\n  const pending = calls.filter((call) => call.state === \"approval-requested\");\n  const decided = calls.filter((call) => call.state !== \"approval-requested\");\n  const current = pending[0];\n  const demoFinished =\n    callsProp === undefined &&\n    calls.length === DEMO_CALLS.length &&\n    pending.length === 0 &&\n    calls.every((call) => call.state !== \"approval-responded\");\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className={cn(\"w-full max-w-md\", className)}>\n        <div className=\"rounded-2xl bg-black/5 p-1.5 dark:bg-white/5\">\n          {decided.length > 0 && (\n            <div className=\"px-2.5 pt-1 pb-1.5\">\n              {decided.map((call) => (\n                <motion.div\n                  animate={{ opacity: 1, height: \"auto\" }}\n                  initial={{ opacity: 0, height: 0 }}\n                  key={call.id}\n                  transition={{ duration: 0.24, ease: EASE_OUT }}\n                >\n                  <TraceLine call={call} />\n                </motion.div>\n              ))}\n            </div>\n          )}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {current ? (\n              <motion.div\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, y: -6, filter: \"blur(2px)\" }}\n                initial={{ opacity: 0, y: 8, filter: \"blur(2px)\" }}\n                key={current.id}\n                transition={{ duration: 0.22, ease: EASE_OUT }}\n              >\n                {pending.length > 1 && (\n                  <p className=\"px-2.5 pb-1.5 text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n                    1 of {pending.length} waiting\n                  </p>\n                )}\n                <ApprovalCard\n                  call={current}\n                  onRespond={(approved, always) =>\n                    respond(current.id, approved, always)\n                  }\n                />\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n          {demoFinished && (\n            <div className=\"flex justify-end px-2.5 pb-1\">\n              <button\n                className=\"rounded-md px-1.5 py-1 text-xs text-zinc-500 outline-hidden transition-colors duration-150 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400/40 dark:text-zinc-400 dark:hover:text-white\"\n                onClick={demo.reset}\n                type=\"button\"\n              >\n                Replay\n              </button>\n            </div>\n          )}\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "path": "/components/kokonutui/ai-tool-approval.tsx",
      "target": "components/kokonutui/ai-tool-approval.tsx"
    }
  ]
}