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** — Use it instead when only the fact of the call matters, and expand to Terminal Stream when its full output does.
- **Diff Summary Card** — Use it instead once the command is done and the outcome is file edits rather than log lines.
- **Console Error Card** — Use it instead when one browser error needs to be surfaced on its own rather than in a stream of output.
Do’s and don’ts
Do use it for
- 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.
Don’t use it for
- 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.
Related patterns
Tool Call Chip
Use it instead when only the fact of the call matters, and expand to Terminal Stream when its full output does.
Diff Summary Card
Use it instead once the command is done and the outcome is file edits rather than log lines.
Console Error Card
Use it instead when one browser error needs to be surfaced on its own rather than in a stream of output.