AI Tool Call
One tool invocation, collapsed by default. It builds on Nuxt UI's ChatTool, so
the disclosure, the streaming shimmer and the card frame behave like every other
tool call on the page.
Status is a four-value enum — pending, running, success, error — rather
than the shared conversation vocabulary. A tool call has a lifecycle, not a
mood, and borrowing AIState here would imply a connection the data does not
have. The booleans ChatTool speaks are derived from it.
The badge is one ring that changes behaviour, not four icons that swap places:
pending breathes, running spins as a gap in the same ring, success keeps the
ring and draws a check inside it, error keeps the ring and draws a cross.
Because the ring never unmounts, the eye tracks a single object across the whole
lifecycle instead of watching icons pop in and out.
Pair it with AI Task List when the plan is the message and these are the detail.
Usage
Render one card per invocation with a name and a status, and mutate that
status as the tool progresses. Supply the args or result slot only when you
have something to show — the card is not expandable without them.
<template>
<div class="w-full max-w-lg space-y-2">
<MAiToolCall
v-for="call in calls"
:key="call.id"
:name="call.name"
:status="call.status"
:summary="call.summary"
>
<template #args>
<pre>{{ call.args }}</pre>
</template>
<template #result>
<pre>{{ call.result }}</pre>
</template>
</MAiToolCall>
</div>
</template>
<script setup lang="ts">
type Status = 'pending' | 'running' | 'success' | 'error'
type Call = {
args: string
id: string
name: string
result?: string
status: Status
summary?: string
}
const calls = ref<Call[]>([
{
id: 'c-1',
args: '{ "query": "theme token audit" }',
name: 'search_docs',
result: '4 documents',
status: 'success',
summary: '4 results',
},
{
id: 'c-2',
args: '{ "path": "src/runtime/components" }',
name: 'read_dir',
status: 'running',
summary: 'scanning',
},
{
id: 'c-3',
args: '{ "ref": "main" }',
name: 'create_branch',
status: 'pending',
},
])
const order: Status[] = ['pending', 'running', 'success']
onMounted(() => {
window.setInterval(() => {
calls.value = calls.value.map((call) => {
if (call.status === 'error' || call.status === 'success') {
return call
}
const index = order.indexOf(call.status)
const status = order[Math.min(index + 1, order.length - 1)]
return {
...call,
status,
summary: status === 'success' ? '0.9s' : call.summary,
result: status === 'success' ? (call.result ?? 'done') : call.result,
}
})
}, 1100)
})
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | - | Required. The tool name, rendered in a mono font — search_web,
read_dir. The font is deliberate: these are identifiers, not prose, and
setting them in the body face makes them read as English words. |
status | 'pending' | 'running' | 'success' | 'error' | 'pending' | The lifecycle state, and the source of the badge's behaviour. |
summary | string | - | A short trailing note — "3 files", "1.2s". The component does not parse it. |
defaultOpen | boolean | false | Start expanded. Leave it false for a live transcript: a tool call that opens
itself steals the reader's attention from the answer it is producing. |
class | string | '' | Merged onto the outer wrapper, not the card. |
Slots
| Slot | Props | Description |
|---|---|---|
args | - | The arguments, under a small ARGUMENTS heading in a monospace, horizontally
scrollable block. |
result | - | The result, under a RESULT heading. |
Supply neither slot and the card renders without a disclosure — there is nothing to expand, so a chevron leading to an empty panel would be a lie. Supply one or both and the card becomes expandable. Presence is checked per render rather than cached, so a slot supplied later still opens the disclosure.
The detail block sets unmount-on-hide, so a large tool result is removed from
the DOM when the card closes instead of sitting there invisible.
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiToolCall in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | |
status | - | sr-only |
tool | - | w-full |
detail | - | space-y-2 text-xs |
detailLabel | - | mb-1 text-[10px] uppercase tracking-wide text-muted |
args | - | overflow-x-auto font-mono text-highlighted |
result | - | overflow-x-auto text-highlighted |
label | - | font-mono text-xs font-medium text-highlighted |
leading | - | !size-5 |
leadingIcon | - | !static !inset-auto !size-5 !opacity-100 |
Accessibility
- Status is spelled out in an
sr-onlyspan —Queued,Running,Done,Failed— in the same place the sighted reader finds the ring. The ring is drawn, not announced, and the text carries it instead. - The SVG badge is
aria-hidden. It duplicates the status label rather than replacing it. - The ring's colour is never the only signal: pending, running, success and error differ in glyph and in behaviour as well as in hue.
successanderroruse green and red ring colours, but the check and cross are what distinguish them. Both marks are drawn on stroke, so they stay legible against any background the theme provides.- Under
prefers-reduced-motion: reducethe ring stops spinning and stops breathing, the dash change and the colour change are instant, and the check and cross appear fully drawn rather than animating theirpathLength. - The trailing cross strokes are staggered by 60ms so they draw as two strokes of one mark. That stagger is motion-only and collapses to nothing under reduced motion.
- Nuxt UI's
ChatToolrenders the disclosure as a real button witharia-expanded; that behaviour is inherited rather than reimplemented.
Performance
- The badge component reads
props.statusinside its render function rather than resolving a component per status. Resolving a new component would tear the ring down and rebuild it on every tick, which is precisely the popping this design exists to avoid. ChatToolrenders its icon throughUIcon, which resolves a component as well as a name, so the ring is handed over directly with no wrapper element.- The three
uioverrides undo Nuxt UI's icon choreography rather than adding to it: the leading icon is designed to be swapped for a chevron on hover and to be absolutely positioned so that swap is free. Here the ring is the permanent marker and the chevron already lives on the trailing edge, so the icon keeps its own box and its own opacity. Overriding it is cheaper than fighting it. - The spinning group uses
transformBox: 'view-box'with an explicittransformOrigin, because an SVG element's rotation origin is otherwise resolved against the page rather than the ring. - The indefinite rotate and the pending opacity loop need Motion — there is no CSS equivalent that restarts without a visible jump.
isRunningandstatusLabelare computeds, so the ring's dash pattern and the hidden label are derived from onestatusread.