23 / Agent UI
Tool Call
A compact record of one tool invocation: name, status, duration, and the input and output behind a disclosure.
Input
{
"pattern": "nullable email",
"path": "migrations/",
"limit": 5
}"use client"
import { DemoVariants } from "@/components/demos/demo-variants"
import { RestartButton } from "@/components/demos/restart-button"
import {
useScriptedTimeline,
type TimelineStep,
} from "@/components/demos/use-scripted-timeline"
import {
ToolCall,
type ToolCallStatus,
} from "mischief-ui/tool-call"
const steps: [TimelineStep<ToolCallStatus>, ...TimelineStep<ToolCallStatus>[]] =
[
{ state: "pending", holdMs: 900 },
{ state: "running", holdMs: 2400 },
{ state: "success", holdMs: 0 },
]
const input = { pattern: "nullable email", path: "migrations/", limit: 5 }
function LiveRun() {
const { state, elapsedMs, isFinished, restart, runId } =
useScriptedTimeline(steps)
return (
<div className="grid gap-4">
<ToolCall
key={runId}
name="search_files"
status={state}
durationMs={state === "pending" ? undefined : elapsedMs}
input={input}
output={
isFinished ? (
<p>
2 matches in <code>0004_add_email_index.sql</code>
</p>
) : undefined
}
defaultOpen
/>
{isFinished ? <RestartButton onClick={restart} /> : null}
</div>
)
}
export function ToolCallDemo() {
return (
<DemoVariants
label="Tool call state"
variants={[
{ id: "live", label: "Live run", render: () => <LiveRun /> },
{
id: "queued",
label: "Queued",
render: () => (
<ToolCall name="search_files" status="pending" input={input} />
),
},
{
id: "error",
label: "Failed",
render: () => (
<ToolCall
name="write_file"
status="error"
input={{ path: "migrations/0004_add_email_index.sql" }}
error="Permission denied. The migrations directory is read-only."
durationMs={120}
defaultOpen
/>
),
},
]}
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/tool-callimport { ToolCall } from "mischief-ui/tool-call"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Check, ChevronRight, Loader, Circle, TriangleAlert, Wrench,} from "lucide-react"import { cn } from "@/lib/utils" export type ToolCallStatus = "pending" | "running" | "success" | "error"Usage
export function Search() {
return (
<ToolCall
name="web_search"
status="success"
input={{ query: "agent ui", limit: 5 }}
output={<p>Three matches.</p>}
durationMs={340}
/>
)
}The four states
A call moves through as many of these as it needs. Each one changes what is shown and is announced politely, naming the tool, so a reader who is not watching still learns what happened.
| Status | Shows |
|---|---|
pending | Queued. The input, and nothing that has happened yet. |
running | In flight, with a live duration if startedAt is set. |
success | The output, and the final duration. |
error | The failure message in place of the output. |
Pass startedAt while running and the duration counts up on its own; pass durationMs once it settles and that fixed figure is shown instead. Setting neither is fine -- the call simply reports no timing.
Input and output
Input is rendered for you: an object is formatted as JSON, a string is shown as it is. Output is not, because only you know whether the result is a table, a paragraph, or three files. Render it and pass it in.
<ToolCall
name="search_files"
status="success"
input={{ pattern: "nullable email", path: "migrations/" }}
output={<FileTree nodes={matches} />}
durationMs={340}
/>There is no syntax highlighting on the input, and no dependency that would provide it. Keep what you pass small enough to read: the arguments that decide what the call did, not everything that was in scope.
API
namestringThe tool name shown in the header.status"pending" | "running" | "success" | "error"The current phase. Defaults to "pending".inputunknownRendered as formatted JSON, or as-is when it is a string.outputReactNodeWhatever the tool returned, rendered by you.errorstringA failure message shown inside the panel.startedAtnumberEpoch milliseconds. Drives a live duration while running.durationMsnumberThe final duration once the call settles.iconReactNodeReplaces the default tool icon.open, defaultOpen, onOpenChangeboolean, boolean, (open: boolean) => voidControls the detail disclosure.Accessibility
Status changes are announced through a polite status region naming the tool. The disclosure is a native button with aria-expanded and aria-controls, and its accessible name says which tool it belongs to. Input is rendered as plain preformatted text in a horizontally scrollable region, with no syntax highlighting and no extra dependency.