ChatTool
Usage
The ChatTool component renders a collapsible block that displays AI tool invocation status, such as "Searching components" or "Reading documentation". When a default slot is provided, it becomes collapsible to reveal tool output.
$ pnpm run lint > eslint . ✔ No lint errors found.
<script setup lang="ts">
const streaming = ref(true)
const result = ref(`$ pnpm run lint
> eslint .
✔ No lint errors found.
`)
let timer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
timer = setTimeout(() => {
streaming.value = false
}, 5000)
})
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<template>
<PChatTool
:text="streaming ? 'Running lint checks' : 'Lint checks completed'"
suffix="cd, pnpm run"
:streaming="streaming"
icon="i-lucide-terminal"
variant="card"
chevron="leading"
class="w-80"
>
<pre language="bash" v-text="result" />
</PChatTool>
</template><script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const streaming = ref(true)
const result = ref(`$ pnpm run lint
> eslint .
✔ No lint errors found.
`)
let timer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
timer = setTimeout(() => {
streaming.value = false
}, 5000)
})
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<template>
<PChatTool
:text="streaming ? 'Running lint checks' : 'Lint checks completed'"
suffix="cd, pnpm run"
:streaming="streaming"
icon="i-lucide-terminal"
variant="card"
chevron="leading"
class="w-80"
>
<pre language="bash" v-text="result" />
</PChatTool>
</template>Text
Use the text prop to set the tool status text.
<template>
<PChatTool text="Searched components" />
</template>Suffix
Use the suffix prop to display secondary text after the main label.
<template>
<PChatTool text="Reading component" suffix="Button" />
</template>Streaming
Use the streaming prop to indicate the tool is actively running. The text displays a shimmer animation.
<template>
<PChatTool streaming text="Searching components..." />
</template>isToolStreaming utility from pohon-ui/utils/ai to determine if a tool part is still running. It returns false when the tool is waiting for a user approval.Shimmer
When streaming, the trigger label uses the ChatShimmer component. Use the shimmer prop to customize its duration and spread.
<template>
<PChatTool
streaming
text="Searching components..."
:shimmer="{
duration: 2,
spread: 2
}"
/>
</template>Icon
Use the icon prop to display an Icon component next to the trigger.
<template>
<PChatTool icon="i-lucide-search" text="Searched components" />
</template>Loading
Use the loading prop to show a loading indicator. Use the loading-icon prop to customize the loading icon.
<template>
<PChatTool loading text="Searching components..." />
</template>Loading Icon
Use the loading-icon prop to customize the loading icon. Defaults to i-lucide-loader-circle.
<template>
<PChatTool loading loading-icon="i-lucide-loader" text="Searching components..." />
</template>Chevron
Use the chevron prop to change the position of the chevron icon.
chevron is set to leading with an icon, the icon swaps with the chevron on hover and when open.<template>
<PChatTool chevron="leading" icon="i-lucide-search" text="Searched components">
Tool output content
</PChatTool>
</template>Chevron Icon
Use the chevron-icon prop to customize the chevron Icon. Defaults to i-lucide-chevron-down.
<template>
<PChatTool chevron-icon="i-lucide-arrow-down" text="Searched components">
Tool output content
</PChatTool>
</template>Variant
Use the variant prop to change the visual style. Defaults to inline.
<template>
<PChatTool variant="card" text="Searched components" icon="i-lucide-search" chevron="trailing">
Tool output content
</PChatTool>
</template>Actions
Use the actions prop to display a list of Button below the trigger, useful for tools that require a user confirmation before running.
<template>
<PChatTool
:actions="[
{
label: 'Approve'
},
{
label: 'Deny',
color: 'neutral',
variant: 'soft'
}
]"
text="Run terminal command"
variant="card"
icon="i-lucide-terminal"
>
$ pnpm run lint
</PChatTool>
</template>Examples
With approval flow
Use the actions prop to build a tool approval flow with the AI SDK. When a tool part is in the approval-requested state, display the approve and deny actions and respond with addToolApprovalResponse.
$ pnpm run lint
<script setup lang="ts">
import type { ButtonProps } from 'pohon-ui'
const state = ref<'approval-requested' | 'output-available' | 'output-denied'>('approval-requested')
const result = ref('')
const text = computed(() => {
if (state.value === 'approval-requested') {
return 'Run terminal command'
}
if (state.value === 'output-denied') {
return 'Command cancelled'
}
return result.value ? 'Ran terminal command' : 'Running terminal command'
})
const output = computed(() => result.value || '$ pnpm run lint')
const actions = computed<Array<ButtonProps> | undefined>(() => {
if (state.value !== 'approval-requested') {
return undefined
}
return [
{ label: 'Approve', onClick: onApprove },
{ label: 'Deny', color: 'neutral', variant: 'soft', onClick: onDeny }
]
})
let timer: ReturnType<typeof setTimeout> | undefined
function onApprove() {
state.value = 'output-available'
timer = setTimeout(() => {
result.value = `$ pnpm run lint
> eslint .
✔ No lint errors found.
`
}, 2000)
}
function onDeny() {
state.value = 'output-denied'
}
function reset() {
clearTimeout(timer)
state.value = 'approval-requested'
result.value = ''
}
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<PChatTool
:text="text"
icon="i-lucide-terminal"
variant="card"
:streaming="state === 'output-available' && !result"
:actions="actions"
class="w-80"
>
<pre language="bash" v-text="output" />
</PChatTool>
<PButton
v-if="state !== 'approval-requested'"
label="Reset"
color="neutral"
variant="link"
size="xs"
icon="i-lucide-rotate-ccw"
class="p-0 right-4 top-4 absolute"
@click="reset"
/>
</div>
</template><script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import type { ButtonProps } from 'pohon-ui'
const state = ref<'approval-requested' | 'output-available' | 'output-denied'>('approval-requested')
const result = ref('')
const text = computed(() => {
if (state.value === 'approval-requested') {
return 'Run terminal command'
}
if (state.value === 'output-denied') {
return 'Command cancelled'
}
return result.value ? 'Ran terminal command' : 'Running terminal command'
})
const output = computed(() => result.value || '$ pnpm run lint')
const actions = computed<Array<ButtonProps> | undefined>(() => {
if (state.value !== 'approval-requested') {
return undefined
}
return [
{ label: 'Approve', onClick: onApprove },
{ label: 'Deny', color: 'neutral', variant: 'soft', onClick: onDeny }
]
})
let timer: ReturnType<typeof setTimeout> | undefined
function onApprove() {
state.value = 'output-available'
timer = setTimeout(() => {
result.value = `$ pnpm run lint
> eslint .
✔ No lint errors found.
`
}, 2000)
}
function onDeny() {
state.value = 'output-denied'
}
function reset() {
clearTimeout(timer)
state.value = 'approval-requested'
result.value = ''
}
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<PChatTool
:text="text"
icon="i-lucide-terminal"
variant="card"
:streaming="state === 'output-available' && !result"
:actions="actions"
class="w-80"
>
<pre language="bash" v-text="output" />
</PChatTool>
<PButton
v-if="state !== 'approval-requested'"
label="Reset"
color="neutral"
variant="link"
size="xs"
icon="i-lucide-rotate-ccw"
class="p-0 right-4 top-4 absolute"
@click="reset"
/>
</div>
</template>isToolApprovalPending utility from pohon-ui/utils/ai to detect a pending approval, isToolStreaming returns false in this state.<script setup lang="ts">
import { useChat } from '@ai-sdk/vue';
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
const { messages, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses
});
</script>
<template>
<PChatTool
v-if="isToolUIPart(part)"
:text="getToolName(part)"
:streaming="isToolStreaming(part)"
:actions="part.state === 'approval-requested' ? [
{ label: 'Approve', onClick: () => addToolApprovalResponse({ id: part.approval.id, approved: true }) },
{ label: 'Deny', color: 'neutral', variant: 'ghost', onClick: () => addToolApprovalResponse({ id: part.approval.id, approved: false }) },
] : undefined"
/>
</template>
API
Props
| Prop | Default | Type |
|---|---|---|
text | stringThe text content to display. | |
suffix | stringThe suffix text displayed after the main text. | |
icon | anyThe icon displayed next to the trigger. | |
loading | false | boolean Whether the tool is in a loading state. |
loadingIcon | appConfig.ui.icons.loading | anyThe icon displayed when loading. |
streaming | false | boolean Whether the tool content is currently streaming. |
variant | 'inline' | "inline" | "card"The visual variant of the tool display. |
chevron | 'trailing' | "leading" | "trailing"The position of the chevron icon. |
chevronIcon | appConfig.ui.icons.chevronDown | anyThe icon displayed as the chevron. |
shimmer | Partial<Omit<ChatShimmerProps, "text">>Customize the | |
actions | ButtonProps[]Display a list of actions below the trigger, useful for tool approval flows.
| |
defaultOpen | boolean The open state of the collapsible when it is initially rendered. | |
open | boolean The controlled open state of the collapsible. Can be binded with | |
disabled | boolean When | |
unmountOnHide | boolean When | |
ui | { root?: SlotClass; trigger?: SlotClass; leading?: SlotClass; leadingIcon?: SlotClass; chevronIcon?: SlotClass; label?: SlotClass; suffix?: SlotClass; trailingIcon?: SlotClass; content?: SlotClass; body?: SlotClass; actions?: SlotClass; } |
Slots
| Slot | Type |
|---|---|
default | { open: boolean; } |
actions | {} |
Emits
| Event | Type |
|---|---|
update:open | [value: boolean] |
Theme
export default defineAppConfig({
ui: {
chatTool: {
slots: {
root: '',
trigger: [
'group flex w-full items-center gap-1.5 color-text-muted text-sm disabled:cursor-default disabled:hover:color-text-muted hover:color-text min-w-0',
'transition-colors'
],
leading: 'relative size-4 shrink-0',
leadingIcon: 'size-4 shrink-0',
chevronIcon: 'shrink-0 size-4 transition-transform-280 ease-out group-data-[state=open]:rotate-180 motion-reduce:transition-none',
label: 'truncate',
suffix: 'color-text-dimmed ms-1',
trailingIcon: 'shrink-0 size-4 transition-transform-280 ease-out group-data-[state=open]:rotate-180 motion-reduce:transition-none',
content: 'data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down data-[state=closed]:overflow-hidden',
body: 'text-sm color-text-dimmed whitespace-pre-wrap',
actions: 'flex items-center justify-end gap-1.5'
},
variants: {
variant: {
inline: {
trigger: 'rounded-sm outline-primary/25 focus-visible:outline-3',
body: 'pt-2',
actions: 'pt-2'
},
card: {
root: 'rounded-md ring ring-border overflow-hidden outline-primary/25 has-focus-visible:outline-3 has-focus-visible:ring-primary',
trigger: 'px-2 py-1 focus:outline-none',
trailingIcon: 'ms-auto',
body: 'border-t border-border p-2 max-h-[200px] overflow-y-auto focus:outline-none',
actions: 'border-t border-border p-2'
}
},
chevron: {
leading: '',
trailing: ''
},
loading: {
true: {
leadingIcon: 'animate-spin'
}
},
alone: {
false: {
leadingIcon: [
'inset-0 absolute group-data-[state=open]:opacity-0 group-hover:opacity-0',
'transition-opacity-280 ease-out'
],
chevronIcon: [
'opacity-0 inset-0 absolute group-data-[state=open]:opacity-100 group-hover:opacity-100',
'transition-[transform,opacity]-280 ease-out motion-reduce:transition-none'
]
}
}
}
}
}
})import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
chatTool: {
slots: {
root: '',
trigger: [
'group flex w-full items-center gap-1.5 color-text-muted text-sm disabled:cursor-default disabled:hover:color-text-muted hover:color-text min-w-0',
'transition-colors'
],
leading: 'relative size-4 shrink-0',
leadingIcon: 'size-4 shrink-0',
chevronIcon: 'shrink-0 size-4 transition-transform-280 ease-out group-data-[state=open]:rotate-180 motion-reduce:transition-none',
label: 'truncate',
suffix: 'color-text-dimmed ms-1',
trailingIcon: 'shrink-0 size-4 transition-transform-280 ease-out group-data-[state=open]:rotate-180 motion-reduce:transition-none',
content: 'data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down data-[state=closed]:overflow-hidden',
body: 'text-sm color-text-dimmed whitespace-pre-wrap',
actions: 'flex items-center justify-end gap-1.5'
},
variants: {
variant: {
inline: {
trigger: 'rounded-sm outline-primary/25 focus-visible:outline-3',
body: 'pt-2',
actions: 'pt-2'
},
card: {
root: 'rounded-md ring ring-border overflow-hidden outline-primary/25 has-focus-visible:outline-3 has-focus-visible:ring-primary',
trigger: 'px-2 py-1 focus:outline-none',
trailingIcon: 'ms-auto',
body: 'border-t border-border p-2 max-h-[200px] overflow-y-auto focus:outline-none',
actions: 'border-t border-border p-2'
}
},
chevron: {
leading: '',
trailing: ''
},
loading: {
true: {
leadingIcon: 'animate-spin'
}
},
alone: {
false: {
leadingIcon: [
'inset-0 absolute group-data-[state=open]:opacity-0 group-hover:opacity-0',
'transition-opacity-280 ease-out'
],
chevronIcon: [
'opacity-0 inset-0 absolute group-data-[state=open]:opacity-100 group-hover:opacity-100',
'transition-[transform,opacity]-280 ease-out motion-reduce:transition-none'
]
}
}
}
}
}
})
]
})