{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"command-output","type":"registry:component","title":"Command output","description":"A command the agent runs: the log streams in, folds away on success, and stays open on failure.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/command-output.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronRight, Square, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * COMMAND OUTPUT: a command the agent runs, and its log\n *\n *   running    $ pnpm test, \"running\" with seconds that roll, and\n *              Stop; the log streams in and follows the newest line\n *   success    the spinner blurs into a check that draws itself,\n *              \"running\" morphs to \"done\" and the time gains its\n *              tenths; Stop folds away as the chevron opens in, and\n *              the log folds away a beat later (open it any time)\n *   error      \"exit 1\" in red, the code rolling in; the log stays\n *              open with the failing lines marked and in view\n *   cancelled  stopped by you\n *\n * Pass the lines as they arrive; plain strings work, ANSI\n * colour codes are stripped, and lines that look like errors\n * are marked (or set level yourself).\n * ───────────────────────────────────────────────────────── */\n\nexport type CommandStatus = \"running\" | \"success\" | \"error\" | \"cancelled\";\n\nexport interface CommandLine {\n  text: string;\n  level?: \"info\" | \"error\" | \"warn\" | \"success\";\n}\n\nexport interface CommandOutputProps extends React.HTMLAttributes<HTMLDivElement> {\n  command: string;\n  status: CommandStatus;\n  lines: (string | CommandLine)[];\n  exitCode?: number;\n  /** When it started (ms); shows a live timer, then the duration. */\n  startedAt?: number;\n  endedAt?: number;\n  onStop?: () => void;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst TENTHS = { minimumFractionDigits: 1, maximumFractionDigits: 1 };\nconst TWO_DIGITS = { minimumIntegerDigits: 2, maximumFractionDigits: 0 };\nconst WHOLE = { maximumFractionDigits: 0 };\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst ANSI = /\\u001b\\[[0-9;]*[A-Za-z]/g;\nconst LOOKS_LIKE_ERROR = /(^|\\s)(error|err!|failed|fail|fatal|exception|✗|✕|×)(\\s|:|$)/i;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity ${show ? \"300ms\" : \"160ms\"} ${MORPH}, filter 300ms ${MORPH}`,\n      }}\n    >\n      <span className=\"flex min-w-0 items-center whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\n/** Seconds that roll up as they pass and minutes that slide open; a short finished run gains its tenths (\"1.8s\"). */\nfunction Elapsed({ ms, settled, reduced }: { ms: number; settled: boolean; reduced: boolean }) {\n  const total = Math.max(0, ms) / 1000;\n  const minutes = Math.floor(total / 60);\n  const tenths = settled && total < 10;\n  const seconds = minutes ? Math.floor(total % 60) : tenths ? Math.floor(total * 10) / 10 : Math.floor(total);\n  return (\n    <span className=\"inline-flex items-baseline\">\n      <Reveal show={minutes > 0} reduced={reduced}>\n        <NumberRoll value={minutes} suffix=\"m\" duration={500} />\n        &nbsp;\n      </Reveal>\n      <NumberRoll value={seconds} format={minutes ? TWO_DIGITS : tenths ? TENTHS : WHOLE} suffix=\"s\" direction=\"up\" duration={500} />\n    </span>\n  );\n}\n\nfunction DrawnCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-2.5\">\n      <path\n        d=\"M3 8.5 6.5 12 13 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"3\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 380ms ${MORPH} 120ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nexport function CommandOutput({\n  command,\n  status,\n  lines,\n  exitCode,\n  startedAt,\n  endedAt,\n  onStop,\n  open: openProp,\n  onOpenChange,\n  className = \"\",\n  ...props\n}: CommandOutputProps) {\n  const reduced = useReducedMotion();\n  const running = status === \"running\";\n  const [now, setNow] = React.useState(startedAt ?? 0);\n  const [userOpen, setUserOpen] = React.useState<boolean | null>(null);\n  const [settled, setSettled] = React.useState(status === \"success\");\n  const logRef = React.useRef<HTMLDivElement>(null);\n  const firstError = React.useRef<HTMLDivElement | null>(null);\n  const follow = React.useRef(true);\n  const stopRef = React.useRef<HTMLButtonElement>(null);\n  const toggleRef = React.useRef<HTMLButtonElement>(null);\n  const id = React.useId();\n\n  // Stop folds away when the run ends; if it had focus, hand it to the chevron that takes its place.\n  React.useLayoutEffect(() => {\n    if (!running && document.activeElement === stopRef.current) toggleRef.current?.focus({ preventScroll: true });\n  }, [running]);\n\n  const rows = React.useMemo(\n    () =>\n      lines.map((line) => {\n        const l = typeof line === \"string\" ? { text: line } : line;\n        const text = l.text.replace(ANSI, \"\");\n        return { text, level: l.level ?? (LOOKS_LIKE_ERROR.test(text) ? \"error\" : \"info\") };\n      }),\n    [lines]\n  );\n\n  // Success folds the log a beat after the last line, so the end is seen before it goes.\n  React.useEffect(() => {\n    if (status !== \"success\") {\n      setSettled(false);\n      return;\n    }\n    const timer = window.setTimeout(() => setSettled(true), reduced ? 0 : 900);\n    return () => window.clearTimeout(timer);\n  }, [status, reduced]);\n\n  const open = openProp ?? userOpen ?? !(status === \"success\" ? settled : status === \"cancelled\");\n  const toggle = () => {\n    setUserOpen(!open);\n    onOpenChange?.(!open);\n  };\n\n  React.useEffect(() => {\n    if (!running || startedAt === undefined) return;\n    setNow(Date.now());\n    const timer = window.setInterval(() => setNow(Date.now()), 1000);\n    return () => window.clearInterval(timer);\n  }, [running, startedAt]);\n\n  // Follow the newest line while running, unless the reader scrolled up.\n  React.useLayoutEffect(() => {\n    const log = logRef.current;\n    if (log && follow.current) log.scrollTop = log.scrollHeight;\n  }, [rows.length]);\n\n  // On failure, bring the first failing line into view.\n  React.useEffect(() => {\n    if (status !== \"error\") return;\n    const log = logRef.current;\n    const line = firstError.current;\n    if (log && line) log.scrollTo({ top: Math.max(0, line.offsetTop - 16), behavior: reduced ? \"auto\" : \"smooth\" });\n  }, [status, reduced]);\n\n  const elapsedMs = startedAt === undefined ? null : running ? now - startedAt : endedAt !== undefined ? endedAt - startedAt : null;\n  const word = { running: \"running\", success: \"done\", error: \"exit\", cancelled: \"stopped\" }[status];\n  const showCode = status === \"error\" && exitCode !== undefined;\n  const canToggle = !running && rows.length > 0;\n  let errorSeen = false;\n\n  return (\n    <div className={`w-full rounded-[18px] border border-border bg-background p-1 ${className}`} {...props}>\n      <div className=\"flex h-9 items-center gap-2.5 pl-2.5 pr-1\">\n        <span aria-hidden=\"true\" className=\"relative flex size-4 shrink-0 items-center justify-center\">\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(running, reduced)}>\n            {/* Spins only while it's showing. */}\n            <span className={`size-3 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${running ? \"animate-spin\" : \"\"}`} />\n          </span>\n          <span\n            className=\"absolute inset-0 flex items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400\"\n            style={swap(status === \"success\", reduced)}\n          >\n            <DrawnCheck drawn={status === \"success\"} reduced={reduced} />\n          </span>\n          <span className=\"absolute inset-0 flex items-center justify-center rounded-full bg-red-500/15 text-red-600 dark:text-red-400\" style={swap(status === \"error\", reduced)}>\n            <X className=\"size-2.5\" strokeWidth={3} />\n          </span>\n          <span className=\"absolute inset-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground\" style={swap(status === \"cancelled\", reduced)}>\n            <Square className=\"size-2\" fill=\"currentColor\" />\n          </span>\n        </span>\n\n        <code className=\"min-w-0 flex-1 truncate font-mono text-[12.5px] text-foreground\">\n          <span className=\"select-none text-muted-foreground\">$ </span>\n          {command}\n        </code>\n\n        {/* One status: the word morphs, the exit code rolls in, the time rolls on and settles. */}\n        <span\n          aria-hidden=\"true\"\n          className={`flex shrink-0 items-baseline font-mono text-[11.5px] tabular-nums transition-colors duration-300 ${status === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}\n        >\n          <TextMorph>{word}</TextMorph>\n          {exitCode !== undefined && (\n            <Reveal show={showCode} reduced={reduced}>\n              &nbsp;\n              <NumberRoll value={showCode ? exitCode : 0} duration={600} />\n            </Reveal>\n          )}\n          {elapsedMs !== null && (\n            <span className={`pl-2 transition-colors duration-300 ${status === \"error\" ? \"text-muted-foreground\" : \"\"}`}>\n              <Elapsed ms={elapsedMs} settled={!running} reduced={reduced} />\n            </span>\n          )}\n        </span>\n        <span role=\"status\" className=\"sr-only\">\n          {running ? \"Running\" : status === \"success\" ? \"Finished\" : status === \"error\" ? `Failed${exitCode !== undefined ? ` with exit code ${exitCode}` : \"\"}` : \"Stopped\"}\n        </span>\n\n        {onStop && (\n          <Reveal show={running} reduced={reduced} className=\"shrink-0\">\n            <button\n              ref={stopRef}\n              type=\"button\"\n              inert={!running}\n              onClick={onStop}\n              aria-label=\"Stop command\"\n              className={`flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n            >\n              <Square className=\"size-2.5\" fill=\"currentColor\" />\n              Stop\n            </button>\n          </Reveal>\n        )}\n        {rows.length > 0 && (\n          <Reveal show={canToggle} reduced={reduced} className=\"shrink-0\">\n            <button\n              ref={toggleRef}\n              type=\"button\"\n              inert={!canToggle}\n              aria-expanded={open}\n              aria-controls={id}\n              aria-label={open ? \"Hide output\" : \"Show output\"}\n              onClick={toggle}\n              className={`flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n            >\n              <ChevronRight\n                className=\"size-3.5 transition-transform duration-300 motion-reduce:transition-none\"\n                style={{ transform: open ? \"rotate(90deg)\" : \"none\", transitionTimingFunction: EASE }}\n              />\n            </button>\n          </Reveal>\n        )}\n      </div>\n\n      <div\n        id={id}\n        inert={!open}\n        className=\"grid\"\n        style={{ gridTemplateRows: open && rows.length ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}` }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div\n            ref={logRef}\n            role=\"log\"\n            aria-label={`Output of ${command}`}\n            tabIndex={0}\n            onScroll={(e) => {\n              const el = e.currentTarget;\n              follow.current = el.scrollHeight - el.clientHeight - el.scrollTop < 24;\n            }}\n            className=\"max-h-56 overflow-y-auto overflow-x-hidden rounded-[14px] bg-muted/70 py-2.5 font-mono text-[11.5px] leading-[1.65] outline-none [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/30\"\n          >\n            {rows.map((row, i) => {\n              const isError = row.level === \"error\";\n              const ref = isError && !errorSeen ? ((errorSeen = true), firstError) : undefined;\n              return (\n                <div\n                  key={i}\n                  ref={ref}\n                  className={`whitespace-pre-wrap break-words px-3 animate-[ui-fade-in_160ms_ease-out_both] motion-reduce:animate-none ${\n                    isError\n                      ? \"bg-red-500/[0.08] text-red-700 dark:text-red-300\"\n                      : row.level === \"warn\"\n                        ? \"text-amber-700 dark:text-amber-300\"\n                        : row.level === \"success\"\n                          ? \"text-emerald-700 dark:text-emerald-400\"\n                          : \"text-foreground/75\"\n                  }`}\n                >\n                  {row.text || \" \"}\n                </div>\n              );\n            })}\n            {running && (\n              <div aria-hidden=\"true\" className=\"px-3\">\n                <span className=\"inline-block h-3.5 w-[7px] translate-y-[2px] bg-foreground/60 animate-[ui-blink_1s_steps(1)_infinite]\" />\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}},"@keyframes ui-blink":{"0%, 100%":{"opacity":"1"},"50%":{"opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/command-output"}