{"slug":"terminal-stream","category":"code","job":"oversee","added":"2026-09-12T06:58Z","title":"Terminal Stream","description":"An auto-scrolling, collapsible panel streaming raw command output line by line.","url":"https://ai-patterns.app/patterns/code/terminal-stream","markdown":"https://ai-patterns.app/patterns/code/terminal-stream.md","json":"https://ai-patterns.app/api/patterns/code/terminal-stream","seenIn":[{"team":"Warp","source":"https://docs.warp.dev/agents/local-agents/agent-context/blocks-as-context"}],"spec":"# Terminal Stream\n\n## Summary\nAn 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.\n\n## When to use\n- 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).\n- The user should be able to collapse it out of the way once they trust it's just noise, without losing the output entirely.\n\n## When not to use\n- For a single tool call's status with no meaningful line-by-line output — use a Tool Call Chip instead.\n- 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.\n- 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.\n\n## Anatomy\n- Header bar: a terminal icon, the command itself (monospace), a status indicator (running / done / error), a line count, and a collapse toggle.\n- Log body: monospace lines, each tinted by level — default, info, warning, error, success.\n- A blinking cursor at the tail while still streaming.\n- A \"Jump to latest\" pill that appears only when the user has scrolled away from the bottom during an active stream.\n\n## Behavior\n- 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.\n- 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.\n- 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.\n- On completion, the header's status swaps to done or error; the log itself is never cleared or replaced, so scrollback stays available.\n\n## Content guidelines\n- Tint lines by their actual level — don't invent color meaning for lines that are all the same kind of output.\n- The header shows the literal command, not a paraphrase or summary of what it does.\n\n## Accessibility\n- 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.\n- The collapse toggle exposes `aria-expanded`.\n- The log body is a real scrollable, focusable region reachable and operable by keyboard, not just by mouse drag.\n- Status is conveyed by icon and text together, never color alone.\n\n## Related patterns\n- **Tool Call Chip** — Use it instead when only the fact of the call matters, and expand to Terminal Stream when its full output does.\n- **Diff Summary Card** — Use it instead once the command is done and the outcome is file edits rather than log lines.\n- **Console Error Card** — Use it instead when one browser error needs to be surfaced on its own rather than in a stream of output.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ArrowDown, Check, ChevronDown, CircleDot, Terminal, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface LogLine {\n  id: string;\n  text: string;\n  level?: \"default\" | \"info\" | \"warn\" | \"error\" | \"success\";\n}\n\nexport type TerminalStatus = \"running\" | \"done\" | \"error\";\n\nexport interface TerminalStreamProps {\n  command: string;\n  lines: LogLine[];\n  status: TerminalStatus;\n  defaultOpen?: boolean;\n  className?: string;\n}\n\nconst LEVEL_CLASS: Record<NonNullable<LogLine[\"level\"]>, string> = {\n  default: \"text-neutral-300\",\n  info: \"text-sky-400\",\n  warn: \"text-amber-400\",\n  error: \"text-red-400\",\n  success: \"text-emerald-400\",\n};\n\nexport function TerminalStream({\n  command,\n  lines,\n  status,\n  defaultOpen = true,\n  className,\n}: TerminalStreamProps) {\n  const [open, setOpen] = React.useState(defaultOpen);\n  const [pinnedToBottom, setPinnedToBottom] = React.useState(true);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    if (!open || !pinnedToBottom) return;\n    const el = bodyRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [lines, open, pinnedToBottom]);\n\n  function handleScroll() {\n    const el = bodyRef.current;\n    if (!el) return;\n    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;\n    setPinnedToBottom(atBottom);\n  }\n\n  function jumpToLatest() {\n    const el = bodyRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n    setPinnedToBottom(true);\n  }\n\n  return (\n    <div\n      className={cn(\n        \"w-full overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950 text-neutral-50\",\n        className\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={() => setOpen((v) => !v)}\n        aria-expanded={open}\n        className=\"flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-sm\"\n      >\n        <Terminal className=\"size-3.5 shrink-0 text-neutral-400\" aria-hidden />\n        <span className=\"min-w-0 flex-1 truncate font-mono text-xs text-neutral-200\">{command}</span>\n        <StatusBadge status={status} />\n        <span className=\"text-xs text-neutral-500\">{lines.length}</span>\n        <ChevronDown\n          className={cn(\"size-4 shrink-0 text-neutral-500 transition-transform\", open && \"rotate-180\")}\n        />\n      </button>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.2, ease: \"easeInOut\" }}\n            className=\"relative overflow-hidden\"\n          >\n            <div\n              ref={bodyRef}\n              onScroll={handleScroll}\n              role=\"log\"\n              aria-live={status === \"running\" ? \"polite\" : \"off\"}\n              className=\"max-h-64 overflow-y-auto px-3.5 py-2.5 font-mono text-xs leading-relaxed\"\n            >\n              {lines.map((line) => (\n                <p key={line.id} className={LEVEL_CLASS[line.level ?? \"default\"]}>\n                  {line.text}\n                </p>\n              ))}\n              {status === \"running\" && <BlinkingCursor />}\n            </div>\n\n            {!pinnedToBottom && (\n              <button\n                type=\"button\"\n                onClick={jumpToLatest}\n                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\"\n              >\n                <ArrowDown className=\"size-3\" aria-hidden /> Jump to latest\n              </button>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction StatusBadge({ status }: { status: TerminalStatus }) {\n  if (status === \"running\") {\n    return <CircleDot className=\"size-3.5 shrink-0 animate-pulse text-amber-400\" aria-label=\"Running\" />;\n  }\n  if (status === \"error\") {\n    return <X className=\"size-3.5 shrink-0 text-red-400\" aria-label=\"Failed\" />;\n  }\n  return <Check className=\"size-3.5 shrink-0 text-emerald-400\" aria-label=\"Done\" />;\n}\n\nfunction BlinkingCursor() {\n  return (\n    <motion.span\n      aria-hidden\n      className=\"inline-block h-3 w-[6px] translate-y-px bg-neutral-400\"\n      animate={{ opacity: [1, 1, 0, 0] }}\n      transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: \"linear\" }}\n    />\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}