Skip to content
loopkit
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.