free157 lines · framer-motion
Tool Call Viewer
What the agent called, with what, and what came back. A collapsible card for one tool or MCP call: name, server, status, latency, and highlighted JSON for args and result.
- Use it for
- Chat transcripts, run inspectors, debugging MCP servers.
- Themes
- Light + dark
- License
- Commercial use (draft)
Live
{
"repo": "acme/web",
"query": "is:open label:billing",
"limit": 5
}tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
export type ToolCallStatus = "running" | "success" | "error";
export interface ToolCallViewerProps {
name?: string;
server?: string;
status?: ToolCallStatus;
args?: unknown;
result?: unknown;
error?: string;
durationMs?: number;
defaultOpen?: boolean;
className?: string;
}
/** Tiny JSON highlighter: keys, strings, numbers, booleans/null. */
function Json({ value }: { value: unknown }) {
const text = JSON.stringify(value, null, 2) ?? "undefined";
const parts = text.split(/("(?:\\.|[^"\\])*"(?:\s*:)?|\b-?\d+(?:\.\d+)?(?:e[+-]?\d+)?\b|\btrue\b|\bfalse\b|\bnull\b)/g);
return (
<code>
{parts.map((p, i) => {
if (!p) return null;
let c = "";
if (/^"/.test(p)) c = /:$/.test(p) ? "text-info" : "text-ok";
else if (/^(true|false|null)$/.test(p)) c = "text-signal";
else if (/^-?\d/.test(p)) c = "text-warn";
return (
<span key={i} className={c}>
{p}
</span>
);
})}
</code>
);
}
const statusMeta: Record<ToolCallStatus, { label: string; cls: string }> = {
running: { label: "Running", cls: "text-info bg-info/10" },
success: { label: "Done", cls: "text-ok bg-ok/10" },
error: { label: "Failed", cls: "text-destructive bg-destructive/10" },
};
export function ToolCallViewer({
name = "github.search_issues",
server = "github-mcp",
status = "success",
args = { repo: "acme/web", query: "is:open label:billing", limit: 5 },
result = { total: 2, items: [{ number: 471, title: "Rounding error on EU invoices" }, { number: 468, title: "Fee shows 3 decimals" }] },
error = "403: token lacks repo:read scope",
durationMs = 412,
defaultOpen = true,
className,
}: ToolCallViewerProps) {
const reduce = useReducedMotion();
const [open, setOpen] = React.useState(defaultOpen);
const [tab, setTab] = React.useState<"args" | "result">("args");
const [copied, setCopied] = React.useState(false);
const panelId = React.useId();
const s = statusMeta[status];
React.useEffect(() => setOpen(defaultOpen), [defaultOpen]);
React.useEffect(() => {
if (status !== "success" && tab === "result") setTab("args");
}, [status, tab]);
const payload = tab === "args" ? args : status === "error" ? { error } : result;
const copy = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard blocked */
}
};
return (
<div className={cn("w-full max-w-lg overflow-hidden rounded-xl border border-border bg-card text-card-foreground", className)}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted/50"
>
<span className="grid size-7 place-items-center rounded-md bg-muted font-mono text-[11px]" aria-hidden>
{status === "running" ? (
<motion.span
className="block size-3 rounded-full border-2 border-info border-t-transparent"
animate={reduce ? undefined : { rotate: 360 }}
transition={{ repeat: Infinity, duration: 0.8, ease: "linear" }}
/>
) : (
"ƒ"
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] font-semibold">{name}</span>
<span className="block font-mono text-[10.5px] text-muted-foreground">via {server}</span>
</span>
<span className={cn("rounded-full px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider", s.cls)}>{s.label}</span>
{status !== "running" && <span className="font-mono text-[11px] tabular-nums text-muted-foreground">{durationMs}ms</span>}
<svg viewBox="0 0 16 16" className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} aria-hidden>
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={panelId}
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden border-t border-border"
>
<div className="flex items-center gap-1 px-3 pt-2" role="tablist" aria-label="Tool call payload">
{(["args", "result"] as const).map((t) => (
<button
key={t}
role="tab"
type="button"
aria-selected={tab === t}
disabled={t === "result" && status === "running"}
onClick={() => setTab(t)}
className={cn(
"rounded-md px-2.5 py-1 font-mono text-[11px] uppercase tracking-wider transition disabled:opacity-40",
tab === t ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
>
{t === "result" && status === "error" ? "error" : t}
</button>
))}
<button type="button" onClick={copy} className="ml-auto rounded-md px-2 py-1 font-mono text-[11px] text-muted-foreground hover:bg-muted" aria-live="polite">
{copied ? "Copied" : "Copy"}
</button>
</div>
<pre role="tabpanel" className="max-h-64 overflow-auto px-4 py-3 font-mono text-[12px] leading-5 scrollbar-thin">
{status === "running" && tab === "result" ? <span className="text-muted-foreground">Waiting…</span> : <Json value={payload} />}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default ToolCallViewer;
How it behaves
- →Header shows a spinner while running, a status chip (Running / Done / Failed) and latency.
- →Args / Result tabs; Result becomes Error when status is error and is disabled while running.
- →Tiny built-in JSON highlighter: keys blue, strings green, numbers amber, booleans/null orange.
- →Copy button copies the visible payload as pretty JSON.
Accessibility
- ✓Header is a button with aria-expanded / aria-controls.
- ✓Payload tabs use role=tablist / tab / tabpanel.
- ✓Honours prefers-reduced-motion.