Tool Approval
Asks a person before an agent runs a tool: it lists the call's parameters, can show the code or command to be executed, lets them approve a single run, grant standing access or deny, then follows the call through running, complete or error.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion shiki tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/agents/tool-approval
import {
Check,
ChevronDown,
CircleAlert,
LoaderCircle,
ShieldCheck,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
export interface ToolApprovalCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onApprove?: () => void;
onAlwaysAllow?: () => void;
onDeny?: () => void;
className?: string;
}
function getStatusCopy(status: ToolApprovalStatus) {
if (status === "approving") return "Approving";
if (status === "approved") return "Approved";
if (status === "denied") return "Denied";
if (status === "running") return "Running";
if (status === "complete") return "Completed";
if (status === "error") return "Failed";
return "Approval required";
}
function getStatusBadgeClass(status: ToolApprovalStatus) {
if (status === "pending") {
return "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400";
}
if (status === "approving" || status === "running") {
return "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400";
}
if (status === "approved" || status === "complete") {
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
}
return "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400";
}
export function ToolApprovalCode({
code,
language = "bash",
className,
}: ToolApprovalCodeProps) {
return (
<AgentCode
code={code}
language={language}
className={cn(
// Parameter values sit in a narrow grid column with nowhere to scroll
// on touch, so they wrap instead of clipping (as ToolResultOutput does).
"whitespace-pre-wrap break-words rounded-lg border border-border/50 bg-muted/30 px-2.5 py-2",
className,
)}
/>
);
}
export function ToolApproval({
tool,
title = "Allow this tool to run?",
description,
parameters = [],
status = "pending",
open,
defaultOpen = false,
onOpenChange,
onApprove,
onAlwaysAllow,
onDeny,
className,
}: ToolApprovalProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const detailsId = `${baseId}-details`;
const previousStatus = useRef(status);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
const busy = status === "approving" || status === "running";
const pending = status === "pending";
const error = status === "error";
useEffect(() => {
if (previousStatus.current === "pending" && status !== "pending") {
setOpen(false);
}
previousStatus.current = status;
}, [setOpen, status]);
return (
<div
data-state={status}
aria-busy={busy}
className={cn(
"w-full overflow-hidden rounded-2xl border border-border/60 bg-muted/20 text-sm",
className,
)}
>
<div className="flex items-start gap-3 p-4">
<span
aria-hidden="true"
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-xl border border-border/60 bg-background text-muted-foreground",
error && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className={cn("size-4", !reduce && "animate-spin")} />
) : error ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors",
getStatusBadgeClass(status),
)}
>
{getStatusCopy(status)}
</span>
</div>
{description ? (
<p className="mt-2 leading-5 text-muted-foreground">{description}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={currentOpen}
aria-controls={detailsId}
onClick={() => setOpen(!currentOpen)}
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"
>
View details
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
) : null}
</div>
</div>
<AgentDisclosure
id={detailsId}
open={currentOpen}
>
<dl className="mx-4 mb-4 grid gap-2 rounded-xl border border-border/50 bg-background/70 p-3">
{parameters.map((parameter) => (
<div
key={parameter.id}
className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-center gap-3 text-xs"
>
<dt className="text-muted-foreground">{parameter.label}</dt>
<dd className="min-w-0 break-words font-mono text-foreground/85">
{parameter.value}
</dd>
</div>
))}
</dl>
</AgentDisclosure>
<AnimatePresence initial={false}>
{pending ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="flex flex-wrap items-center gap-2 border-t border-border/60 px-4 py-3"
>
<motion.button
type="button"
onClick={onApprove}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
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"
>
Allow once
</motion.button>
{onAlwaysAllow ? (
<motion.button
type="button"
onClick={onAlwaysAllow}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
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"
>
Always allow
</motion.button>
) : null}
<button
type="button"
onClick={onDeny}
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"
>
Deny
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
"use client";
import {
type CSSProperties,
Fragment,
useEffect,
useState,
} from "react";
import { createHighlighter, type Highlighter } from "shiki";
import { cn } from "@/lib/utils";
export type AgentCodeLanguage =
| "bash"
| "diff"
| "json"
| "text"
| "tsx"
| "typescript";
export interface AgentCodeToken {
content: string;
offset: number;
light?: string;
dark?: string;
}
export type AgentCodeTokenLines = AgentCodeToken[][];
export interface AgentCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface AgentCodeLineProps {
code: string;
tokens?: AgentCodeToken[];
className?: string;
}
const LIGHT_THEME = "github-light-high-contrast";
const DARK_THEME = "github-dark-high-contrast";
let agentCodeHighlighter: Promise<Highlighter> | null = null;
const tokenCache = new Map<string, AgentCodeTokenLines>();
function getAgentCodeHighlighter() {
if (!agentCodeHighlighter) {
agentCodeHighlighter = createHighlighter({
themes: [LIGHT_THEME, DARK_THEME],
langs: ["bash", "diff", "json", "tsx", "typescript"],
});
}
return agentCodeHighlighter;
}
function tokenCacheKey(code: string, language: AgentCodeLanguage) {
return `${language}\u0000${code}`;
}
export function useAgentCodeTokens(
code: string,
language: AgentCodeLanguage,
) {
const key = tokenCacheKey(code, language);
const cached = tokenCache.get(key);
const [result, setResult] = useState<{
key: string;
code: string;
language: AgentCodeLanguage;
lines: AgentCodeTokenLines;
} | null>(cached ? { key, code, language, lines: cached } : null);
useEffect(() => {
const current = tokenCache.get(key);
if (current) {
setResult({ key, code, language, lines: current });
return;
}
let cancelled = false;
getAgentCodeHighlighter().then((highlighter) => {
if (cancelled) return;
const lines = highlighter
.codeToTokensWithThemes(code, {
lang: language,
themes: {
light: LIGHT_THEME,
dark: DARK_THEME,
},
})
.map((line) =>
line.map((token) => ({
content: token.content,
offset: token.offset,
light: token.variants.light?.color,
dark: token.variants.dark?.color,
})),
);
tokenCache.set(key, lines);
setResult({ key, code, language, lines });
});
return () => {
cancelled = true;
};
}, [code, key, language]);
if (result?.key === key) return result.lines;
if (result?.language === language && code.startsWith(result.code)) {
return result.lines;
}
return null;
}
export function AgentCodeLine({
code,
tokens,
className,
}: AgentCodeLineProps) {
return (
<span className={className}>
{tokens
? tokens.map((token) => (
<span
key={`${token.offset}-${token.content}`}
style={
{
"--agent-code-light": token.light ?? "currentColor",
"--agent-code-dark": token.dark ?? token.light ?? "currentColor",
} as CSSProperties
}
className="text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]"
>
{token.content}
</span>
))
: code}
</span>
);
}
export function AgentCode({
code,
language = "bash",
className,
}: AgentCodeProps) {
const tokens = useAgentCodeTokens(code, language);
let offset = 0;
const lines = code.split("\n").map((content) => {
const line = { content, offset };
offset += content.length + 1;
return line;
});
return (
<pre
className={cn(
"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85",
className,
)}
>
<code>
{lines.map((line, index) => (
<Fragment key={line.offset}>
<AgentCodeLine code={line.content} tokens={tokens?.[index]} />
{index < lines.length - 1 ? "\n" : null}
</Fragment>
))}
</code>
</pre>
);
}
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
ToolApprovalCode
codestring—language?"text" | "bash" | "diff" | "json" | "tsx" | "typescript"bashclassName?string—ToolApproval
toolReactNode—title?ReactNodeAllow this tool to run?description?ReactNode—parameters?ToolApprovalParameter[][]status?"error" | "complete" | "pending" | "approved" | "running" | "approving" | "denied"pendingopen?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—onApprove?(() => void)—onAlwaysAllow?(() => void)—onDeny?(() => void)—className?string—Composition
When the choice depends on reading the code, show that code within the request itself.
Message
└── MessageContent
└── ToolApproval
└── ToolApprovalCodeNote: Approval Card takes on open questions and wider reviews. Agent Activity shows the run that is waiting and what resumes. Tool Result shows what the tool returned once it ran.
How it works
Approving a tool draws a line around what the agent may do. People need to see what will execute, what it can touch, and whether saying yes covers this call only or every call after it.
Updated