{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tool-approval",
  "title": "Tool Approval",
  "description": "Asks before an agent runs a tool. Shows what the call will do, offers Allow once, Always allow and Deny, and leaves a one-line record of the answer.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/tool-approval/tool-approval.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Ban, Check, ChevronRight, Loader, ShieldCheck } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type ToolApprovalDecision = \"allow-once\" | \"allow-always\" | \"deny\"\n\nexport type ToolApprovalProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"input\"\n> & {\n  /** The tool the agent wants to run, such as `delete_branch`. */\n  name: string\n  /** The raw arguments. A plain object is also listed key by key. */\n  input?: unknown\n  /** A sentence that says what the call will do, in place of the key list. */\n  summary?: React.ReactNode\n  /** Allowing then takes a press and hold, or a second confirmation. */\n  destructive?: boolean\n  /** May return a promise. A rejection puts the choices back. */\n  onDecision: (decision: ToolApprovalDecision) => void | Promise<void>\n  /** A decision made earlier, for a card restored from history. */\n  decision?: ToolApprovalDecision | null\n  /** Offer Always allow. Defaults to true. */\n  allowAlways?: boolean\n  /** How long a destructive allow is held, in milliseconds. */\n  holdDuration?: number\n}\n\nconst resolvedText: Record<ToolApprovalDecision, string> = {\n  \"allow-once\": \"Allowed once\",\n  \"allow-always\": \"Always allowed\",\n  deny: \"Denied\",\n}\n\nconst actionText: Record<ToolApprovalDecision, string> = {\n  \"allow-once\": \"Allow once\",\n  \"allow-always\": \"Always allow\",\n  deny: \"Deny\",\n}\n\nfunction formatValue(value: unknown) {\n  if (typeof value === \"string\") return value\n\n  try {\n    return JSON.stringify(value)\n  } catch {\n    return String(value)\n  }\n}\n\nfunction formatRaw(value: unknown) {\n  if (typeof value === \"string\") return value\n\n  try {\n    return JSON.stringify(value, null, 2)\n  } catch {\n    return String(value)\n  }\n}\n\nfunction entriesOf(value: unknown) {\n  if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n    return null\n  }\n\n  return Object.entries(value as Record<string, unknown>)\n}\n\nconst buttonBase =\n  \"focus-visible:ring-ring relative isolate inline-flex min-h-10 items-center justify-center gap-1.5 overflow-hidden rounded-full px-4 text-sm font-medium select-none focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-60 motion-safe:transition-[background-color,color,transform] motion-safe:duration-150\"\n\ntype HoldPhase = \"idle\" | \"holding\" | \"armed\"\n\ntype DecisionButtonProps = {\n  decision: ToolApprovalDecision\n  hold: boolean\n  holdDuration: number\n  pending: boolean\n  disabled: boolean\n  className: string\n  describedBy?: string\n  onChoose: (decision: ToolApprovalDecision) => void\n  onPhaseChange: (phase: HoldPhase) => void\n}\n\nfunction DecisionButton({\n  decision,\n  hold,\n  holdDuration,\n  pending,\n  disabled,\n  className,\n  describedBy,\n  onChoose,\n  onPhaseChange,\n}: DecisionButtonProps) {\n  const [phase, setPhase] = React.useState<HoldPhase>(\"idle\")\n  const [progress, setProgress] = React.useState(0)\n  const phaseRef = React.useRef<HoldPhase>(\"idle\")\n  const frame = React.useRef<number | null>(null)\n  const armTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const heldKey = React.useRef<string | null>(null)\n  const keyReleasedAt = React.useRef(-Infinity)\n\n  const move = React.useCallback(\n    (next: HoldPhase) => {\n      phaseRef.current = next\n      setPhase(next)\n      onPhaseChange(next)\n    },\n    [onPhaseChange]\n  )\n\n  const stopFrame = () => {\n    if (frame.current !== null) cancelAnimationFrame(frame.current)\n    frame.current = null\n  }\n\n  React.useEffect(\n    () => () => {\n      if (frame.current !== null) cancelAnimationFrame(frame.current)\n      if (armTimer.current) clearTimeout(armTimer.current)\n    },\n    []\n  )\n\n  const complete = () => {\n    stopFrame()\n    if (armTimer.current) clearTimeout(armTimer.current)\n    setProgress(0)\n    move(\"idle\")\n    onChoose(decision)\n  }\n\n  const begin = () => {\n    if (disabled || phaseRef.current === \"holding\") return\n    if (armTimer.current) clearTimeout(armTimer.current)\n\n    const length = Math.max(holdDuration, 500)\n    const startedAt = performance.now()\n    move(\"holding\")\n\n    const tick = (now: number) => {\n      const next = Math.min((now - startedAt) / length, 1)\n      setProgress(next)\n      if (next >= 1) {\n        complete()\n        return\n      }\n      frame.current = requestAnimationFrame(tick)\n    }\n\n    frame.current = requestAnimationFrame(tick)\n  }\n\n  const cancel = () => {\n    if (phaseRef.current !== \"holding\") return\n    stopFrame()\n    setProgress(0)\n    move(\"idle\")\n  }\n\n  const arm = () => {\n    move(\"armed\")\n    armTimer.current = setTimeout(() => move(\"idle\"), 5000)\n  }\n\n  const label =\n    phase === \"holding\"\n      ? \"Keep holding\"\n      : phase === \"armed\"\n        ? \"Press again to allow\"\n        : actionText[decision]\n\n  return (\n    <button\n      aria-busy={pending || undefined}\n      aria-describedby={hold ? describedBy : undefined}\n      className={cn(\n        buttonBase,\n        hold && \"cursor-default touch-none [-webkit-touch-callout:none]\",\n        phase === \"holding\" && \"motion-safe:scale-[0.98]\",\n        className\n      )}\n      data-decision={decision}\n      data-hold={hold ? \"\" : undefined}\n      data-state={phase}\n      disabled={disabled}\n      type=\"button\"\n      onBlur={cancel}\n      onClick={(event) => {\n        if (!hold) {\n          onChoose(decision)\n          return\n        }\n        // A click with no pointer and no held key behind it comes from\n        // assistive technology that activates directly, so it confirms in two\n        // steps. Space releases its own click right after keyup.\n        if (event.detail !== 0) return\n        if (heldKey.current) return\n        if (performance.now() - keyReleasedAt.current < 400) return\n        if (phaseRef.current === \"armed\") complete()\n        else arm()\n      }}\n      onKeyDown={(event) => {\n        if (!hold) return\n        if (event.key !== \"Enter\" && event.key !== \" \") return\n        event.preventDefault()\n        if (event.repeat || heldKey.current) return\n        heldKey.current = event.key\n        begin()\n      }}\n      onKeyUp={(event) => {\n        if (!hold || event.key !== heldKey.current) return\n        event.preventDefault()\n        heldKey.current = null\n        keyReleasedAt.current = performance.now()\n        cancel()\n      }}\n      onContextMenu={(event) => {\n        if (hold) event.preventDefault()\n      }}\n      onLostPointerCapture={cancel}\n      onPointerCancel={cancel}\n      onPointerDown={(event) => {\n        if (!hold || event.button !== 0) return\n        event.currentTarget.setPointerCapture(event.pointerId)\n        begin()\n      }}\n      onPointerUp={cancel}\n    >\n      {hold ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"bg-foreground/20 absolute inset-0 -z-10 origin-left\"\n          style={{ transform: `scaleX(${progress})` }}\n        />\n      ) : null}\n      {pending ? (\n        <Loader\n          aria-hidden=\"true\"\n          className=\"animate-spin motion-reduce:animate-none\"\n          size={14}\n        />\n      ) : null}\n      <span>{label}</span>\n    </button>\n  )\n}\n\nexport function ToolApproval({\n  name,\n  input,\n  summary,\n  destructive = false,\n  onDecision,\n  decision,\n  allowAlways = true,\n  holdDuration = 1200,\n  className,\n  ...rootProps\n}: ToolApprovalProps) {\n  const id = React.useId()\n  const titleId = `${id}-title`\n  const summaryId = `${id}-summary`\n  const rawId = `${id}-raw`\n  const holdHintId = `${id}-hold`\n\n  const [chosen, setChosen] = React.useState<ToolApprovalDecision | null>(null)\n  const [status, setStatus] = React.useState<\"idle\" | \"pending\" | \"failed\">(\n    \"idle\"\n  )\n  const [rawOpen, setRawOpen] = React.useState(false)\n  const [notice, setNotice] = React.useState(\"\")\n  const resolvedRef = React.useRef<HTMLParagraphElement>(null)\n  const focusResolved = React.useRef(false)\n\n  const resolved = decision ?? (status === \"idle\" ? chosen : null)\n  const pending = status === \"pending\"\n\n  React.useLayoutEffect(() => {\n    if (resolved && focusResolved.current) {\n      focusResolved.current = false\n      resolvedRef.current?.focus()\n    }\n  }, [resolved])\n\n  const choose = async (next: ToolApprovalDecision) => {\n    if (pending || resolved) return\n    setChosen(next)\n    setStatus(\"pending\")\n    setNotice(\"\")\n\n    try {\n      await onDecision(next)\n      focusResolved.current = true\n      setStatus(\"idle\")\n    } catch {\n      setChosen(null)\n      setStatus(\"failed\")\n    }\n  }\n\n  const onPhaseChange = React.useCallback(\n    (phase: HoldPhase) => {\n      setNotice(\n        phase === \"holding\"\n          ? \"Keep holding to allow\"\n          : phase === \"armed\"\n            ? `${name} can make changes that are hard to undo. Activate again to allow it.`\n            : \"\"\n      )\n    },\n    [name]\n  )\n\n  if (resolved) {\n    const denied = resolved === \"deny\"\n\n    return (\n      <div\n        className={cn(\n          \"text-muted-foreground flex items-center gap-2 px-1 py-1.5 text-sm\",\n          className\n        )}\n        data-decision={resolved}\n        data-slot=\"tool-approval\"\n        data-state=\"resolved\"\n        {...rootProps}\n      >\n        {denied ? (\n          <Ban aria-hidden=\"true\" className=\"shrink-0\" size={14} />\n        ) : (\n          <Check\n            aria-hidden=\"true\"\n            className=\"text-accent shrink-0\"\n            size={14}\n          />\n        )}\n        <p\n          className=\"focus-visible:ring-ring min-w-0 truncate rounded-sm outline-none focus-visible:ring-2\"\n          ref={resolvedRef}\n          role=\"status\"\n          tabIndex={-1}\n        >\n          <span className=\"text-foreground\">{resolvedText[resolved]}</span>{\" \"}\n          <span className=\"font-[family-name:var(--font-mono),monospace]\">\n            {name}\n          </span>\n        </p>\n      </div>\n    )\n  }\n\n  const entries = summary === undefined ? entriesOf(input) : null\n  const hasRaw = input !== undefined\n  const disabled = pending\n\n  return (\n    <div\n      aria-busy={pending || undefined}\n      aria-describedby={\n        summary !== undefined || entries ? summaryId : undefined\n      }\n      aria-labelledby={titleId}\n      className={cn(\n        \"border-border bg-card text-card-foreground rounded-[var(--radius)] border\",\n        destructive && \"border-destructive/40\",\n        className\n      )}\n      data-destructive={destructive ? \"\" : undefined}\n      data-slot=\"tool-approval\"\n      data-state={pending ? \"pending\" : \"open\"}\n      role=\"group\"\n      {...rootProps}\n    >\n      <div className=\"flex items-start gap-2.5 px-3.5 pt-3\">\n        <ShieldCheck\n          aria-hidden=\"true\"\n          className={cn(\n            \"mt-0.5 shrink-0\",\n            destructive ? \"text-destructive\" : \"text-muted-foreground\"\n          )}\n          size={16}\n        />\n        <div className=\"min-w-0\">\n          <p className=\"text-sm\" id={titleId}>\n            Allow{\" \"}\n            <span className=\"font-[family-name:var(--font-mono),monospace] font-semibold break-all\">\n              {name}\n            </span>{\" \"}\n            to run?\n          </p>\n          {destructive ? (\n            <p className=\"text-destructive mt-0.5 text-xs\">\n              This can make changes that are hard to undo.\n            </p>\n          ) : null}\n        </div>\n      </div>\n\n      {summary !== undefined ? (\n        <div className=\"px-3.5 pt-2 pl-[2.375rem] text-sm\" id={summaryId}>\n          {summary}\n        </div>\n      ) : entries && entries.length > 0 ? (\n        <dl\n          className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 px-3.5 pt-2 pl-[2.375rem] text-xs\"\n          id={summaryId}\n        >\n          {entries.map(([key, value]) => (\n            <React.Fragment key={key}>\n              <dt className=\"text-muted-foreground\">{key}</dt>\n              <dd className=\"truncate font-[family-name:var(--font-mono),monospace]\">\n                {formatValue(value)}\n              </dd>\n            </React.Fragment>\n          ))}\n        </dl>\n      ) : null}\n\n      {hasRaw ? (\n        <div className=\"px-3.5 pt-1 pl-[1.875rem]\">\n          <button\n            aria-controls={rawId}\n            aria-expanded={rawOpen}\n            className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex min-h-8 items-center gap-1 rounded-full px-2 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none\"\n            type=\"button\"\n            onClick={() => setRawOpen((open) => !open)}\n          >\n            <ChevronRight\n              aria-hidden=\"true\"\n              className={cn(\n                \"transition-transform duration-200 motion-reduce:transition-none\",\n                rawOpen && \"rotate-90\"\n              )}\n              size={13}\n            />\n            Raw input\n          </button>\n          <pre\n            className=\"bg-muted/60 mt-1 mb-1 max-h-48 overflow-auto rounded-[calc(var(--radius)-0.25rem)] p-2.5 font-[family-name:var(--font-mono),monospace] text-xs leading-relaxed\"\n            hidden={!rawOpen}\n            id={rawId}\n          >\n            {formatRaw(input)}\n          </pre>\n        </div>\n      ) : null}\n\n      {status === \"failed\" ? (\n        <p className=\"text-destructive px-3.5 pt-2 text-xs\" role=\"alert\">\n          Your choice did not go through. Choose again.\n        </p>\n      ) : null}\n\n      <div className=\"flex flex-wrap items-center justify-end gap-2 px-3 pt-3 pb-3\">\n        <DecisionButton\n          className=\"text-muted-foreground hover:text-foreground hover:bg-muted mr-auto\"\n          decision=\"deny\"\n          disabled={disabled}\n          hold={false}\n          holdDuration={holdDuration}\n          pending={pending && chosen === \"deny\"}\n          onChoose={choose}\n          onPhaseChange={onPhaseChange}\n        />\n        {allowAlways ? (\n          <DecisionButton\n            className=\"border-border hover:bg-muted border\"\n            decision=\"allow-always\"\n            disabled={disabled}\n            describedBy={holdHintId}\n            hold={destructive}\n            holdDuration={holdDuration}\n            pending={pending && chosen === \"allow-always\"}\n            onChoose={choose}\n            onPhaseChange={onPhaseChange}\n          />\n        ) : null}\n        <DecisionButton\n          className={\n            destructive\n              ? \"bg-destructive text-destructive-foreground\"\n              : \"bg-primary text-primary-foreground\"\n          }\n          decision=\"allow-once\"\n          disabled={disabled}\n          describedBy={holdHintId}\n          hold={destructive}\n          holdDuration={holdDuration}\n          pending={pending && chosen === \"allow-once\"}\n          onChoose={choose}\n          onPhaseChange={onPhaseChange}\n        />\n      </div>\n\n      {destructive ? (\n        <p\n          className=\"text-muted-foreground px-3.5 pb-3 text-right text-xs\"\n          id={holdHintId}\n        >\n          Press and hold to allow. With a keyboard, hold Enter or Space.\n        </p>\n      ) : null}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {notice}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "agent",
    "permission",
    "dialog"
  ],
  "type": "registry:ui"
}