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 Reka UI. Each example runs with local data.

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.

ComponentTarget 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