Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download trace code-block icon button message bubble -f vue --primitive reka Match each output update to its command ID. A nonzero exit code is an error. Open output when it first arrives and preserve the user’s disclosure choice. Replay uses fixed output and does not execute a shell command. Reduced motion shows the completed output at once.
Usage
Complete example
Save as TerminalExample.vue.
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from "vue";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
type Phase = "complete" | "running" | "error" | "pending";
const props = withDefaults(defineProps<{ state?: Phase }>(), {
state: "complete",
});
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
() => props.state,
(value) => {
clearTimeout(timer);
phase.value = value;
},
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
clearTimeout(timer);
expanded.value = {};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
phase.value = "complete";
return;
}
phase.value = "running";
timer = setTimeout(() => {
phase.value = "complete";
}, 3600);
}
const expanded = ref<Record<string, boolean>>({});
function expand(id: string, value: boolean) {
expanded.value = { ...expanded.value, [id]: value };
}
type Command = {
id: string;
command: string;
output: string;
followsRun?: boolean;
failure?: { output: string; exitCode: number };
};
const commands: Command[] = [
{
id: "lint",
command: "pnpm lint",
output: "> checkout@1.0.0 lint\n> eslint .",
},
{
id: "test",
command: "pnpm test",
output:
"> vitest run\n\n ✓ utils/ai.test.ts (6 tests) 41ms\n ✓ components/Chat.test.ts (9 tests) 212ms\n\n Test Files 2 passed (2)\n Tests 15 passed (15)",
},
{
id: "build",
followsRun: true,
failure: {
output:
"> nuxt build\nBuild failed: missing API_URL.\nELIFECYCLE Command failed with exit code 1.",
exitCode: 1,
},
command: "pnpm build",
output:
"> nuxt build\n\nBuilding client...\n✓ Client built in 4210ms\nBuilding server...\n✓ Server built in 1180ms\n\n✓ Generated public .output/public\n┌ routes/index.mjs (1.9 kB)\n└ routes/api/chat.post.mjs (141 B)",
},
];
const lineCount = ref(1);
watch(
phase,
(value, _, onCleanup) => {
if (value !== "running") return;
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
lineCount.value = 20;
return;
}
lineCount.value = 1;
const interval = setInterval(() => {
lineCount.value = Math.min(lineCount.value + 1, 10);
}, 350);
onCleanup(() => clearInterval(interval));
},
{ immediate: true },
);
const rows = computed(() =>
commands.map((command) => {
const status: "running" | "error" | "pending" | undefined =
phase.value === "pending"
? "pending"
: command.followsRun && phase.value !== "complete"
? phase.value
: undefined;
return {
...command,
status,
title:
status === "pending"
? `Waiting to run ${command.command}`
: status === "running"
? `Running ${command.command}`
: status === "error"
? `${command.command} exited with code ${command.failure?.exitCode ?? 0}`
: `Ran ${command.command}`,
output:
status === "error"
? (command.failure?.output ?? command.output)
: status === "running"
? command.output.split("\n").slice(0, lineCount.value).join("\n")
: command.output,
};
}),
);
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
min-width: 0;
min-height: 288px;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Run the checks, then build the app.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style="gap: 16px; min-width: 0">
<Trace>
<TraceCollapsible
:unmount-on-hide="false"
v-for="row in rows"
:key="row.id"
:open="
expanded[row.id] ??
(row.status === 'running' || row.status === 'error')
"
@update:open="expand(row.id, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="row.status">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>{{ row.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock v-if="row.status !== 'pending'" size="sm">
<CodeBlockContent wrap style="max-height: 256px">
{{ row.output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<p v-if="phase === 'complete'">
Lint and tests pass, and the build compiled both routes.
</p>
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
:disabled="phase === 'running'"
@click="replay"
>
Replay
</Button>
</div>
</div>
</template> AI SDK
The adapter joins each tool-runCommand part to a data-terminal part whose id equals toolCallId. Send data-terminal with data.output from the server writer while the command runs. Reuse the same id to replace its accumulated output. Final stdout and exitCode come from the tool result; output-error uses errorText.
The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:
pnpm add ai
Pass a message from your chat client to the adapter. The supplied message does not call a provider.
Server fragment for server/api/chat.post.ts.
Call this helper with accumulated output and the same tool call ID for each update.
import type { UIMessage, UIMessageStreamWriter } from "ai";
type TerminalMessage = UIMessage<unknown, { terminal: { output: string } }>;
export function writeTerminalOutput(writer: UIMessageStreamWriter<TerminalMessage>, toolCallId: string, output: string) {
writer.write({
type: "data-terminal",
id: toolCallId,
data: { output },
});
}
Save as AiTerminalAdapter.vue.
<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceTitle,
TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
type SdkCommandMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: { command: string };
output: { stdout: string; exitCode: number };
};
}
>;
type SdkCommandPart = Extract<
SdkCommandMessage["parts"][number],
{ type: "tool-runCommand" }
>;
function commandState(part: SdkCommandPart, streaming: boolean) {
const command = part.input?.command ?? "command";
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const finished = part.state === "output-available" && !part.preliminary;
const pending = part.state === "approval-requested";
const approved =
part.state === "approval-responded" && part.approval.approved;
const failed =
part.state === "output-error" ||
(finished && part.output.exitCode !== 0) ||
(!denied && !finished && !pending && !approved && !streaming);
const status = denied
? undefined
: pending
? "pending"
: failed
? "error"
: finished
? undefined
: "running";
return {
status,
title:
finished && part.output.exitCode !== 0
? `${command} exited with code ${part.output.exitCode}`
: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`,
output:
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.output.stdout
: undefined,
} as const;
}
type SdkTerminalMessage = UIMessage<
unknown,
{ terminal: { output: string } },
{
runCommand: {
input: { command: string };
output: { stdout: string; exitCode: number };
};
}
>;
const props = defineProps<{
message: SdkTerminalMessage;
streaming: boolean;
}>();
const rows = computed(() =>
props.message.parts
.filter((part): part is SdkCommandPart => part.type === "tool-runCommand")
.map((part) => {
const state = commandState(part, props.streaming);
const live = props.message.parts.find(
(item) => item.type === "data-terminal" && item.id === part.toolCallId,
);
return {
id: part.toolCallId,
...state,
output:
state.output ??
(live?.type === "data-terminal" ? live.data.output : undefined),
};
}),
);
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
open.value = { ...open.value, [id]: value };
}
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
min-width: 0;
margin-inline: auto;
display: grid;
gap: 16px;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Trace>
<template v-for="row in rows" :key="row.id">
<TraceCollapsible
:unmount-on-hide="false"
v-if="row.output !== undefined"
:open="
open[row.id] ?? (row.status === 'error' || row.status === 'running')
"
@update:open="setOpen(row.id, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="row.status">
<TraceIcon>
<Icon
:name="row.status === 'error' ? 'CircleAlert' : 'Terminal'"
/>
</TraceIcon>
<TraceTitle>{{ row.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock
size="sm"
:variant="row.status === 'error' ? 'destructive' : 'default'"
>
<CodeBlockContent wrap style="max-height: 256px">
{{ row.output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<template v-else>
<TraceLine :status="row.status">
<TraceIcon>
<Icon
:name="row.status === 'error' ? 'CircleAlert' : 'Terminal'"
/>
</TraceIcon>
<TraceTitle>{{ row.title }}</TraceTitle>
</TraceLine>
</template>
</template>
</Trace>
</div>
</template> Error
Save as TerminalErrorExample.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
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>
<div
style="
width: 100%;
max-width: 384px;
min-width: 0;
margin-inline: auto;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Trace>
<TraceLine>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Ran pnpm install</TraceTitle>
</TraceLine>
<TraceCollapsible :unmount-on-hide="false" default-open>
<TraceCollapsibleTrigger>
<TraceLine status="error">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>pnpm lint exited with code 1</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap>
{{ LINT_OUTPUT }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</div>
</template> Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | Reka UI API |
code-block | Reka UI API |
icon | Reka UI API |
button | Reka UI API |
message | Reka UI API |
bubble | Reka UI API |