{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tool-approval","type":"registry:component","title":"Tool Approval","description":"A human-in-the-loop permission card for reviewing tool details, allowing once, remembering access, or denying execution.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","shiki","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/tool-approval.tsx","type":"registry:component","target":"@components/agents/tool-approval.tsx","content":"\"use client\";\n// beui.dev/components/agents/tool-approval\n\nimport {\n  Check,\n  ChevronDown,\n  CircleAlert,\n  LoaderCircle,\n  ShieldCheck,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  AgentCode,\n  type AgentCodeLanguage,\n} from \"@/components/agents/agent-code\";\nimport { AgentDisclosure } from \"@/components/agents/agent-disclosure\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToolApprovalStatus =\n  | \"pending\"\n  | \"approving\"\n  | \"approved\"\n  | \"denied\"\n  | \"running\"\n  | \"complete\"\n  | \"error\";\n\nexport interface ToolApprovalParameter {\n  id: string;\n  label: ReactNode;\n  value: ReactNode;\n}\n\nexport interface ToolApprovalCodeProps {\n  code: string;\n  language?: AgentCodeLanguage;\n  className?: string;\n}\n\nexport interface ToolApprovalProps {\n  tool: ReactNode;\n  title?: ReactNode;\n  description?: ReactNode;\n  parameters?: ToolApprovalParameter[];\n  status?: ToolApprovalStatus;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  onApprove?: () => void;\n  onAlwaysAllow?: () => void;\n  onDeny?: () => void;\n  className?: string;\n}\n\nfunction getStatusCopy(status: ToolApprovalStatus) {\n  if (status === \"approving\") return \"Approving\";\n  if (status === \"approved\") return \"Approved\";\n  if (status === \"denied\") return \"Denied\";\n  if (status === \"running\") return \"Running\";\n  if (status === \"complete\") return \"Completed\";\n  if (status === \"error\") return \"Failed\";\n  return \"Approval required\";\n}\n\nfunction getStatusBadgeClass(status: ToolApprovalStatus) {\n  if (status === \"pending\") {\n    return \"border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400\";\n  }\n  if (status === \"approving\" || status === \"running\") {\n    return \"border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400\";\n  }\n  if (status === \"approved\" || status === \"complete\") {\n    return \"border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\";\n  }\n  return \"border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400\";\n}\n\nexport function ToolApprovalCode({\n  code,\n  language = \"bash\",\n  className,\n}: ToolApprovalCodeProps) {\n  return (\n    <AgentCode\n      code={code}\n      language={language}\n      className={cn(\n        \"rounded-lg border border-border/50 bg-muted/30 px-2.5 py-2\",\n        className,\n      )}\n    />\n  );\n}\n\nexport function ToolApproval({\n  tool,\n  title = \"Allow this tool to run?\",\n  description,\n  parameters = [],\n  status = \"pending\",\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  onApprove,\n  onAlwaysAllow,\n  onDeny,\n  className,\n}: ToolApprovalProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const detailsId = `${baseId}-details`;\n  const previousStatus = useRef(status);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const currentOpen = open ?? internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (open === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, open],\n  );\n  const busy = status === \"approving\" || status === \"running\";\n  const pending = status === \"pending\";\n  const error = status === \"error\";\n\n  useEffect(() => {\n    if (previousStatus.current === \"pending\" && status !== \"pending\") {\n      setOpen(false);\n    }\n    previousStatus.current = status;\n  }, [setOpen, status]);\n\n  return (\n    <div\n      data-state={status}\n      aria-busy={busy}\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl border border-border/60 bg-muted/20 text-sm\",\n        className,\n      )}\n    >\n      <div className=\"flex items-start gap-3 p-4\">\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"mt-0.5 grid size-8 shrink-0 place-items-center rounded-xl border border-border/60 bg-background text-muted-foreground\",\n            error && \"text-destructive\",\n          )}\n        >\n          {busy ? (\n            <LoaderCircle className={cn(\"size-4\", !reduce && \"animate-spin\")} />\n          ) : error ? (\n            <CircleAlert className=\"size-4\" />\n          ) : status === \"denied\" ? (\n            <X className=\"size-4\" />\n          ) : status === \"approved\" || status === \"complete\" ? (\n            <Check className=\"size-4\" />\n          ) : (\n            <ShieldCheck className=\"size-4\" />\n          )}\n        </span>\n\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex min-w-0 items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <div className=\"font-medium text-foreground\">{title}</div>\n              <div className=\"mt-0.5 truncate font-mono text-xs text-muted-foreground\">\n                {tool}\n              </div>\n            </div>\n            <span\n              className={cn(\n                \"shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors\",\n                getStatusBadgeClass(status),\n              )}\n            >\n              {getStatusCopy(status)}\n            </span>\n          </div>\n          {description ? (\n            <p className=\"mt-2 leading-5 text-muted-foreground\">{description}</p>\n          ) : null}\n\n          {parameters.length ? (\n            <button\n              type=\"button\"\n              aria-expanded={currentOpen}\n              aria-controls={detailsId}\n              onClick={() => setOpen(!currentOpen)}\n              className=\"mt-2 inline-flex items-center gap-1 rounded-md text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              View details\n              <motion.span\n                aria-hidden=\"true\"\n                animate={{ rotate: currentOpen ? 180 : 0 }}\n                transition={reduce ? { duration: 0 } : SPRING_SWAP}\n              >\n                <ChevronDown className=\"size-3.5\" />\n              </motion.span>\n            </button>\n          ) : null}\n        </div>\n      </div>\n\n      <AgentDisclosure\n        id={detailsId}\n        open={currentOpen}\n      >\n        <dl className=\"mx-4 mb-4 grid gap-2 rounded-xl border border-border/50 bg-background/70 p-3\">\n          {parameters.map((parameter) => (\n            <div\n              key={parameter.id}\n              className=\"grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-center gap-3 text-xs\"\n            >\n              <dt className=\"text-muted-foreground\">{parameter.label}</dt>\n              <dd className=\"min-w-0 break-words font-mono text-foreground/85\">\n                {parameter.value}\n              </dd>\n            </div>\n          ))}\n        </dl>\n      </AgentDisclosure>\n\n      <AnimatePresence initial={false}>\n        {pending ? (\n          <motion.div\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}\n            className=\"flex flex-wrap items-center gap-2 border-t border-border/60 px-4 py-3\"\n          >\n            <motion.button\n              type=\"button\"\n              onClick={onApprove}\n              whileTap={reduce ? undefined : { scale: 0.97 }}\n              transition={SPRING_PRESS}\n              className=\"rounded-xl bg-foreground px-3 py-1.5 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n            >\n              Allow once\n            </motion.button>\n            {onAlwaysAllow ? (\n              <motion.button\n                type=\"button\"\n                onClick={onAlwaysAllow}\n                whileTap={reduce ? undefined : { scale: 0.97 }}\n                transition={SPRING_PRESS}\n                className=\"rounded-xl border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                Always allow\n              </motion.button>\n            ) : null}\n            <button\n              type=\"button\"\n              onClick={onDeny}\n              className=\"rounded-xl px-3 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              Deny\n            </button>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/agents/agent-code.tsx","type":"registry:component","target":"@components/agents/agent-code.tsx","content":"\"use client\";\n\nimport {\n  type CSSProperties,\n  Fragment,\n  useEffect,\n  useState,\n} from \"react\";\nimport { createHighlighter, type Highlighter } from \"shiki\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AgentCodeLanguage =\n  | \"bash\"\n  | \"diff\"\n  | \"json\"\n  | \"text\"\n  | \"tsx\"\n  | \"typescript\";\n\nexport interface AgentCodeToken {\n  content: string;\n  offset: number;\n  light?: string;\n  dark?: string;\n}\n\nexport type AgentCodeTokenLines = AgentCodeToken[][];\n\nexport interface AgentCodeProps {\n  code: string;\n  language?: AgentCodeLanguage;\n  className?: string;\n}\n\nexport interface AgentCodeLineProps {\n  code: string;\n  tokens?: AgentCodeToken[];\n  className?: string;\n}\n\nconst LIGHT_THEME = \"github-light-high-contrast\";\nconst DARK_THEME = \"github-dark-high-contrast\";\nlet agentCodeHighlighter: Promise<Highlighter> | null = null;\nconst tokenCache = new Map<string, AgentCodeTokenLines>();\n\nfunction getAgentCodeHighlighter() {\n  if (!agentCodeHighlighter) {\n    agentCodeHighlighter = createHighlighter({\n      themes: [LIGHT_THEME, DARK_THEME],\n      langs: [\"bash\", \"diff\", \"json\", \"tsx\", \"typescript\"],\n    });\n  }\n  return agentCodeHighlighter;\n}\n\nfunction tokenCacheKey(code: string, language: AgentCodeLanguage) {\n  return `${language}\\u0000${code}`;\n}\n\nexport function useAgentCodeTokens(\n  code: string,\n  language: AgentCodeLanguage,\n) {\n  const key = tokenCacheKey(code, language);\n  const cached = tokenCache.get(key);\n  const [result, setResult] = useState<{\n    key: string;\n    code: string;\n    language: AgentCodeLanguage;\n    lines: AgentCodeTokenLines;\n  } | null>(cached ? { key, code, language, lines: cached } : null);\n\n  useEffect(() => {\n    const current = tokenCache.get(key);\n    if (current) {\n      setResult({ key, code, language, lines: current });\n      return;\n    }\n\n    let cancelled = false;\n    getAgentCodeHighlighter().then((highlighter) => {\n      if (cancelled) return;\n      const lines = highlighter\n        .codeToTokensWithThemes(code, {\n          lang: language,\n          themes: {\n            light: LIGHT_THEME,\n            dark: DARK_THEME,\n          },\n        })\n        .map((line) =>\n          line.map((token) => ({\n            content: token.content,\n            offset: token.offset,\n            light: token.variants.light?.color,\n            dark: token.variants.dark?.color,\n          })),\n      );\n      tokenCache.set(key, lines);\n      setResult({ key, code, language, lines });\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [code, key, language]);\n\n  if (result?.key === key) return result.lines;\n  if (result?.language === language && code.startsWith(result.code)) {\n    return result.lines;\n  }\n  return null;\n}\n\nexport function AgentCodeLine({\n  code,\n  tokens,\n  className,\n}: AgentCodeLineProps) {\n  return (\n    <span className={className}>\n      {tokens\n        ? tokens.map((token) => (\n            <span\n              key={`${token.offset}-${token.content}`}\n              style={\n                {\n                  \"--agent-code-light\": token.light ?? \"currentColor\",\n                  \"--agent-code-dark\": token.dark ?? token.light ?? \"currentColor\",\n                } as CSSProperties\n              }\n              className=\"text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]\"\n            >\n              {token.content}\n            </span>\n          ))\n        : code}\n    </span>\n  );\n}\n\nexport function AgentCode({\n  code,\n  language = \"bash\",\n  className,\n}: AgentCodeProps) {\n  const tokens = useAgentCodeTokens(code, language);\n  let offset = 0;\n  const lines = code.split(\"\\n\").map((content) => {\n    const line = { content, offset };\n    offset += content.length + 1;\n    return line;\n  });\n\n  return (\n    <pre\n      className={cn(\n        \"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85\",\n        className,\n      )}\n    >\n      <code>\n        {lines.map((line, index) => (\n          <Fragment key={line.offset}>\n            <AgentCodeLine code={line.content} tokens={tokens?.[index]} />\n            {index < lines.length - 1 ? \"\\n\" : null}\n          </Fragment>\n        ))}\n      </code>\n    </pre>\n  );\n}\n"},{"path":"components/agents/agent-disclosure.tsx","type":"registry:component","target":"@components/agents/agent-disclosure.tsx","content":"\"use client\";\n\nimport { motion, type HTMLMotionProps, useReducedMotion } from \"motion/react\";\nimport type { CSSProperties } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AgentDisclosureProps\n  extends Omit<HTMLMotionProps<\"div\">, \"animate\" | \"initial\"> {\n  open: boolean;\n  openHeight?: CSSProperties[\"height\"];\n}\n\n/** Shared transform-only reveal for collapsible agent content. */\nexport function AgentDisclosure({\n  open,\n  openHeight = \"auto\",\n  className,\n  style,\n  transition,\n  ...props\n}: AgentDisclosureProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <motion.div\n      {...props}\n      aria-hidden={!open}\n      inert={!open}\n      initial={false}\n      animate={\n        reduce\n          ? { opacity: open ? 1 : 0 }\n          : {\n              opacity: open ? 1 : 0,\n              clipPath: open ? \"inset(0 0 0% 0)\" : \"inset(0 0 100% 0)\",\n              y: open ? 0 : -4,\n            }\n      }\n      transition={\n        transition ?? {\n          duration: reduce ? 0 : open ? 0.22 : 0.14,\n          ease: EASE_OUT,\n        }\n      }\n      className={cn(\"overflow-hidden\", className)}\n      style={{\n        ...style,\n        height: open ? openHeight : 0,\n        pointerEvents: open ? undefined : \"none\",\n        transformOrigin: \"top\",\n      }}\n    />\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}