{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "terminal",
  "type": "registry:ui",
  "title": "Terminal",
  "description": "A session that types itself with human pacing, in space it already reserved.",
  "categories": [
    "developer"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/terminal.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Refresh } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type TerminalTone = \"default\" | \"muted\" | \"success\" | \"error\" | \"warning\" | \"info\";\n\nexport type TerminalLine =\n  /** Typed character by character, after the prompt. */\n  | { kind: \"command\"; text: string; cwd?: string }\n  /** Streams in after the command that produced it. */\n  | { kind: \"output\"; text: string; tone?: TerminalTone }\n  /** A spinner for `duration` ms, then a tick (or a cross) and the `done` text. */\n  | { kind: \"task\"; text: string; done?: string; duration?: number; failed?: boolean }\n  | { kind: \"blank\" };\n\nconst TONE: Record<TerminalTone, string> = {\n  default: \"text-fg-2\",\n  muted: \"text-fg-3\",\n  success: \"text-success\",\n  error: \"text-danger\",\n  warning: \"text-warning\",\n  info: \"text-info\",\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Pacing. Deterministic per line, so a replay types the same way and nothing random reaches render.\n * -----------------------------------------------------------------------------------------------*/\n\nfunction seeded(text: string) {\n  let h = 2166136261;\n  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);\n  return () => {\n    h = Math.imul(h ^ (h >>> 15), 2246822507);\n    h = Math.imul(h ^ (h >>> 13), 3266489909);\n    return ((h ^= h >>> 16) >>> 0) / 4294967296;\n  };\n}\n\n/** Milliseconds before each character of a command, the way a person types: bursts inside words, a beat at spaces and symbols. */\nfunction keystrokes(text: string, speed: number) {\n  const rand = seeded(text);\n  return [...text].map((ch, i) => {\n    let ms = 34 + rand() * 46;\n    if (ch === \" \") ms += 40 + rand() * 50;\n    if (/[-@/.=]/.test(ch)) ms += 30;\n    if (i > 0 && text[i - 1] === ch) ms *= 0.6; // double letters roll\n    if (rand() < 0.04) ms += 160; // the occasional hesitation\n    return ms / speed;\n  });\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Terminal\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TerminalProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  lines: TerminalLine[];\n  /** Window title, e.g. \"zsh — ~/storefront\". */\n  title?: string;\n  /** Prompt glyph before each command. */\n  prompt?: string;\n  /** Type and stream when it scrolls into view. Off renders the whole session at once. */\n  animate?: boolean;\n  /** Pacing multiplier: 2 is twice as fast. */\n  speed?: number;\n  /** Called once the last line has played. */\n  onDone?: () => void;\n};\n\ntype Pos = { line: number; chars: number };\n\nconst REDUCE = \"(prefers-reduced-motion: reduce)\";\nconst onReduceChange = (notify: () => void) => {\n  const m = window.matchMedia(REDUCE);\n  m.addEventListener(\"change\", notify);\n  return () => m.removeEventListener(\"change\", notify);\n};\n/** Reduced motion that hydrates as \"no preference\" and then corrects itself, because what renders (typed or complete) depends on it. */\nfunction useReduce() {\n  return useSyncExternalStore(onReduceChange, () => window.matchMedia(REDUCE).matches, () => false);\n}\n\nexport function Terminal({\n  lines,\n  title = \"Terminal\",\n  prompt = \"$\",\n  animate = true,\n  speed = 1,\n  onDone,\n  className,\n  ...rest\n}: TerminalProps) {\n  const reduce = useReduce();\n  const playing = animate && !reduce;\n  const end: Pos = { line: lines.length, chars: 0 };\n  const [pos, setPos] = useState<Pos>({ line: 0, chars: 0 });\n  const [run, setRun] = useState(0); // bumps on replay\n  const [visible, setVisible] = useState(false);\n  const root = useRef<HTMLDivElement>(null);\n  const body = useRef<HTMLDivElement>(null);\n  const follow = useRef(true);\n  const doneRef = useRef(onDone);\n  const reported = useRef(false);\n  useEffect(() => {\n    doneRef.current = onDone;\n  });\n\n  const shown = playing ? pos : end;\n  const finished = shown.line >= lines.length;\n\n  // Only plays while on screen and while the tab is visible; it picks up where it left off.\n  useEffect(() => {\n    const el = root.current;\n    if (!el || !playing) return;\n    let inView = false;\n    const sync = () => setVisible(inView && document.visibilityState === \"visible\");\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      sync();\n    }, { threshold: 0.35 });\n    io.observe(el);\n    document.addEventListener(\"visibilitychange\", sync);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n    };\n  }, [playing]);\n\n  // The clock: one timeout at a time, scheduling the next beat from the current position.\n  useEffect(() => {\n    if (!playing || !visible) return;\n    if (pos.line >= lines.length) {\n      // Once per playthrough, even if it scrolls away and back after finishing.\n      if (!reported.current) doneRef.current?.();\n      reported.current = true;\n      return;\n    }\n    const line = lines[pos.line];\n    const prev = lines[pos.line - 1];\n    let wait: number;\n    let next: Pos;\n    if (line.kind === \"command\") {\n      const keys = keystrokes(line.text, speed);\n      if (pos.chars < line.text.length) {\n        // A pause to \"think\" before the first key of a command, longer after output than at the start.\n        wait = pos.chars === 0 ? (pos.line === 0 ? 500 : 650) / speed : keys[pos.chars];\n        next = { line: pos.line, chars: pos.chars + 1 };\n      } else {\n        wait = 320 / speed; // the beat before Enter\n        next = { line: pos.line + 1, chars: 0 };\n      }\n    } else if (line.kind === \"task\") {\n      wait = (line.duration ?? 1100) / speed;\n      next = { line: pos.line + 1, chars: 0 };\n    } else {\n      // Output streams: the first line after a command takes a moment, the rest arrive in a burst.\n      const rand = seeded(`${pos.line}${line.kind === \"output\" ? line.text : \"\"}`);\n      wait = (prev?.kind === \"command\" ? 180 + rand() * 160 : 40 + rand() * 90) / speed;\n      next = { line: pos.line + 1, chars: 0 };\n    }\n    const t = window.setTimeout(() => setPos(next), wait);\n    return () => window.clearTimeout(t);\n  }, [playing, visible, pos, lines, speed, run]);\n\n  // Fades whichever edge has more to scroll to, written straight to the element so scrolling never re-renders.\n  useEffect(() => {\n    const el = body.current;\n    if (!el) return;\n    let frame = 0;\n    const update = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        // While it plays, lines below the active one are laid out but not shown yet, so they don't count as \"more\".\n        const last = el.querySelector<HTMLElement>(\"[data-active]\");\n        const end = last ? last.offsetTop + last.offsetHeight + 16 : el.scrollHeight;\n        const below = Math.min(el.scrollHeight, end) - el.clientHeight - el.scrollTop;\n        el.style.setProperty(\"--fade-top\", el.scrollTop > 2 ? \"28px\" : \"0px\");\n        el.style.setProperty(\"--fade-bottom\", below > 2 ? \"28px\" : \"0px\");\n      });\n    };\n    update();\n    el.addEventListener(\"scroll\", update, { passive: true });\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    return () => {\n      cancelAnimationFrame(frame);\n      el.removeEventListener(\"scroll\", update);\n      ro.disconnect();\n    };\n  }, [pos, playing]);\n\n  // Keep the active line in view while it plays, unless the reader has scrolled away to look at something.\n  useEffect(() => {\n    const el = body.current;\n    if (!el || !playing || !follow.current) return;\n    const active = el.querySelector<HTMLElement>(\"[data-active]\");\n    if (!active) return;\n    const bottom = active.offsetTop + active.offsetHeight + 16;\n    if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight;\n  }, [pos, playing]);\n\n  const replay = () => {\n    follow.current = true;\n    reported.current = false;\n    if (body.current) body.current.scrollTop = 0;\n    setPos({ line: 0, chars: 0 });\n    setRun((r) => r + 1);\n  };\n  const skip = () => setPos(end);\n\n  const commands = lines.flatMap((l) => (l.kind === \"command\" ? [l.text] : [])).join(\"\\n\");\n\n  return (\n    <div\n      ref={root}\n      data-state={finished ? \"done\" : \"running\"}\n      className={cn(\"flex min-w-0 flex-col overflow-hidden rounded-xl border border-line-2 bg-frame shadow-[var(--shadow)]\", className)}\n      {...rest}\n    >\n      <Tooltip.Provider delay={500}>\n        <div className=\"relative flex h-10 shrink-0 items-center border-b border-line bg-raised px-3\">\n          <span aria-hidden className=\"flex gap-1.5\">\n            {[0, 1, 2].map((i) => (\n              <span key={i} className=\"size-2.5 rounded-full bg-fg/12 ring-1 ring-inset ring-fg/[0.06]\" />\n            ))}\n          </span>\n          <span className=\"pointer-events-none absolute inset-x-24 truncate text-center font-mono text-[11.5px] text-fg-3\">{title}</span>\n          <span className=\"ml-auto flex items-center gap-0.5\">\n            {playing && (\n              <Hint label={finished ? \"Replay\" : \"Skip to the end\"}>\n                <button type=\"button\" onClick={finished ? replay : skip} aria-label={finished ? \"Replay\" : \"Skip to the end\"} className={iconButton}>\n                  <span className=\"relative grid size-4 place-items-center\">\n                    <AnimatePresence initial={false}>\n                      <motion.span\n                        key={finished ? \"replay\" : \"skip\"}\n                        className=\"absolute inset-0 grid place-items-center\"\n                        initial={{ opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n                        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                        exit={{ opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n                        transition={spring.pop}\n                      >\n                        {finished ? <Refresh size={15} className=\"transition-transform duration-500 ease-out-expo group-hover/icon:-rotate-90\" /> : <SkipIcon />}\n                      </motion.span>\n                    </AnimatePresence>\n                  </span>\n                </button>\n              </Hint>\n            )}\n            {commands && (\n              <Hint label=\"Copy commands\">\n                <CopyButton value={commands} iconOnly variant=\"ghost\" size=\"sm\" label=\"Copy commands\" />\n              </Hint>\n            )}\n          </span>\n        </div>\n      </Tooltip.Provider>\n\n      {/* Screen readers get the whole session once; the typing is for eyes only. */}\n      <div className=\"sr-only\">\n        {lines.map((l, i) => (\n          <p key={i}>{l.kind === \"command\" ? `${prompt} ${l.text}` : l.kind === \"task\" ? (l.done ?? l.text) : l.kind === \"output\" ? l.text : \"\"}</p>\n        ))}\n      </div>\n\n      <div\n        className={cn(\n          \"relative flex min-h-0 flex-1 flex-col\",\n          // The focus ring sits above the scroller, whose edge fades would otherwise fade the ring too.\n          \"after:pointer-events-none after:absolute after:inset-0 after:rounded-b-[calc(var(--radius-xl)-1px)] after:content-['']\",\n          \"has-[[role=region]:focus-visible]:after:shadow-[inset_0_0_0_1px_var(--fg-3)]\",\n        )}\n      >\n        <div\n          ref={body}\n          // The scroller is focusable so arrow keys can page through it; what it shows is for eyes only (see the transcript above).\n          role=\"region\"\n          aria-label={`${title} output`}\n          tabIndex={0}\n          onWheel={() => (follow.current = false)}\n          onTouchMove={() => (follow.current = false)}\n          onKeyDown={() => (follow.current = false)}\n          className={cn(\n            \"min-h-0 flex-1 overflow-auto overscroll-contain p-4 font-mono text-[12.5px] leading-5 outline-none\",\n            // Lines fade under the title bar and at the bottom edge instead of being sliced, and only where there is more.\n            \"[mask-image:linear-gradient(to_bottom,transparent,var(--fg)_var(--fade-top,0px),var(--fg)_calc(100%-var(--fade-bottom,0px)),transparent)]\",\n          )}\n        >\n          <div aria-hidden className=\"w-max min-w-full\">\n            {/* Every line is laid out from the start and revealed in place, so the window never grows while it plays. */}\n            {lines.map((line, i) => {\n              const state = i < shown.line ? \"done\" : i === shown.line ? \"active\" : \"pending\";\n              return <Row key={`${run}-${i}`} line={line} state={state} chars={shown.chars} prompt={prompt} playing={playing} reduce={!!reduce} />;\n            })}\n            <div data-active={finished || undefined} className={cn(\"flex whitespace-pre\", !finished && \"invisible\")}>\n              <Prompt prompt={prompt} cwd={lastCwd(lines)} />\n              <Caret />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst lastCwd = (lines: TerminalLine[]) => {\n  for (let i = lines.length - 1; i >= 0; i--) {\n    const l = lines[i];\n    if (l.kind === \"command\") return l.cwd;\n  }\n};\n\nfunction Row({ line, state, chars, prompt, playing, reduce }: { line: TerminalLine; state: \"done\" | \"active\" | \"pending\"; chars: number; prompt: string; playing: boolean; reduce: boolean }) {\n  const pending = state === \"pending\";\n  const active = state === \"active\";\n\n  if (line.kind === \"blank\") return <div data-active={active || undefined} className=\"h-5\" />;\n\n  if (line.kind === \"command\") {\n    const typing = active && playing;\n    return (\n      <div data-active={active || undefined} className={cn(\"flex min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\", pending && \"invisible\", !pending && \"mt-1 first:mt-0\")}>\n        <Prompt prompt={prompt} cwd={line.cwd} />\n        {typing ? (\n          // The full command reserves the space; the typed part sits on top of it.\n          <span className=\"grid min-w-0 flex-1\">\n            <span className=\"invisible col-start-1 row-start-1\">{line.text}</span>\n            <span className=\"col-start-1 row-start-1 text-fg\">\n              {line.text.slice(0, chars)}\n              <Caret />\n            </span>\n          </span>\n        ) : (\n          <span className=\"min-w-0 flex-1 text-fg\">{line.text}</span>\n        )}\n      </div>\n    );\n  }\n\n  if (line.kind === \"task\") {\n    const running = active && playing;\n    const glyph = running ? \"spin\" : line.failed ? \"fail\" : \"ok\";\n    return (\n      <div data-active={active || undefined} className={cn(\"flex min-w-0 gap-2 whitespace-pre-wrap\", pending && \"invisible\")}>\n        <span className=\"relative grid w-[1ch] shrink-0 place-items-center\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={glyph}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n              className={cn(glyph === \"ok\" ? \"text-success\" : glyph === \"fail\" ? \"text-danger\" : \"text-fg-3\")}\n            >\n              {glyph === \"spin\" ? <Spinner /> : glyph === \"ok\" ? \"✓\" : \"✗\"}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        {/* Both texts share one cell, so resolving never reflows the lines below. */}\n        <span className=\"grid min-w-0 flex-1\">\n          <span className=\"invisible col-start-1 row-start-1\">{line.text}</span>\n          {line.done && <span className=\"invisible col-start-1 row-start-1\">{line.done}</span>}\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={running ? \"run\" : \"done\"}\n              className={cn(\"col-start-1 row-start-1\", running ? \"text-fg-2\" : line.failed ? \"text-danger\" : \"text-fg\")}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              transition={{ duration: 0.16, ease: ease.out }}\n            >\n              {running ? line.text : (line.done ?? line.text)}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      data-active={active || undefined}\n      className={cn(\n        // Output keeps its columns (tables, progress bars); a narrow window scrolls sideways instead of wrapping them apart.\n        \"whitespace-pre transition-opacity duration-100 ease-out\",\n        TONE[line.tone ?? \"default\"],\n        pending ? \"invisible opacity-0\" : \"opacity-100\",\n      )}\n    >\n      {line.text || \" \"}\n    </div>\n  );\n}\n\nfunction Prompt({ prompt, cwd }: { prompt: string; cwd?: string }) {\n  return (\n    <span className=\"shrink-0 select-none\">\n      {cwd && <span className=\"text-fg-3\">{cwd} </span>}\n      <span className=\"text-fg-4\">{prompt} </span>\n    </span>\n  );\n}\n\n// A block caret, blinking on the steps the terminal uses.\nfunction Caret() {\n  return <span aria-hidden className=\"ml-px inline-block h-4 w-[0.6em] translate-y-[3px] animate-caret bg-fg/70 align-top motion-reduce:animate-none\" />;\n}\n\nconst FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nfunction Spinner() {\n  const [i, setI] = useState(0);\n  useEffect(() => {\n    const t = window.setInterval(() => setI((n) => (n + 1) % FRAMES.length), 80);\n    return () => window.clearInterval(t);\n  }, []);\n  return <span>{FRAMES[i]}</span>;\n}\n\nfunction SkipIcon() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M3.5 4.5 7.5 8l-4 3.5zM8.5 4.5l4 3.5-4 3.5zM12.75 4v8\" />\n    </svg>\n  );\n}\n\nconst iconButton = cn(\n  \"group/icon relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n);\n\nfunction Hint({ label, children }: { label: string; children: React.ReactElement }) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger render={children} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex min-h-[26px] items-center rounded-lg border border-line-2 bg-raised px-2 py-[3px] text-[12px] leading-4 text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:translate-y-0.5 data-starting-style:opacity-0\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n            )}\n          >\n            {label}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/terminal"
}
