工具授权
New人机协同权限卡:审阅工具详情与参数、允许一次/始终允许/拒绝,可选 Shiki 高亮代码值。
Allow this tool to run?
terminal.run
The agent wants to run the project test suite in the current workspace.
- Command
bun test tests/a11y.test.tsx- Directory
- ui-components
TSXcomponents/previews/blocks/tool-approval.preview.tsx
"use client";
import { RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ToolApproval,
ToolApprovalCode,
type ToolApprovalStatus,
} from "@/components/agents/tool-approval";
export function ToolApprovalPreview() {
const [status, setStatus] = useState<ToolApprovalStatus>("pending");
const [detailsOpen, setDetailsOpen] = useState(true);
const timers = useRef<number[]>([]);
const clearTimers = useCallback(() => {
timers.current.forEach(window.clearTimeout);
timers.current = [];
}, []);
useEffect(() => () => clearTimers(), [clearTimers]);
const finish = (next: ToolApprovalStatus) => {
clearTimers();
setStatus(next);
};
const approve = () => {
clearTimers();
setStatus("approving");
timers.current = [
window.setTimeout(() => setStatus("approved"), 600),
window.setTimeout(() => setStatus("running"), 1150),
window.setTimeout(() => setStatus("complete"), 2200),
];
};
const replay = () => {
clearTimers();
setStatus("pending");
setDetailsOpen(true);
};
return (
<div className="relative h-[360px] w-full max-w-lg">
<ToolApproval
tool="terminal.run"
title={status === "pending" ? "Allow this tool to run?" : "Terminal access"}
description="The agent wants to run the project test suite in the current workspace."
status={status}
open={detailsOpen}
onOpenChange={setDetailsOpen}
parameters={[
{
id: "command",
label: "Command",
value: (
<ToolApprovalCode
code="bun test tests/a11y.test.tsx"
language="bash"
/>
),
},
{ id: "directory", label: "Directory", value: "ui-components" },
]}
onApprove={approve}
onAlwaysAllow={approve}
onDeny={() => finish("denied")}
/>
<button
type="button"
onClick={replay}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 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"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
TSXcomponents/agents/tool-approval.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/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(
"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>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/tool-approval
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion shiki tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/agents/tool-approval.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/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(
"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>
);
}
TSXcomponents/agents/agent-code.tsx
"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>
);
}
TSXcomponents/agents/agent-disclosure.tsx
"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",
}}
/>
);
}
API 参考
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" | "pending" | "approved" | "denied" | "running" | "complete" | "approving"pendingopen?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—onApprove?(() => void)—onAlwaysAllow?(() => void)—onDeny?(() => void)—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.