PixelDosa UI

Tool Call Card

What the agent actually did — read, searched, fetched, wrote — as a record beside what it says, so retrieved knowledge looks different from assumed knowledge.

  1. Tool
    grep
    Input
    {
      "pattern": "requestTimeout",
      "path": "src/"
    }

Installation

bash
npx shadcn@latest add @pixeldosa/tool-call-card

Requires `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. ToolCallGroup reads "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

effectDefault forShown as
read-onlyread, search, fetchnothing extra
changeswrite, run"Made changes"
externalcall"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

PropTypeDescription
kind"read" | "search" | "fetch" | "write" | "run" | "call"Required.
targetstringRequired. What it touched: path, query, URL, command.
status"running" | "done" | "failed" | "blocked"Required.
namestringThe raw tool name.
effect"read-only" | "changes" | "external"Defaults from kind.
inputstring | objectObjects render as formatted JSON.
outputReactNodeStrings render as a scrollable block.
errorstringWhy it failed or was blocked.
durationMsnumberHidden while running.
defaultOpenboolean

ToolCallGroup

PropTypeDescription
calls(ToolCallCardProps & { id })[]Required.
defaultOpenbooleanCollapsed by default.

Accessibility

  • Summaries are buttons with aria-expanded, and details are hidden with inert when 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.