Skip to content
AI Interfaces block

Tool Call Chips

Dense tool invocations that open into their arguments and result, with status carried by icon and word rather than colour, and errors marked as alerts only once the user opens them.

258 lineslucide-reactAdded 13 Aug 2026Updated 10 Sept 2026
  • tools
  • function calling
  • chips
  • logs
  • status

What's included

  • components/agent-tool-calls.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add agent-tool-calls

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

4 tool calls · 2 messages

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/agent-tool-calls.tsx
/**
 * <AgentToolCalls> — tool invocations as compact chips that open into their
 * arguments and result.
 *
 * A run makes a dozen calls and only two of them are interesting, so the
 * default has to be dense: name, a one-line argument summary, a duration, a
 * status. Everything else is one click away.
 *
 * What is worth lifting:
 *
 *  - The status is never colour alone. Each chip carries an icon *and* an
 *    `sr-only` word, because "which of these failed" answered only in red is
 *    not answered for roughly one man in twelve.
 *  - Arguments render as a definition list — `<dt>` name, `<dd>` value —
 *    rather than a table, since the shape is genuinely key/value and a table
 *    of two columns forces a reader through row-and-column navigation for
 *    what is really a list of pairs.
 *  - Errors are `role="alert"` only when the panel is opened by the user.
 *    Marking a failed chip as an alert while the list renders would fire
 *    every past failure at a screen reader on page load.
 *  - The whole list is one `aria-busy` container while anything is running,
 *    which is how a reader learns the run is not finished without a live
 *    region reciting each call.
 */

'use client'

import * as React from 'react'
import {
  AlertTriangle,
  Check,
  ChevronRight,
  Database,
  FileCode2,
  Globe,
  Loader2,
  Terminal,
} from 'lucide-react'

export type ToolStatus = 'ok' | 'error' | 'running'

export interface ToolCall {
  id: string
  name: string
  kind?: 'query' | 'http' | 'shell' | 'file'
  /** One-line argument summary shown on the collapsed chip. */
  preview: string
  args?: Record<string, string>
  result?: string
  status?: ToolStatus
  duration?: string
}

export interface AgentToolCallsProps {
  heading?: string
  calls?: ToolCall[]
  className?: string
}

const KIND_ICON = {
  query: Database,
  http: Globe,
  shell: Terminal,
  file: FileCode2,
} as const

const STATUS_LABEL = {
  ok: 'Succeeded',
  error: 'Failed',
  running: 'Running',
} as const

const DEFAULT_CALLS: ToolCall[] = [
  {
    id: '1',
    name: 'warehouse.query',
    kind: 'query',
    preview: 'select … from subscriptions where canceled_at …',
    args: {
      sql: 'select seat_band, sum(arr) from subscriptions where canceled_at between $1 and $2 group by 1',
      params: '["2025-07-01", "2025-09-30"]',
      timeout: '30s',
    },
    result: '3 rows · 214 ms',
    status: 'ok',
    duration: '2.7s',
  },
  {
    id: '2',
    name: 'files.read',
    kind: 'file',
    preview: 'pricing-handbook.md',
    args: { path: 'docs/pricing-handbook.md', range: 'L1–L240' },
    result: '9.4 KB read',
    status: 'ok',
    duration: '0.3s',
  },
  {
    id: '3',
    name: 'crm.search',
    kind: 'http',
    preview: 'GET /accounts?status=at_risk',
    args: { url: 'https://api.crm.internal/v2/accounts', query: 'status=at_risk&limit=100' },
    result: 'HTTP 429 — rate limited, retrying in 4s',
    status: 'error',
    duration: '1.2s',
  },
  {
    id: '4',
    name: 'sandbox.exec',
    kind: 'shell',
    preview: 'python analyse_cohorts.py',
    args: { command: 'python analyse_cohorts.py --window 90d', cwd: '/workspace' },
    status: 'running',
  },
]

export function AgentToolCalls({
  heading = '4 tool calls · 2 messages',
  calls = DEFAULT_CALLS,
  className = '',
}: AgentToolCallsProps) {
  const [open, setOpen] = React.useState<string[]>([])
  const busy = calls.some((c) => (c.status ?? 'ok') === 'running')
  const baseId = React.useId()

  function toggle(id: string) {
    setOpen((list) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]))
  }

  return (
    <div className={`mx-auto w-full max-w-2xl p-6 ${className}`}>
      <div
        aria-busy={busy}
        className="overflow-y-hidden rounded-2xl border border-border/60 bg-card"
      >
        <div className="flex items-center gap-2 border-b border-border/60 px-4 py-2.5">
          <Terminal aria-hidden className="h-3.5 w-3.5 text-muted-foreground" />
          {heading ? (
            <h3
              data-stress-ignore
              className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
            >
              {heading}
            </h3>
          ) : null}
        </div>

        <ul className="divide-y divide-border/40">
          {calls.map((call) => {
            const status = call.status ?? 'ok'
            const Icon = KIND_ICON[call.kind ?? 'query']
            const expanded = open.includes(call.id)
            const panelId = `${baseId}-${call.id}`

            return (
              <li key={call.id}>
                <button
                  type="button"
                  aria-expanded={expanded}
                  aria-controls={panelId}
                  onClick={() => toggle(call.id)}
                  className="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
                >
                  <ChevronRight
                    aria-hidden
                    className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${
                      // Closed points at the panel it opens — start edge, so
                      // it mirrors. Open points down, which RTL leaves alone.
                      expanded ? 'rotate-90' : 'rtl:rotate-180'
                    }`}
                  />

                  <Icon aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

                  <code className="shrink-0 font-mono text-xs font-semibold">{call.name}</code>

                  <span className="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">
                    {call.preview}
                  </span>

                  {call.duration ? (
                    <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
                      {call.duration}
                    </span>
                  ) : null}

                  <StatusMark status={status} />
                </button>

                {expanded ? (
                  <div id={panelId} className="space-y-3 bg-muted/30 px-4 pb-3.5 pt-1">
                    {call.args ? (
                      <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 pt-2">
                        {Object.entries(call.args).map(([key, value]) => (
                          <React.Fragment key={key}>
                            <dt className="font-mono text-[11px] text-muted-foreground">{key}</dt>
                            <dd className="min-w-0 break-words font-mono text-[11px]">{value}</dd>
                          </React.Fragment>
                        ))}
                      </dl>
                    ) : null}

                    {call.result ? (
                      <p
                        // Announced only because the user just opened this
                        // panel — never on first render.
                        role={status === 'error' ? 'alert' : undefined}
                        className={`rounded-lg border px-2.5 py-1.5 font-mono text-[11px] ${
                          status === 'error'
                            ? 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400'
                            : 'border-border/60 bg-background text-muted-foreground'
                        }`}
                      >
                        {call.result}
                      </p>
                    ) : null}

                    {status === 'running' && !call.result ? (
                      <p className="font-mono text-[11px] text-muted-foreground">
                        Waiting for output…
                      </p>
                    ) : null}
                  </div>
                ) : null}
              </li>
            )
          })}
        </ul>
      </div>
    </div>
  )
}

/**
 * Icon plus hidden word. The spinner degrades rather than stops under
 * reduced motion: a frozen spinner beside "Running" reads as a hung
 * process, which removes the feedback instead of the discomfort.
 */
function StatusMark({ status }: { status: ToolStatus }) {
  return (
    <span className="shrink-0">
      <span className="sr-only">{STATUS_LABEL[status]}</span>

      {status === 'ok' ? (
        <Check aria-hidden className="h-3.5 w-3.5 text-emerald-500" />
      ) : status === 'error' ? (
        <AlertTriangle aria-hidden className="h-3.5 w-3.5 text-rose-500" />
      ) : (
        <Loader2
          aria-hidden
          className="h-3.5 w-3.5 animate-spin text-primary motion-reduce:[animation-duration:2.4s]"
        />
      )}
    </span>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/agent-tool-calls.tsx and import it where you need the section:

import { AgentToolCalls } from '@/components/agent-tool-calls'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
headingstring'4 tool calls · 2 messages'
callsToolCall[]DEFAULT_CALLS
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

agent-tool-calls.html
<!--
  Tool Call Chips — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<div class="mx-auto w-full max-w-2xl p-6 ">
  <div aria-busy="true" class="overflow-y-hidden rounded-2xl border border-border/60 bg-card">
    <div class="flex items-center gap-2 border-b border-border/60 px-4 py-2.5">
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal h-3.5 w-3.5 text-muted-foreground" aria-hidden="true">
        <path d="M12 19h8"></path>
        <path d="m4 17 6-6-6-6"></path>
      </svg>
      <h3 data-stress-ignore="true" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">4 tool calls · 2 messages</h3>
    </div>
    <ul class="divide-y divide-border/40">
      <li>
        <button type="button" aria-expanded="false" aria-controls="_R_0_-1" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
            <path d="m9 18 6-6-6-6"></path>
          </svg>
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
            <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
            <path d="M3 5V19A9 3 0 0 0 21 19V5"></path>
            <path d="M3 12A9 3 0 0 0 21 12"></path>
          </svg>
          <code class="shrink-0 font-mono text-xs font-semibold">warehouse.query</code>
          <span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">select … from subscriptions where canceled_at …</span>
          <span class="shrink-0 font-mono text-[11px] text-muted-foreground">2.7s</span>
          <span class="shrink-0">
            <span class="sr-only">Succeeded</span>
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3.5 w-3.5 text-emerald-500" aria-hidden="true">
              <path d="M20 6 9 17l-5-5"></path>
            </svg>
          </span>
        </button>
      </li>
      <li>
        <button type="button" aria-expanded="false" aria-controls="_R_0_-2" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
            <path d="m9 18 6-6-6-6"></path>
          </svg>
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code2 lucide-file-code-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
            <path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4"></path>
            <path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
            <path d="m5 12-3 3 3 3"></path>
            <path d="m9 18 3-3-3-3"></path>
          </svg>
          <code class="shrink-0 font-mono text-xs font-semibold">files.read</code>
          <span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">pricing-handbook.md</span>
          <span class="shrink-0 font-mono text-[11px] text-muted-foreground">0.3s</span>
          <span class="shrink-0">
            <span class="sr-only">Succeeded</span>
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3.5 w-3.5 text-emerald-500" aria-hidden="true">
              <path d="M20 6 9 17l-5-5"></path>
            </svg>
          </span>
        </button>
      </li>
      <li>
        <button type="button" aria-expanded="false" aria-controls="_R_0_-3" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
            <path d="m9 18 6-6-6-6"></path>
          </svg>
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
            <circle cx="12" cy="12" r="10"></circle>
            <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
            <path d="M2 12h20"></path>
          </svg>
          <code class="shrink-0 font-mono text-xs font-semibold">crm.search</code>
          <span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">GET /accounts?status=at_risk</span>
          <span class="shrink-0 font-mono text-[11px] text-muted-foreground">1.2s</span>
          <span class="shrink-0">
            <span class="sr-only">Failed</span>
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-3.5 w-3.5 text-rose-500" aria-hidden="true">
              <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
              <path d="M12 9v4"></path>
              <path d="M12 17h.01"></path>
            </svg>
          </span>
        </button>
      </li>
      <li>
        <button type="button" aria-expanded="false" aria-controls="_R_0_-4" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
            <path d="m9 18 6-6-6-6"></path>
          </svg>
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
            <path d="M12 19h8"></path>
            <path d="m4 17 6-6-6-6"></path>
          </svg>
          <code class="shrink-0 font-mono text-xs font-semibold">sandbox.exec</code>
          <span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">python analyse_cohorts.py</span>
          <span class="shrink-0">
            <span class="sr-only">Running</span>
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle h-3.5 w-3.5 animate-spin text-primary motion-reduce:[animation-duration:2.4s]" aria-hidden="true">
              <path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
            </svg>
          </span>
        </button>
      </li>
    </ul>
  </div>
</div>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

6 more blocks in Agent Reasoning

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Agent Reasoning

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Agent Reasoning blocks

View category
Open the full page for this block

Collapsible Thinking Trace

The "thought for 5s" panel: a native details/summary outside, individually expandable steps inside — buttons rather than nested disclosures, which are announced as a disclosure within a disclosure.

Agent Reasoning256 lines1 dep
Open the full page for this block

Live Agent Task List

Run status as rows — done, running, failed, queued — with native progress elements, per-task retry buttons that name their task, and progress announced from one small status region instead of every row.

Agent Reasoning234 lines1 dep
Open the full page for this block

Working Indicator with Elapsed Time

The gap before the first token: a shimmer grid, a phase label that advances as the wait lengthens, and a running counter — which is the only honest answer to "is it stuck". The timer is hidden from the live region on purpose.

Agent Reasoning120 lines1 dep
Open the full page for this block

Failed Run Trace

The trace after something broke: retries counted in the open with what each one cost, a step that was silently worked around drawn in amber rather than green, and the raw error kept apart from the agent’s reading of it.

Agent Reasoning337 lines1 dep
Open the full page for this block

Agent Retry Log

What the agent tried, what failed, and what it did about it — the run history a user needs before they trust a second attempt.

Agent Reasoning153 linesNo deps
Open the full page for this block

Agent Cost Breakdown

What one run cost, per step, so an expensive answer is attributable rather than a monthly surprise.

Agent Reasoning149 linesNo deps