Installation
npx shadcn@latest add @pixeldosa/tool-call-cardRequires `cn` at `@/lib/utils`. Map each tool your agent exposes to a `kind`, and override `effect` for tools whose side effects the kind doesn't imply (e.g. a `run` that is read-only). Show the group next to the answer it supports, not in a separate debug panel.
Usage
"use client";
import { ToolCallCard, ToolCallGroup } from "@/components/ui/tool-call-card";
<ToolCallCard
kind="read"
name="fs.read_file"
target="src/config.ts"
status="done"
input={{ path: "src/config.ts" }}
output={fileExcerpt}
durationMs={42}
/>
<ToolCallGroup calls={run.toolCalls} />
Why it exists
"The config sets a 30s timeout" reads the same whether the agent opened the file or guessed. A record of the calls next to the answer shows which it was. It also shows what didn't work: a failed fetch sits right beside an answer that skipped those docs.
Features
- A sentence, not a log line. Verb, then the exact target in monospace:
"Read
src/config.ts". Arguments and result are one click away. - Acting stands out from looking. Read-only calls stay quiet. Calls that made changes or reached outside the app are labelled, because those are what a reviewer looks for.
- One line for a whole run.
ToolCallGroupreads "Read 2 files, searched once, wrote 1 file", with "1 call made changes" and "1 failed" beside it. While a call runs, that call replaces the tally. - Honest counts. Only finished calls count as done. Failures and blocked calls are reported separately.
- Evidence can't swamp the page. Results are height-capped with their own scroll.
Effects
effect | Default for | Shown as |
|---|---|---|
read-only | read, search, fetch | nothing extra |
changes | write, run | "Made changes" |
external | call | "Outside this app" |
Override when the kind lies: a run of git status is read-only.
Examples
Mapping tool events, whatever your agent runtime:
const kindFor = { read_file: "read", grep: "search", web_fetch: "fetch", edit_file: "write", bash: "run" };
const calls = events.map((event) => ({
id: event.id,
kind: kindFor[event.tool] ?? "call",
name: event.tool,
target: event.args.path ?? event.args.query ?? event.args.url ?? event.tool,
status: event.error ? "failed" : event.result === undefined ? "running" : "done",
input: event.args,
output: event.result,
error: event.error,
}));
A call the user's permissions stopped:
<ToolCallCard kind="write" target=".env" status="blocked" error="Secrets files are outside this agent's scope." />
Props
ToolCallCard
| Prop | Type | Description |
|---|---|---|
kind | "read" | "search" | "fetch" | "write" | "run" | "call" | Required. |
target | string | Required. What it touched: path, query, URL, command. |
status | "running" | "done" | "failed" | "blocked" | Required. |
name | string | The raw tool name. |
effect | "read-only" | "changes" | "external" | Defaults from kind. |
input | string | object | Objects render as formatted JSON. |
output | ReactNode | Strings render as a scrollable block. |
error | string | Why it failed or was blocked. |
durationMs | number | Hidden while running. |
defaultOpen | boolean |
ToolCallGroup
| Prop | Type | Description |
|---|---|---|
calls | (ToolCallCardProps & { id })[] | Required. |
defaultOpen | boolean | Collapsed by default. |
Accessibility
- Summaries are buttons with
aria-expanded, and details are hidden withinertwhen closed. - Calls in a group are an ordered list, because the order is part of the record.
- Status is carried by words ("Couldn't fetch", "Not allowed to write"), not only colour.
- The running shimmer falls back to static text under reduced motion.
When not to use
Don't use it for a debugging console or a raw event log. It's for people reviewing
what an agent did. For the agent's current activity in one line, use Live Status Line.
For why it produced an answer, use AI Context Surface.
why it's built this way
Engineering Notes
Built for a trust gap seen from the model side. An agent's answer blends what it retrieved with what it inferred, and the text alone reads the same either way. A visible record of the calls lets a reader tell 'the config sets a 30s timeout' backed by a read of config.ts from the same sentence with nothing behind it, and it makes failures just as visible: the demo's answer admits the docs fetch failed, and the failed card is right there. Three decisions keep this from becoming a log dump. The summary is a sentence, verb then exact target in monospace, so it scans like prose. Evidence (arguments and result) sits in a disclosure, capped in height with its own scroll so one large result cannot swamp the run. And effect is a first-class prop defaulted from the kind, so the handful of calls that changed something are labelled among the many that only looked. Blocked calls never ran, so they never carry an effect label. The group tallies only finished calls and reports failures separately, so the headline never counts a failed read as a read. Collapsible regions use the house 0fr→1fr grid with overflow-hidden, padding inside the min-h-0 child, and inert while closed.
how it moves, and when it doesn't
Motion Notes
Motion only where the machine is working. A running call's verb takes the shared `pd-shimmer`. The target never shimmers, because it is content and must stay readable. While any call runs, the group headline swaps its tally for that call, so the one moving line is always the current action. Disclosures open with a grid-template-rows transition at `--pd-duration-fast` and a chevron rotation on the same curve. Hover colour runs at `--pd-duration-instant`. Finished, failed and blocked calls hold still. Everything falls back to instant or static text under prefers-reduced-motion.