Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download trace code-block icon button message bubble item sheet -f vue --primitive reka Map input streaming to running, client input waiting to pending, output errors to error, and available output to a complete line. A pending approval uses Item outside Trace. Keep the approval ID with the response and accept only one decision. These examples do not execute commands.
The output sheet uses JSON syntax highlighting through the Code Block helpers. Plain command output does not load the tokenizer.
Usage
Approval
Save as ToolApprovalExample.vue.
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace";
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";
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from "@/components/ui/item";
const decision = ref<"pending" | "approved" | "denied">("pending");
const result = ref<HTMLParagraphElement | null>(null);
async function decide(value: "approved" | "denied") {
if (decision.value !== "pending") return;
decision.value = value;
await nextTick();
result.value?.focus();
}
</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 build before I ship this change.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Item v-if="decision === 'pending'" variant="outline" size="sm">
<ItemMedia variant="icon">
<Icon name="Terminal" />
</ItemMedia>
<ItemContent>
<ItemTitle>Run pnpm build?</ItemTitle>
<ItemDescription>The command runs in this project.</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm" variant="outline" @click="decide('denied')">
Deny
</Button>
<Button size="sm" @click="decide('approved')">Approve</Button>
</ItemActions>
</Item>
<Message v-else>
<MessageContent style="gap: 16px">
<Trace>
<TraceLine :status="decision === 'approved' ? 'running' : undefined">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{{
decision === "approved"
? "Running pnpm build"
: "Denied pnpm build"
}}
</TraceTitle>
</TraceLine>
</Trace>
<p ref="result" tabindex="-1">
{{
decision === "approved"
? "Build approved. The command is running."
: "Build cancelled. No command was run."
}}
</p>
</MessageContent>
</Message>
<div>
<Button size="sm" variant="ghost" @click="decision = 'pending'">
Reset
</Button>
</div>
</div>
</template> Output sheet
Save as ToolOutputSheetExample.vue.
<script setup lang="ts">
import { ref, watch } from "vue";
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace";
import {
CodeBlock,
CodeBlockContent,
CodeBlockLine,
CodeBlockToken,
} 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";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import {
highlightCode,
type HighlightedToken,
} from "@/components/ui/code-block/highlighter";
const sheetOpen = ref(false);
const lines = ref<HighlightedToken[][]>([]);
watch(sheetOpen, async (open, _, onCleanup) => {
if (!open || lines.value.length) return;
let active = true;
onCleanup(() => {
active = false;
});
const result = await highlightCode(value, "json");
if (active)
lines.value = result ?? value.split("\n").map((content) => [{ content }]);
});
const value =
'{\n "name": "checkout",\n "private": true,\n "scripts": {\n "build": "vite build",\n "test": "vitest run"\n }\n}';
</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>Which commands does this app provide?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style="gap: 16px">
<Trace>
<Sheet v-model:open="sheetOpen">
<SheetTrigger as-child>
<Button variant="ghost">
<TraceLine>
<TraceIcon>
<Icon name="FileText" />
</TraceIcon>
<TraceTitle>Read package.json</TraceTitle>
<TraceIcon>
<Icon name="ChevronRight" />
</TraceIcon>
</TraceLine>
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>package.json</SheetTitle>
<SheetDescription>
Build and test commands for this project.
</SheetDescription>
</SheetHeader>
<div style="min-width: 0; padding-block: 24px">
<CodeBlock size="sm">
<CodeBlockContent line-numbers wrap>
<template v-if="lines.length">
<CodeBlockLine
v-for="(line, index) in lines"
:key="index"
>
<CodeBlockToken
v-for="(token, tokenIndex) in line"
:key="tokenIndex"
:token="token"
/>
</CodeBlockLine>
</template>
<template v-else>{{ value }}</template>
</CodeBlockContent>
</CodeBlock>
</div>
</SheetContent>
</Sheet>
</Trace>
<p>The app provides build and test commands.</p>
</MessageContent>
</Message>
</div>
</template> Complete example
Save as ToolExample.vue.
<script setup lang="ts">
import { 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);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
phase.value = "complete";
return;
}
phase.value = "running";
timer = setTimeout(() => {
phase.value = "complete";
}, 3600);
}
const commands = [
{ command: "node --version", output: "v24.18.0" },
{ command: "npm --version", output: "11.6.2" },
{
command: "pnpm --version",
output: "command not found: pnpm",
failed: true,
},
];
</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>Which package managers are installed?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style="gap: 16px; min-width: 0">
<Trace>
<template v-for="(command, index) in commands" :key="command.command">
<TraceCollapsible
:unmount-on-hide="false"
v-if="
phase === 'complete' || (index === 0 && phase !== 'pending')
"
:default-open="command.failed"
>
<TraceCollapsibleTrigger>
<TraceLine :status="command.failed ? 'error' : undefined">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{{ command.failed ? "Failed to run" : "Ran" }}
{{ command.command }}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap style="max-height: 256px">
{{ command.output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine
v-else
:status="index === 2 && phase === 'running' ? 'pending' : phase"
>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{{
phase === "pending" || (index === 2 && phase === "running")
? "Waiting to run"
: phase === "error"
? "Failed to run"
: "Running"
}}
{{ command.command }}
</TraceTitle>
</TraceLine>
</template>
</Trace>
<p v-if="phase === 'complete'">
Node 24 and npm 11 are installed. pnpm is not.
</p>
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
:disabled="phase === 'running'"
@click="replay"
>
Replay
</Button>
</div>
</div>
</template> AI SDK
The adapter reads tool-runCommand parts and maps every input, approval, output, error, and denial state. Pass clientCallIds for client tools that remain input-available after streaming stops; they stay pending until addToolOutput supplies a result. Approval requests are Item cards outside Trace. Pass addToolApprovalResponse from your chat client. The adapter sends the approval ID once and permits retry if the callback rejects. Configure lastAssistantMessageIsCompleteWithApprovalResponses on your chat client to resume after all approvals are answered.
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.
Save as AiToolAdapter.vue.
<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref, watch, nextTick } 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";
import { Button } from "@/components/ui/button";
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from "@/components/ui/item";
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,
client = false,
) {
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" ||
(part.state === "input-available" && client);
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: `${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;
}
const props = withDefaults(
defineProps<{
message: SdkCommandMessage;
streaming: boolean;
clientCallIds?: string[];
addToolApprovalResponse: (response: {
id: string;
approved: boolean;
}) => void | PromiseLike<void>;
}>(),
{ clientCallIds: () => [] },
);
const parts = computed(() =>
props.message.parts.filter(
(part): part is SdkCommandPart => part.type === "tool-runCommand",
),
);
const rows = computed(() =>
parts.value
.filter((part) => part.state !== "approval-requested")
.map((part) => ({
id: part.toolCallId,
...commandState(
part,
props.streaming,
props.clientCallIds.includes(part.toolCallId),
),
})),
);
const submitted = ref<Record<string, boolean>>({}),
errors = ref<Record<string, string>>({});
const pending = new Set<string>();
const results = new Map<string, HTMLElement>();
const focusApproval = ref<string>();
watch(
() => props.message,
async (current) => {
if (!focusApproval.value) return;
const responded = current.parts.find(
(part) =>
part.type === "tool-runCommand" &&
part.state !== "approval-requested" &&
"approval" in part &&
part.approval?.id === focusApproval.value,
);
if (responded) {
await nextTick();
if (responded.type === "tool-runCommand")
results.get(responded.toolCallId)?.focus();
focusApproval.value = undefined;
}
},
);
async function approve(id: string, approved: boolean) {
if (pending.has(id)) return;
pending.add(id);
focusApproval.value = id;
submitted.value = { ...submitted.value, [id]: true };
errors.value = { ...errors.value, [id]: "" };
try {
await props.addToolApprovalResponse({ id, approved });
} catch (error) {
pending.delete(id);
focusApproval.value = undefined;
submitted.value = { ...submitted.value, [id]: false };
errors.value = {
...errors.value,
[id]: error instanceof Error ? error.message : String(error),
};
}
}
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;
"
>
<template v-for="part in parts" :key="part.toolCallId">
<Item
v-if="part.state === 'approval-requested'"
variant="outline"
size="sm"
>
<ItemMedia variant="icon">
<Icon name="Terminal" />
</ItemMedia>
<ItemContent>
<ItemTitle>Run {{ part.input.command }}?</ItemTitle>
<ItemDescription v-if="errors[part.approval.id]" role="alert">
{{ errors[part.approval.id] }}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
size="sm"
variant="outline"
:disabled="submitted[part.approval.id]"
@click="approve(part.approval.id, false)"
>
Deny
</Button>
<Button
size="sm"
:disabled="submitted[part.approval.id]"
@click="approve(part.approval.id, true)"
>
Approve
</Button>
</ItemActions>
</Item>
</template>
<Trace>
<div
v-for="row in rows"
:key="row.id"
role="group"
:tabindex="-1"
:aria-label="row.title"
:ref="
(element) => {
if (element) results.set(row.id, element as HTMLElement);
else results.delete(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>
</div>
</Trace>
</div>
</template> States
Save as ToolStatesExample.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 status = ` M server/api/chat.post.ts
M components/Chat.vue`;
</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>
<TraceCollapsible :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Ran git status --short</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap>
{{ status }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceCollapsible :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine status="error">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Failed to run pnpm dev</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap>
Command timed out after 120 seconds.
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Denied rm -rf dist</TraceTitle>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Running pnpm test</TraceTitle>
</TraceLine>
</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 |
item | Reka UI API |
sheet | Reka UI API |