- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Code Block
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Form
- Hover Card
- Input
- Input Group
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Pin Input
- Popover
- Progress
- Questionnaire
- Radio Group
- Range Calendar
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Stepper
- Switch
- Table
- Tabs
- Tags Input
- Textarea
- Toast
- Toggle
- Toggle Group
- Tooltip
- Trace
- Typography
> acme-chat@0.1.0 build
> nuxt build
Nuxt 4.2.0 with Nitro 2.12.0
ℹ Building client...
✔ Client built in 4210ms
ℹ Building server...
✔ Server built in 1180ms
✔ Generated public .output/public
├─ .output/server/chunks/routes/index.mjs (1.9 kB)
└─ .output/server/chunks/routes/api/chat.post.mjs (141 B)Lint and tests pass, and the build compiled both routes.
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, ref } from 'vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import { Message, MessageContent } from '@/components/ui/message'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
interface ToolPart {
toolCallId: string
state: 'input-available' | 'output-available' | 'output-error'
input: { command: string }
output?: { exitCode: number }
errorText?: string
}
const QUESTION = 'Run the checks, then build the app.'
const ANSWER = 'Lint and tests pass, and the build compiled both routes.'
const COMMANDS = [
{
id: 'lint',
command: 'pnpm lint',
output: ['> acme-chat@0.1.0 lint', '> eslint .'],
},
{
id: 'test',
command: 'pnpm test',
output: [
'> acme-chat@0.1.0 test',
'> vitest run',
'',
' ✓ utils/ai.test.ts (6 tests) 41ms',
' ✓ components/Chat.test.ts (9 tests) 212ms',
'',
' Test Files 2 passed (2)',
' Tests 15 passed (15)',
],
},
{
id: 'build',
command: 'pnpm build',
output: [
'> acme-chat@0.1.0 build',
'> nuxt build',
'',
'Nuxt 4.2.0 with Nitro 2.12.0',
'ℹ Building client...',
'✔ Client built in 4210ms',
'ℹ Building server...',
'✔ Server built in 1180ms',
'',
'✔ Generated public .output/public',
'├─ .output/server/chunks/routes/index.mjs (1.9 kB)',
'└─ .output/server/chunks/routes/api/chat.post.mjs (141 B)',
],
},
]
function finalTools(): ToolPart[] {
return COMMANDS.map(({ id, command }) => ({
toolCallId: id,
state: 'output-available',
input: { command },
output: { exitCode: 0 },
}))
}
function finalOutputs() {
return Object.fromEntries(COMMANDS.map(({ id, output }) => [id, output.join('\n')]))
}
const tools = ref<ToolPart[]>(finalTools())
// The data-terminal parts, keyed by the tool call id.
const outputs = ref<Record<string, string>>(finalOutputs())
const text = ref(ANSWER)
const busy = ref(false)
// Open the last command when the transcript is not streaming.
const openState = ref<Record<string, boolean>>({ build: true })
function getTitle(part: ToolPart, running: boolean) {
const command = part.input.command
if (part.state === 'output-available') {
return part.output?.exitCode === 0
? `Ran ${command}`
: `${command} exited with code ${part.output?.exitCode}`
}
return running ? `Running ${command}` : `Failed to run ${command}`
}
const rows = computed(() =>
tools.value.map((part) => {
const output = outputs.value[part.toolCallId] ?? ''
const running = busy.value && part.state === 'input-available'
const failed
= !running
&& (part.state !== 'output-available' || part.output?.exitCode !== 0)
return {
id: part.toolCallId,
title: getTitle(part, running),
status: running ? 'running' as const : failed ? 'error' as const : undefined,
open: openState.value[part.toolCallId] ?? (running && output.length > 0),
output: part.state === 'output-error' ? part.errorText : output,
}
}),
)
// Unmounting bumps `run`, so a running script stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)
async function wait(ms: number, id: number) {
await promiseTimeout(ms)
return id === run
}
async function replay() {
const id = ++run
busy.value = true
tools.value = []
outputs.value = {}
text.value = ''
openState.value = {}
for (const { id: toolCallId, command, output } of COMMANDS) {
tools.value.push({ toolCallId, state: 'input-available', input: { command } })
// The tool streams its output as a data part with the tool call's id.
for (let index = 0; index < output.length; index++) {
outputs.value[toolCallId] = output.slice(0, index + 1).join('\n')
if (!(await wait(200, id)))
return
}
const part = tools.value.find(tool => tool.toolCallId === toolCallId)!
part.state = 'output-available'
part.output = { exitCode: 0 }
}
for (const word of ANSWER.split(/(?<= )/)) {
if (!(await wait(20, id)))
return
text.value += word
}
busy.value = false
}
</script>
<template>
<div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ QUESTION }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message v-if="rows.length > 0 || text">
<MessageContent class="gap-4">
<Trace v-if="rows.length > 0">
<TraceCollapsible
v-for="row in rows"
:key="row.id"
:open="row.open"
@update:open="openState[row.id] = $event"
>
<TraceCollapsibleTrigger>
<TraceLine :status="row.status">
<TraceIcon>
<TerminalIcon />
</TraceIcon>
<TraceTitle>{{ row.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap class="max-h-64">
{{ row.output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<p v-if="text">
{{ text }}
</p>
</MessageContent>
</Message>
<Button
v-if="!busy"
variant="outline"
size="sm"
class="mt-auto self-start"
@click="replay"
>
Replay
</Button>
</div>
</template>Terminal is a shell command the agent runs, such as a lint, a test run or a build. The line shows the command, and its output streams in underneath while it runs.
Terminal is built with Trace with a TraceCollapsible for each command.
Installation
Terminal is built with the Trace and Code Block components.
pnpm dlx shadcn-vue@latest add trace code-block
Usage
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
defineProps<{ output: string }>()
</script>
<template>
<Trace>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<TerminalIcon />
</TraceIcon>
<TraceTitle>Ran pnpm build</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap class="max-h-64">
{{ output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template>Exit Code
A command that exits with any code other than 0 failed. Set status="error".
> acme-chat@0.1.0 lint
> eslint .
server/api/chat.post.ts
14:7 error 'model' is assigned a value but never used no-unused-vars
components/Chat.vue
42:5 error 'messages' is defined but never used vue/no-unused-vars
✖ 2 problems (2 errors, 0 warnings)
ELIFECYCLE Command failed with exit code 1.<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
const LINT_OUTPUT = `> acme-chat@0.1.0 lint
> eslint .
server/api/chat.post.ts
14:7 error 'model' is assigned a value but never used no-unused-vars
components/Chat.vue
42:5 error 'messages' is defined but never used vue/no-unused-vars
✖ 2 problems (2 errors, 0 warnings)
ELIFECYCLE Command failed with exit code 1.`
</script>
<template>
<Trace class="max-w-sm">
<TraceLine>
<TraceIcon>
<TerminalIcon />
</TraceIcon>
<TraceTitle>Ran pnpm install</TraceTitle>
</TraceLine>
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine status="error">
<TraceIcon>
<TerminalIcon />
</TraceIcon>
<TraceTitle>pnpm lint exited with code 1</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap class="max-h-64">
{{ LINT_OUTPUT }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template>| Command | status | Title |
|---|---|---|
| Running | "running" | Running pnpm build |
Exited with 0 | none | Ran pnpm build |
| Exited with another code | "error" | pnpm lint exited with code 1 |
The command is a tool call, so its states map the same way as in Tool.
Open While Running
Open the line once the first output arrives, so the output is visible while the command runs. Leave it open or closed after that, as in Trace.
AI SDK
A tool result arrives only once the command exits. To stream the output while it runs, write it from the tool as a data part with the tool call's id. The AI SDK replaces a data part that has the same id, so the output grows in place.
writer.write({
type: 'data-terminal',
id: toolCallId,
data: { output },
})On the client, find the data part that matches each tool part.
function getOutput(message: ChatMessage, toolCallId: string) {
for (const part of message.parts) {
if (part.type === 'data-terminal' && part.id === toolCallId) {
return part.data.output
}
}
return ''
}Migrating from AI Elements Vue
If you added Terminal from AI Elements Vue, your project has a components/ai-elements/terminal/ folder. Paste this prompt into your coding agent to rebuild it on Trace and Code Block. Every export keeps its name and props, so the code that uses it keeps working.
Migrate components/ai-elements/terminal/ from AI Elements Vue to the shadcn-vue Trace and Code Block components.
Keep every export, with the same name and props, so the code that imports it keeps working. Only change how each part renders.
1. Install the components: npx shadcn-vue@latest add trace code-block
2. Read https://www.shadcn-vue.com/docs/ai-elements/terminal and build Terminal the same way.
3. Rebuild each part:
- Import CodeBlock and CodeBlockContent from @/components/ui/code-block, not from ../code-block.
- Terminal renders a Trace with a TraceCollapsible. Keep output, isStreaming, autoScroll and the clear event.
- TerminalHeader renders a TraceCollapsibleTrigger with a TraceLine. Set status="running" while isStreaming is true.
- TerminalTitle renders a TraceIcon with a TerminalIcon and a TraceTitle, like "Ran pnpm build".
- TerminalContent renders a TraceCollapsibleContent with a CodeBlock with size="sm", and a CodeBlockContent with wrap and class="max-h-64" for the output.
- Open the line once output arrives while the command runs.
- Buttons cannot sit inside a trigger. Render TerminalStatus, TerminalActions and the copy and clear buttons next to the TraceCollapsibleTrigger, not inside it.
4. Remove the ANSI renderer from the dependencies if nothing else uses it.
5. Remove the imports the files no longer use.
6. Run vue-tsc and fix any errors.API Reference
See Trace.
Sponsors
Your logo hereshadcn-vue is free and open source. Sponsorship keeps it maintained.
Become a sponsor