Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Terminal

Shell commands with streaming output, exit status, and expandable command logs.

Interactive preview

The shared preview uses React Base. The source below uses Radix UI. Each example runs with local data.

Installation

Complete project setup with react/radix, then install these components:

pnpm exec frontfriend download trace code-block icon button message bubble -f react --primitive radix

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,
  TraceCollapsibleContent,
  TraceLine,
  TraceIcon,
  TraceTitle,
} 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",
  },
];
function TracePanel({
  trigger,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  ...props
}: React.ComponentProps<typeof TraceCollapsible> & {
  trigger: React.ReactNode;
}) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number>();
  const [localOpen, setLocalOpen] = React.useState(defaultOpen);
  const expanded = controlledOpen ?? localOpen;
  const [hasOpened, setHasOpened] = React.useState(expanded);
  React.useEffect(() => {
    if (expanded) setHasOpened(true);
  }, [expanded]);
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const inner = content?.firstElementChild;
    if (!expanded || !content || !inner) return;
    const measure = () => setHeight(content.scrollHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(inner);
    return () => observer.disconnect();
  }, [expanded]);
  return (
    <TraceCollapsible
      {...props}
      open={expanded}
      onOpenChange={(value) => {
        if (!value && contentRef.current) {
          setHeight(contentRef.current.getBoundingClientRect().height);
        }
        setLocalOpen(value);
        onOpenChange?.(value);
      }}
    >
      <TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent
        ref={contentRef}
        style={
          height === undefined
            ? undefined
            : ({
                "--radix-collapsible-content-height": `${height}px`,
              } as React.CSSProperties)
        }
        forceMount={hasOpened ? true : undefined}
        inert={!expanded}
      >
        {children}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
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 (
                <TracePanel
                  key={command.id}
                  open={
                    open[command.id] ??
                    (status === "running" || status === "error")
                  }
                  onOpenChange={(value) =>
                    setOpen((current) => ({
                      ...current,
                      [command.id]: value,
                    }))
                  }
                  trigger={
                    <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>
                  }
                >
                  {status !== "pending" && (
                    <CodeBlock size="sm">
                      <CodeBlockContent wrap style={{ maxHeight: 256 }}>
                        {output}
                      </CodeBlockContent>
                    </CodeBlock>
                  )}
                </TracePanel>
              );
            })}
          </Trace>
          {phase === "complete" && (
            <p>Lint and tests pass, and the build compiled both routes.</p>
          )}
        </MessageContent>
      </Message>
      <div>
        <Button
          size="sm"
          variant="outline"
          onClick={replay}
          disabled={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,
  TraceCollapsibleContent,
  TraceLine,
  TraceIcon,
  TraceTitle,
} 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 TracePanel({
  trigger,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  ...props
}: React.ComponentProps<typeof TraceCollapsible> & {
  trigger: React.ReactNode;
}) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number>();
  const [localOpen, setLocalOpen] = React.useState(defaultOpen);
  const expanded = controlledOpen ?? localOpen;
  const [hasOpened, setHasOpened] = React.useState(expanded);
  React.useEffect(() => {
    if (expanded) setHasOpened(true);
  }, [expanded]);
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const inner = content?.firstElementChild;
    if (!expanded || !content || !inner) return;
    const measure = () => setHeight(content.scrollHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(inner);
    return () => observer.disconnect();
  }, [expanded]);
  return (
    <TraceCollapsible
      {...props}
      open={expanded}
      onOpenChange={(value) => {
        if (!value && contentRef.current) {
          setHeight(contentRef.current.getBoundingClientRect().height);
        }
        setLocalOpen(value);
        onOpenChange?.(value);
      }}
    >
      <TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent
        ref={contentRef}
        style={
          height === undefined
            ? undefined
            : ({
                "--radix-collapsible-content-height": `${height}px`,
              } as React.CSSProperties)
        }
        forceMount={hasOpened ? true : undefined}
        inert={!expanded}
      >
        {children}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
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 (
    <TracePanel
      open={expanded}
      onOpenChange={setOpen}
      trigger={
        <TraceLine status={status}>
          <TraceIcon>
            <Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
          </TraceIcon>
          <TraceTitle>{title}</TraceTitle>
        </TraceLine>
      }
    >
      {children}
    </TracePanel>
  );
}
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,
};
function TracePanel({
  trigger,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  ...props
}: React.ComponentProps<typeof TraceCollapsible> & {
  trigger: React.ReactNode;
}) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number>();
  const [localOpen, setLocalOpen] = React.useState(defaultOpen);
  const expanded = controlledOpen ?? localOpen;
  const [hasOpened, setHasOpened] = React.useState(expanded);
  React.useEffect(() => {
    if (expanded) setHasOpened(true);
  }, [expanded]);
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const inner = content?.firstElementChild;
    if (!expanded || !content || !inner) return;
    const measure = () => setHeight(content.scrollHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(inner);
    return () => observer.disconnect();
  }, [expanded]);
  return (
    <TraceCollapsible
      {...props}
      open={expanded}
      onOpenChange={(value) => {
        if (!value && contentRef.current) {
          setHeight(contentRef.current.getBoundingClientRect().height);
        }
        setLocalOpen(value);
        onOpenChange?.(value);
      }}
    >
      <TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent
        ref={contentRef}
        style={
          height === undefined
            ? undefined
            : ({
                "--radix-collapsible-content-height": `${height}px`,
              } as React.CSSProperties)
        }
        forceMount={hasOpened ? true : undefined}
        inert={!expanded}
      >
        {children}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
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>
      <TracePanel
        defaultOpen
        trigger={
          <TraceLine status="error">
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>pnpm lint exited with code 1</TraceTitle>
          </TraceLine>
        }
      >
        <CodeBlock size="sm">
          <CodeBlockContent
            wrap
            style={{
              maxHeight: 256,
            }}
          >
            {namedTerminalErrorLINT_OUTPUT}
          </CodeBlockContent>
        </CodeBlock>
      </TracePanel>
    </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.

ComponentTarget reference
trace Radix UI API
code-block Radix UI API
icon Radix UI API
button Radix UI API
message Radix UI API
bubble Radix UI API