Tool Result
A lightweight execution disclosure for syntax-highlighted terminal output and request responses that collapses into a compact completed state.
Terminal Output
tool-result.tsxStreams command output into a bounded viewport, follows new lines, then collapses into the completed run summary.
"use client";
import { RotateCcw } from "lucide-react";
import { useState } from "react";
import {
ToolResult,
ToolResultOutput,
} from "@/components/agents/tool-result";
import { useToolResultDemo } from "./use-tool-result-demo";
const OUTPUT = [
"$ bun test tests/a11y.test.tsx",
"bun test v1.3.14",
"✓ StreamingResponse complete",
"✓ ToolApproval pending",
"✓ Citations expanded",
"49 pass · 0 fail",
] as const;
function TerminalRun({ onReplay }: { onReplay: () => void }) {
const { visible, status } = useToolResultDemo(OUTPUT.length);
const output = OUTPUT.slice(0, visible).join("\n");
return (
<ToolResult
tool="terminal.run"
title={status === "running" ? "Running accessibility tests" : "Tests passed"}
kind="terminal"
status={status}
meta={status === "success" ? "2.9s" : undefined}
copyText={output}
onRetry={onReplay}
maxHeight={150}
>
<ToolResultOutput>{output}</ToolResultOutput>
</ToolResult>
);
}
export function ToolResultTerminalPreview() {
const [run, setRun] = useState(0);
const replay = () => setRun((value) => value + 1);
return (
<div className="relative h-[330px] w-full max-w-lg">
<TerminalRun key={run} onReplay={replay} />
<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>
);
}
"use client";
// beui.dev/components/agents/tool-result
import {
Ban,
Braces,
Check,
ChevronDown,
CircleCheck,
CircleX,
Copy,
LoaderCircle,
RotateCcw,
SquareTerminal,
Wrench,
} from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
export type ToolResultKind = "terminal" | "request" | "custom";
export interface ToolResultProps {
tool: ReactNode;
title: ReactNode;
children: ReactNode;
status?: ToolResultStatus;
kind?: ToolResultKind;
meta?: ReactNode;
icon?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
copyText?: string;
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
className?: string;
contentClassName?: string;
}
export interface ToolResultOutputProps {
children: string;
language?: AgentCodeLanguage;
className?: string;
}
function getStatusLabel(status: ToolResultStatus) {
if (status === "running") return "Running";
if (status === "success") return "Completed";
if (status === "error") return "Failed";
return "Cancelled";
}
function getSwapKey(value: ReactNode, fallback: string) {
return typeof value === "string" || typeof value === "number"
? String(value)
: fallback;
}
function getStatusClass(status: ToolResultStatus) {
if (status === "running") {
return "text-blue-600 dark:text-blue-400";
}
if (status === "success") {
return "text-emerald-600 dark:text-emerald-400";
}
if (status === "error") {
return "text-rose-600 dark:text-rose-400";
}
return "text-muted-foreground";
}
function KindIcon({ kind }: { kind: ToolResultKind }) {
if (kind === "terminal") return <SquareTerminal className="size-4" />;
if (kind === "request") return <Braces className="size-4" />;
return <Wrench className="size-4" />;
}
function StatusIcon({
status,
reduce,
}: {
status: ToolResultStatus;
reduce: boolean;
}) {
if (status === "running") {
return <LoaderCircle className={cn("size-3", !reduce && "animate-spin")} />;
}
if (status === "success") return <CircleCheck className="size-3" />;
if (status === "error") return <CircleX className="size-3" />;
return <Ban className="size-3" />;
}
function ToolResultAction({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</motion.button>
);
}
export function ToolResultOutput({
children,
language = "bash",
className,
}: ToolResultOutputProps) {
return (
<AgentCode
code={children}
language={language}
className={cn(
"whitespace-pre-wrap break-words text-foreground/80",
className,
)}
/>
);
}
export function ToolResult({
tool,
title,
children,
status = "running",
kind = "custom",
meta,
icon,
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 220,
copyText,
onCopy,
onRetry,
className,
contentClassName,
}: ToolResultProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef<HTMLDivElement>(null);
const previousStatus = useRef(status);
const copyTimer = useRef<number | undefined>(undefined);
const [copied, setCopied] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const running = status === "running";
const canCopy = Boolean(copyText || onCopy);
const titleKey = getSwapKey(title, status);
const metaKey = getSwapKey(meta, `${status}-meta`);
const toolKey = getSwapKey(tool, `${status}-tool`);
const statusLabel = getStatusLabel(status);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousStatus.current !== "running" && status === "running") {
setOpen(true);
}
if (
previousStatus.current === "running" &&
status !== "running" &&
collapseOnComplete
) {
setOpen(false);
}
previousStatus.current = status;
}, [collapseOnComplete, setOpen, status]);
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !currentOpen || !running) return;
const frame = requestAnimationFrame(() => {
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
});
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
return (
<div
data-state={status}
aria-busy={running}
className={cn("w-full text-sm", className)}
>
<button
id={triggerId}
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center text-muted-foreground"
>
{icon ?? <KindIcon kind={kind} />}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate font-medium text-foreground/90">
<ActionSwapRollText value={titleKey}>
{title}
</ActionSwapRollText>
</span>
{meta ? (
<span className="shrink-0 text-xs text-muted-foreground/60">
<ActionSwapRollText value={metaKey}>
{meta}
</ActionSwapRollText>
</span>
) : null}
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={toolKey}>
{tool}
</ActionSwapRollText>
</span>
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-[11px] font-medium",
getStatusClass(status),
)}
>
<StatusIcon status={status} reduce={reduce} />
<ActionSwapRollText value={status}>{statusLabel}</ActionSwapRollText>
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={currentOpen}
>
<div className="pl-6 pt-1.5">
<div className="overflow-hidden rounded-xl bg-muted/80">
<div
ref={viewportRef}
role="log"
aria-live="polite"
className="scrollbar-hide overflow-y-auto"
style={{ maxHeight }}
>
<div className={cn("p-3", contentClassName)}>{children}</div>
</div>
{canCopy || onRetry ? (
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{canCopy ? (
<ToolResultAction
label={copied ? "Copied" : "Copy result"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ToolResultAction>
) : null}
{onRetry ? (
<ToolResultAction label="Run again" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ToolResultAction>
) : null}
<span className="ml-auto text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={status}>
{statusLabel}
</ActionSwapRollText>
</span>
</div>
) : null}
</div>
</div>
</AgentDisclosure>
</div>
);
}
Request Result
tool-result.tsxPresents an in-flight request and its highlighted response payload with retry and copy actions.
"use client";
import { RotateCcw } from "lucide-react";
import { useState } from "react";
import { AgentCode } from "@/components/agents/agent-code";
import {
ToolResult,
ToolResultOutput,
} from "@/components/agents/tool-result";
import { useToolResultDemo } from "./use-tool-result-demo";
const RESPONSE = `{
"error": "rate_limit_exceeded",
"retryAfter": 30,
"requestId": "req_8f21"
}`;
function RequestRun({ onReplay }: { onReplay: () => void }) {
const { visible, status } = useToolResultDemo(3, 600, "error");
return (
<ToolResult
tool="http.request"
title={status === "running" ? "Fetching project activity" : "Request failed"}
kind="request"
status={status}
meta={status === "error" ? "429" : "GET /v1/activity"}
copyText={RESPONSE}
onRetry={onReplay}
collapseOnComplete={false}
maxHeight={150}
>
{visible < 3 ? (
<ToolResultOutput>
{visible === 0
? "Preparing request…"
: visible === 1
? "GET /v1/activity\nConnecting…"
: "GET /v1/activity\nWaiting for response…"}
</ToolResultOutput>
) : (
<AgentCode code={RESPONSE} language="json" />
)}
</ToolResult>
);
}
export function ToolResultRequestPreview() {
const [run, setRun] = useState(0);
const replay = () => setRun((value) => value + 1);
return (
<div className="relative h-[330px] w-full max-w-lg">
<RequestRun key={run} onReplay={replay} />
<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>
);
}
"use client";
// beui.dev/components/agents/tool-result
import {
Ban,
Braces,
Check,
ChevronDown,
CircleCheck,
CircleX,
Copy,
LoaderCircle,
RotateCcw,
SquareTerminal,
Wrench,
} from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
export type ToolResultKind = "terminal" | "request" | "custom";
export interface ToolResultProps {
tool: ReactNode;
title: ReactNode;
children: ReactNode;
status?: ToolResultStatus;
kind?: ToolResultKind;
meta?: ReactNode;
icon?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
copyText?: string;
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
className?: string;
contentClassName?: string;
}
export interface ToolResultOutputProps {
children: string;
language?: AgentCodeLanguage;
className?: string;
}
function getStatusLabel(status: ToolResultStatus) {
if (status === "running") return "Running";
if (status === "success") return "Completed";
if (status === "error") return "Failed";
return "Cancelled";
}
function getSwapKey(value: ReactNode, fallback: string) {
return typeof value === "string" || typeof value === "number"
? String(value)
: fallback;
}
function getStatusClass(status: ToolResultStatus) {
if (status === "running") {
return "text-blue-600 dark:text-blue-400";
}
if (status === "success") {
return "text-emerald-600 dark:text-emerald-400";
}
if (status === "error") {
return "text-rose-600 dark:text-rose-400";
}
return "text-muted-foreground";
}
function KindIcon({ kind }: { kind: ToolResultKind }) {
if (kind === "terminal") return <SquareTerminal className="size-4" />;
if (kind === "request") return <Braces className="size-4" />;
return <Wrench className="size-4" />;
}
function StatusIcon({
status,
reduce,
}: {
status: ToolResultStatus;
reduce: boolean;
}) {
if (status === "running") {
return <LoaderCircle className={cn("size-3", !reduce && "animate-spin")} />;
}
if (status === "success") return <CircleCheck className="size-3" />;
if (status === "error") return <CircleX className="size-3" />;
return <Ban className="size-3" />;
}
function ToolResultAction({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</motion.button>
);
}
export function ToolResultOutput({
children,
language = "bash",
className,
}: ToolResultOutputProps) {
return (
<AgentCode
code={children}
language={language}
className={cn(
"whitespace-pre-wrap break-words text-foreground/80",
className,
)}
/>
);
}
export function ToolResult({
tool,
title,
children,
status = "running",
kind = "custom",
meta,
icon,
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 220,
copyText,
onCopy,
onRetry,
className,
contentClassName,
}: ToolResultProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef<HTMLDivElement>(null);
const previousStatus = useRef(status);
const copyTimer = useRef<number | undefined>(undefined);
const [copied, setCopied] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const running = status === "running";
const canCopy = Boolean(copyText || onCopy);
const titleKey = getSwapKey(title, status);
const metaKey = getSwapKey(meta, `${status}-meta`);
const toolKey = getSwapKey(tool, `${status}-tool`);
const statusLabel = getStatusLabel(status);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousStatus.current !== "running" && status === "running") {
setOpen(true);
}
if (
previousStatus.current === "running" &&
status !== "running" &&
collapseOnComplete
) {
setOpen(false);
}
previousStatus.current = status;
}, [collapseOnComplete, setOpen, status]);
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !currentOpen || !running) return;
const frame = requestAnimationFrame(() => {
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
});
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
return (
<div
data-state={status}
aria-busy={running}
className={cn("w-full text-sm", className)}
>
<button
id={triggerId}
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center text-muted-foreground"
>
{icon ?? <KindIcon kind={kind} />}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate font-medium text-foreground/90">
<ActionSwapRollText value={titleKey}>
{title}
</ActionSwapRollText>
</span>
{meta ? (
<span className="shrink-0 text-xs text-muted-foreground/60">
<ActionSwapRollText value={metaKey}>
{meta}
</ActionSwapRollText>
</span>
) : null}
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={toolKey}>
{tool}
</ActionSwapRollText>
</span>
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-[11px] font-medium",
getStatusClass(status),
)}
>
<StatusIcon status={status} reduce={reduce} />
<ActionSwapRollText value={status}>{statusLabel}</ActionSwapRollText>
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={currentOpen}
>
<div className="pl-6 pt-1.5">
<div className="overflow-hidden rounded-xl bg-muted/80">
<div
ref={viewportRef}
role="log"
aria-live="polite"
className="scrollbar-hide overflow-y-auto"
style={{ maxHeight }}
>
<div className={cn("p-3", contentClassName)}>{children}</div>
</div>
{canCopy || onRetry ? (
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{canCopy ? (
<ToolResultAction
label={copied ? "Copied" : "Copy result"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ToolResultAction>
) : null}
{onRetry ? (
<ToolResultAction label="Run again" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ToolResultAction>
) : null}
<span className="ml-auto text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={status}>
{statusLabel}
</ActionSwapRollText>
</span>
</div>
) : null}
</div>
</div>
</AgentDisclosure>
</div>
);
}
API Reference
ToolResultOutput
language?"text" | "bash" | "diff" | "json" | "tsx" | "typescript"bashclassName?string—ToolResult
toolReactNode—titleReactNode—status?"error" | "success" | "running" | "cancelled"runningkind?"custom" | "terminal" | "request"custommeta?ReactNode—icon?ReactNode—open?boolean—defaultOpen?booleantrueonOpenChange?((open: boolean) => void)—collapseOnComplete?booleantruemaxHeight?number220copyText?string—onCopy?(() => void | Promise<void>)—onRetry?(() => void)—className?string—contentClassName?string—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";
// beui.dev/components/agents/tool-result
import {
Ban,
Braces,
Check,
ChevronDown,
CircleCheck,
CircleX,
Copy,
LoaderCircle,
RotateCcw,
SquareTerminal,
Wrench,
} from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
export type ToolResultKind = "terminal" | "request" | "custom";
export interface ToolResultProps {
tool: ReactNode;
title: ReactNode;
children: ReactNode;
status?: ToolResultStatus;
kind?: ToolResultKind;
meta?: ReactNode;
icon?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
copyText?: string;
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
className?: string;
contentClassName?: string;
}
export interface ToolResultOutputProps {
children: string;
language?: AgentCodeLanguage;
className?: string;
}
function getStatusLabel(status: ToolResultStatus) {
if (status === "running") return "Running";
if (status === "success") return "Completed";
if (status === "error") return "Failed";
return "Cancelled";
}
function getSwapKey(value: ReactNode, fallback: string) {
return typeof value === "string" || typeof value === "number"
? String(value)
: fallback;
}
function getStatusClass(status: ToolResultStatus) {
if (status === "running") {
return "text-blue-600 dark:text-blue-400";
}
if (status === "success") {
return "text-emerald-600 dark:text-emerald-400";
}
if (status === "error") {
return "text-rose-600 dark:text-rose-400";
}
return "text-muted-foreground";
}
function KindIcon({ kind }: { kind: ToolResultKind }) {
if (kind === "terminal") return <SquareTerminal className="size-4" />;
if (kind === "request") return <Braces className="size-4" />;
return <Wrench className="size-4" />;
}
function StatusIcon({
status,
reduce,
}: {
status: ToolResultStatus;
reduce: boolean;
}) {
if (status === "running") {
return <LoaderCircle className={cn("size-3", !reduce && "animate-spin")} />;
}
if (status === "success") return <CircleCheck className="size-3" />;
if (status === "error") return <CircleX className="size-3" />;
return <Ban className="size-3" />;
}
function ToolResultAction({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</motion.button>
);
}
export function ToolResultOutput({
children,
language = "bash",
className,
}: ToolResultOutputProps) {
return (
<AgentCode
code={children}
language={language}
className={cn(
"whitespace-pre-wrap break-words text-foreground/80",
className,
)}
/>
);
}
export function ToolResult({
tool,
title,
children,
status = "running",
kind = "custom",
meta,
icon,
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 220,
copyText,
onCopy,
onRetry,
className,
contentClassName,
}: ToolResultProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef<HTMLDivElement>(null);
const previousStatus = useRef(status);
const copyTimer = useRef<number | undefined>(undefined);
const [copied, setCopied] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const running = status === "running";
const canCopy = Boolean(copyText || onCopy);
const titleKey = getSwapKey(title, status);
const metaKey = getSwapKey(meta, `${status}-meta`);
const toolKey = getSwapKey(tool, `${status}-tool`);
const statusLabel = getStatusLabel(status);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousStatus.current !== "running" && status === "running") {
setOpen(true);
}
if (
previousStatus.current === "running" &&
status !== "running" &&
collapseOnComplete
) {
setOpen(false);
}
previousStatus.current = status;
}, [collapseOnComplete, setOpen, status]);
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !currentOpen || !running) return;
const frame = requestAnimationFrame(() => {
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
});
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
return (
<div
data-state={status}
aria-busy={running}
className={cn("w-full text-sm", className)}
>
<button
id={triggerId}
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center text-muted-foreground"
>
{icon ?? <KindIcon kind={kind} />}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate font-medium text-foreground/90">
<ActionSwapRollText value={titleKey}>
{title}
</ActionSwapRollText>
</span>
{meta ? (
<span className="shrink-0 text-xs text-muted-foreground/60">
<ActionSwapRollText value={metaKey}>
{meta}
</ActionSwapRollText>
</span>
) : null}
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={toolKey}>
{tool}
</ActionSwapRollText>
</span>
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-[11px] font-medium",
getStatusClass(status),
)}
>
<StatusIcon status={status} reduce={reduce} />
<ActionSwapRollText value={status}>{statusLabel}</ActionSwapRollText>
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={currentOpen}
>
<div className="pl-6 pt-1.5">
<div className="overflow-hidden rounded-xl bg-muted/80">
<div
ref={viewportRef}
role="log"
aria-live="polite"
className="scrollbar-hide overflow-y-auto"
style={{ maxHeight }}
>
<div className={cn("p-3", contentClassName)}>{children}</div>
</div>
{canCopy || onRetry ? (
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{canCopy ? (
<ToolResultAction
label={copied ? "Copied" : "Copy result"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ToolResultAction>
) : null}
{onRetry ? (
<ToolResultAction label="Run again" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ToolResultAction>
) : null}
<span className="ml-auto text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={status}>
{statusLabel}
</ActionSwapRollText>
</span>
</div>
) : null}
</div>
</div>
</AgentDisclosure>
</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",
}}
/>
);
}
"use client";
import {
ActionSwapButton,
ActionSwapIcon,
ActionSwapText,
type ActionSwapButtonProps,
type ActionSwapIconProps,
type ActionSwapTextProps,
} from "./action-swap";
export type {
ActionSwapButtonSize,
ActionSwapButtonVariant,
ActionSwapItem,
} from "./action-swap";
export type ActionSwapRollButtonProps = Omit<ActionSwapButtonProps, "animation">;
export type ActionSwapRollTextProps = Omit<ActionSwapTextProps, "animation">;
export type ActionSwapRollIconProps = Omit<ActionSwapIconProps, "animation">;
export function ActionSwapRollButton(props: ActionSwapRollButtonProps) {
return <ActionSwapButton {...props} animation="roll" />;
}
export function ActionSwapRollText(props: ActionSwapRollTextProps) {
return <ActionSwapText {...props} animation="roll" />;
}
export function ActionSwapRollIcon(props: ActionSwapRollIconProps) {
return <ActionSwapIcon {...props} animation="roll" />;
}
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
Composition
Use the output primitive for terminal text, request details, generated code, or another structured result.
ToolResult
└── ToolResultOutputNote: Tool Approval collects permission before the execution begins. Code Block renders highlighted code returned by a tool. Agent Activity summarizes the tool invocation within a longer run.
How it works
A tool result is evidence from an execution, not another chat bubble. It should connect a compact run summary to the output needed for inspection, recovery, or reuse.
Updated