Installation
Complete project setup with react/aria, then install these components:
pnpm exec frontfriend download trace code-block icon button message bubble -f react --primitive aria 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
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
const [phase, setPhase] = React.useState(initial);
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
React.useEffect(() => {
clearTimeout(timer.current);
setPhase(initial);
}, [initial]);
React.useEffect(() => () => clearTimeout(timer.current), []);
function replay() {
clearTimeout(timer.current);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setPhase("complete");
return;
}
setPhase("running");
timer.current = setTimeout(() => setPhase("complete"), 3600);
}
return { phase, replay };
}
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
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 ✓ lib/ai.test.ts (6 tests) 41ms\n ✓ components/chat.test.tsx (9 tests) 212ms\n\n Test Files 2 passed (2)\n Tests 15 passed (15)",
},
{
id: "build",
followsRun: true,
failure: {
output:
"> next build\nBuild failed: missing API_URL.\nELIFECYCLE Command failed with exit code 1.",
exitCode: 1,
},
command: "pnpm build",
output:
"> next build\n\nCreating an optimized production build ...\n✓ Compiled successfully in 4.2s\n✓ Generating static pages (12/12)\n\nRoute (app) First Load JS\n┌ ○ / 112 kB\n└ ƒ /api/chat 102 kB",
},
];
export default function AiTerminalExample() {
const { phase, replay: run } = useRun("complete");
const [open, setOpen] = React.useState<Record<string, boolean>>({});
const [lineCount, setLineCount] = React.useState(1);
React.useEffect(() => {
if (phase !== "running") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setLineCount(20);
return;
}
setLineCount(1);
const timer = setInterval(
() => setLineCount((value) => Math.min(value + 1, 9)),
350,
);
return () => clearInterval(timer);
}, [phase]);
function replay() {
setOpen({});
run();
}
return (
<div style={frame}>
<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: 16, minWidth: 0 }}>
<Trace>
{commands.map((command) => {
const status =
phase === "pending"
? "pending"
: command.followsRun && phase !== "complete"
? phase
: undefined;
const output =
status === "error"
? (command.failure?.output ?? command.output)
: status === "running"
? command.output.split("\n").slice(0, lineCount).join("\n")
: command.output;
return (
<TraceCollapsible
key={command.id}
isExpanded={
open[command.id] ??
(status === "running" || status === "error")
}
onExpandedChange={(value) =>
setOpen((current) => ({
...current,
[command.id]: value,
}))
}
>
<TraceCollapsibleTrigger>
<TraceLine status={status}>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{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}`}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
{status !== "pending" && (
<CodeBlock size="sm">
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{output}
</CodeBlockContent>
</CodeBlock>
)}
</TraceCollapsibleContent>
</TraceCollapsible>
);
})}
</Trace>
{phase === "complete" && (
<p>Lint and tests pass, and the build compiled both routes.</p>
)}
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
onPress={replay}
isDisabled={phase === "running"}
>
Replay
</Button>
</div>
</div>
);
} 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 app/api/chat/route.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 },
});
}import type { UIMessage } from "ai";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
type SdkTerminalMessage = UIMessage<
unknown,
{
terminal: {
output: string;
};
},
{
runCommand: {
input: {
command: string;
};
output: {
stdout: string;
exitCode: number;
};
};
}
>;
type SdkCommandMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: {
command: string;
};
output: {
stdout: string;
exitCode: number;
};
};
}
>;
type SdkCommandPart = Extract<
SdkCommandMessage["parts"][number],
{
type: "tool-runCommand";
}
>;
function sdkCommandState(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 waiting = 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 && !waiting && !approved && !streaming);
const status = denied
? undefined
: waiting
? "pending"
: failed
? "error"
: finished
? undefined
: "running";
const title =
finished && part.output.exitCode !== 0
? `${command} exited with code ${part.output.exitCode}`
: `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
const output =
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.output.stdout
: undefined;
return { status, title, output } as const;
}
function SdkPanel({
title,
status,
children,
initialOpen = false,
}: {
title: string;
status?: "running" | "pending" | "error";
children: React.ReactNode;
initialOpen?: boolean;
}) {
const [open, setOpen] = React.useState<boolean>();
const expanded = open ?? initialOpen;
return (
<TraceCollapsible isExpanded={expanded} onExpandedChange={setOpen}>
<TraceCollapsibleTrigger>
<TraceLine status={status}>
<TraceIcon>
<Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
</TraceIcon>
<TraceTitle>{title}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>{children}</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function SdkCommandRow({
part,
streaming,
liveOutput,
}: {
part: SdkCommandPart;
streaming: boolean;
liveOutput?: string;
}) {
const view = sdkCommandState(part, streaming);
const output = view.output ?? liveOutput;
if (output !== undefined)
return (
<SdkPanel
title={view.title}
status={view.status}
initialOpen={view.status === "error" || view.status === "running"}
>
<CodeBlock
size="sm"
variant={view.status === "error" ? "destructive" : "default"}
>
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{output}
</CodeBlockContent>
</CodeBlock>
</SdkPanel>
);
return (
<TraceLine status={view.status}>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>{view.title}</TraceTitle>
</TraceLine>
);
}
function TerminalSdkAdapter({
message,
streaming,
}: {
message: SdkTerminalMessage;
streaming: boolean;
}) {
return (
<Trace>
{message.parts.map((part) => {
if (part.type !== "tool-runCommand") return null;
const live = message.parts.find(
(item) =>
item.type === "data-terminal" && item.id === part.toolCallId,
);
return (
<SdkCommandRow
key={part.toolCallId}
part={part}
streaming={streaming}
liveOutput={
live?.type === "data-terminal" ? live.data.output : undefined
}
/>
);
})}
</Trace>
);
}
const sdkMessage: SdkTerminalMessage = {
id: "build",
role: "assistant",
parts: [
{
type: "tool-runCommand",
toolCallId: "build-command",
state: "input-available",
input: { command: "pnpm build" },
},
{
type: "data-terminal",
id: "build-command",
data: {
output:
"> vite build\nBuilding client...\n✓ Client built in 4210ms\nBuilding server...",
},
},
],
};
export default function AiTerminalExample() {
return (
<div style={frame}>
<TerminalSdkAdapter message={sdkMessage} streaming />
</div>
);
} Error
import {
Trace,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
const namedTerminalErrorLINT_OUTPUT = `> acme-chat@0.1.0 lint
> eslint .
app/api/chat/route.ts
14:7 error 'model' is assigned a value but never used no-unused-vars
components/chat.tsx
42:5 error React Hook useEffect has a missing dependency: 'messages' react-hooks/exhaustive-deps
✖ 2 problems (2 errors, 0 warnings)
ELIFECYCLE Command failed with exit code 1.`;
function TerminalErrorExample() {
return (
<Trace>
<TraceLine>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Ran pnpm install</TraceTitle>
</TraceLine>
<TraceCollapsible defaultExpanded>
<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
style={{
maxHeight: 256,
}}
>
{namedTerminalErrorLINT_OUTPUT}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
);
}
export default function AiTerminalExample() {
return (
<div style={frame}>
<TerminalErrorExample />
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | React Aria API |
code-block | React Aria API |
icon | React Aria API |
button | React Aria API |
message | React Aria API |
bubble | React Aria API |