AI Patterns

code

Terminal Stream

An auto-scrolling, collapsible panel streaming raw command output line by line.

"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ArrowDown, Check, ChevronDown, CircleDot, Terminal, X } from "lucide-react";

import { cn } from "@/lib/utils";

export interface LogLine {
  id: string;
  text: string;
  level?: "default" | "info" | "warn" | "error" | "success";
}

export type TerminalStatus = "running" | "done" | "error";

export interface TerminalStreamProps {
  command: string;
  lines: LogLine[];
  status: TerminalStatus;
  defaultOpen?: boolean;
  className?: string;
}

const LEVEL_CLASS: Record<NonNullable<LogLine["level"]>, string> = {
  default: "text-neutral-300",
  info: "text-sky-400",
  warn: "text-amber-400",
  error: "text-red-400",
  success: "text-emerald-400",
};

export function TerminalStream({
  command,
  lines,
  status,
  defaultOpen = true,
  className,
}: TerminalStreamProps) {
  const [open, setOpen] = React.useState(defaultOpen);
  const [pinnedToBottom, setPinnedToBottom] = React.useState(true);
  const bodyRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (!open || !pinnedToBottom) return;
    const el = bodyRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [lines, open, pinnedToBottom]);

  function handleScroll() {
    const el = bodyRef.current;
    if (!el) return;
    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
    setPinnedToBottom(atBottom);
  }

  function jumpToLatest() {
    const el = bodyRef.current;
    if (el) el.scrollTop = el.scrollHeight;
    setPinnedToBottom(true);
  }

  return (
    <div
      className={cn(
        "w-full overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950 text-neutral-50",
        className
      )}
    >
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-sm"
      >
        <Terminal className="size-3.5 shrink-0 text-neutral-400" aria-hidden />
        <span className="min-w-0 flex-1 truncate font-mono text-xs text-neutral-200">{command}</span>
        <StatusBadge status={status} />
        <span className="text-xs text-neutral-500">{lines.length}</span>
        <ChevronDown
          className={cn("size-4 shrink-0 text-neutral-500 transition-transform", open && "rotate-180")}
        />
      </button>

      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2, ease: "easeInOut" }}
            className="relative overflow-hidden"
          >
            <div
              ref={bodyRef}
              onScroll={handleScroll}
              role="log"
              aria-live={status === "running" ? "polite" : "off"}
              className="max-h-64 overflow-y-auto px-3.5 py-2.5 font-mono text-xs leading-relaxed"
            >
              {lines.map((line) => (
                <p key={line.id} className={LEVEL_CLASS[line.level ?? "default"]}>
                  {line.text}
                </p>
              ))}
              {status === "running" && <BlinkingCursor />}
            </div>

            {!pinnedToBottom && (
              <button
                type="button"
                onClick={jumpToLatest}
                className="absolute bottom-2.5 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full bg-neutral-800 px-2.5 py-1 text-xs text-neutral-200 shadow-sm transition-colors hover:bg-neutral-700"
              >
                <ArrowDown className="size-3" aria-hidden /> Jump to latest
              </button>
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function StatusBadge({ status }: { status: TerminalStatus }) {
  if (status === "running") {
    return <CircleDot className="size-3.5 shrink-0 animate-pulse text-amber-400" aria-label="Running" />;
  }
  if (status === "error") {
    return <X className="size-3.5 shrink-0 text-red-400" aria-label="Failed" />;
  }
  return <Check className="size-3.5 shrink-0 text-emerald-400" aria-label="Done" />;
}

function BlinkingCursor() {
  return (
    <motion.span
      aria-hidden
      className="inline-block h-3 w-[6px] translate-y-px bg-neutral-400"
      animate={{ opacity: [1, 1, 0, 0] }}
      transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: "linear" }}
    />
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Terminal Stream

## Summary
An auto-scrolling, collapsible panel that streams a command's raw output line by line, tinted by level (info, warning, error, success) — the closest thing in the catalogue to a real terminal, for when the full, unstructured output of a running process is worth showing.

## When to use
- An agent or tool is executing a shell command, build, test run, or deploy, and its raw output has real value beyond "it succeeded" (debugging a failure, watching a long build).
- The user should be able to collapse it out of the way once they trust it's just noise, without losing the output entirely.

## When not to use
- For a single tool call's status with no meaningful line-by-line output — use a Tool Call Chip instead.
- For output that's actually structured data (a diff, a table, a list of files) — render it as that shape (e.g. Diff Summary) rather than flattening it into log lines.
- As a permanent, always-expanded fixture once the command is long done — let it collapse to a compact header so it doesn't dominate the surrounding content.

## Anatomy
- Header bar: a terminal icon, the command itself (monospace), a status indicator (running / done / error), a line count, and a collapse toggle.
- Log body: monospace lines, each tinted by level — default, info, warning, error, success.
- A blinking cursor at the tail while still streaming.
- A "Jump to latest" pill that appears only when the user has scrolled away from the bottom during an active stream.

## Behavior
- New lines append at the bottom and the panel auto-scrolls to keep the latest line in view, as long as the user hasn't manually scrolled up.
- Scrolling up during an active stream pauses auto-scroll and reveals "Jump to latest"; clicking it snaps back to the bottom and resumes auto-scroll.
- Collapsing hides the log body but keeps the header — including a live line count and status — visible; output keeps accumulating in the background while collapsed.
- On completion, the header's status swaps to done or error; the log itself is never cleared or replaced, so scrollback stays available.

## Content guidelines
- Tint lines by their actual level — don't invent color meaning for lines that are all the same kind of output.
- The header shows the literal command, not a paraphrase or summary of what it does.

## Accessibility
- The log region is a live region only while actively streaming and expanded (`aria-live="polite"`) — throttle or batch announcements for high-volume output rather than announcing every line.
- The collapse toggle exposes `aria-expanded`.
- The log body is a real scrollable, focusable region reachable and operable by keyboard, not just by mouse drag.
- Status is conveyed by icon and text together, never color alone.

## Related patterns
- Tool Call Chip is the compact, single-call counterpart — reach for this pattern once that call's full output actually matters.
- Diff Summary is the finished-state counterpart specifically for file-edit output, once raw log lines aren't the right shape anymore.