{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "terminal",
  "type": "registry:component",
  "title": "Terminal",
  "description": "A terminal that replays a session: commands typed at the prompt, output streamed in real ANSI colour, braille spinners that resolve to a tick, and a status bar that keeps time, with skip and replay.",
  "categories": [
    "component",
    "Dev Tools"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/terminal.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, FastForwardIcon, PlayIcon, RotateCcwIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TerminalStep =\n  | { type: \"command\"; text: string }\n  | { type: \"output\"; text: string; pace?: number }\n  | { type: \"spinner\"; text: string; duration: number; done: string; ok?: boolean }\n  | { type: \"wait\"; ms: number };\n\nexport interface TerminalProps {\n  steps: TerminalStep[];\n  title?: string;\n  cwd?: string;\n  branch?: string;\n  autoPlay?: boolean;\n  speed?: number;\n  onDone?: () => void;\n  className?: string;\n}\n\ntype Line =\n  | { kind: \"prompt\"; text: string; typed: number; ok: boolean }\n  | { kind: \"out\"; text: string }\n  | { kind: \"spin\"; text: string; done?: string; ok?: boolean };\n\nconst fg: Record<number, string> = {\n  30: \"text-foreground\",\n  31: \"text-red-600 dark:text-red-400\",\n  32: \"text-emerald-700 dark:text-emerald-400\",\n  33: \"text-amber-700 dark:text-amber-300\",\n  34: \"text-sky-700 dark:text-sky-400\",\n  35: \"text-violet-600 dark:text-violet-400\",\n  36: \"text-cyan-700 dark:text-cyan-300\",\n  37: \"text-foreground\",\n  90: \"text-muted-foreground\",\n};\n\nfunction Ansi({ text }: { text: string }) {\n  const parts: ReactNode[] = [];\n  let color = \"\";\n  let bold = false;\n  let dim = false;\n  let k = 0;\n  for (const chunk of text.split(/(\\x1b\\[[\\d;]*m)/)) {\n    const code = chunk.match(/^\\x1b\\[([\\d;]*)m$/);\n    if (code) {\n      for (const n of (code[1] || \"0\").split(\";\").map(Number)) {\n        if (n === 0) {\n          color = \"\";\n          bold = false;\n          dim = false;\n        } else if (n === 1) bold = true;\n        else if (n === 2) dim = true;\n        else if (n === 22) {\n          bold = false;\n          dim = false;\n        } else if (n === 39) color = \"\";\n        else if (fg[n] || fg[n - 60]) color = fg[n] ?? fg[n - 60];\n      }\n      continue;\n    }\n    if (!chunk) continue;\n    parts.push(\n      <span className={cn(color, bold && \"font-semibold\", dim && \"opacity-75\")} key={k++}>\n        {chunk}\n      </span>,\n    );\n  }\n  return <>{parts}</>;\n}\n\nconst FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n\nfunction Spinner() {\n  const [i, setI] = useState(0);\n  useEffect(() => {\n    const t = setInterval(() => setI((n) => (n + 1) % FRAMES.length), 80);\n    return () => clearInterval(t);\n  }, []);\n  return <span className=\"text-sky-700 dark:text-sky-400\">{FRAMES[i]}</span>;\n}\n\nfunction Caret({ idle }: { idle: boolean }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      animate={idle && !reduce ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}\n      aria-hidden\n      className=\"ms-px inline-block h-[1.1em] w-[0.55em] translate-y-[0.2em] bg-foreground/80\"\n      transition={idle ? { duration: 1.1, repeat: Number.POSITIVE_INFINITY, times: [0, 0.5, 0.5, 1], ease: \"linear\" } : undefined}\n    />\n  );\n}\n\nfunction Prompt({ cwd, branch, ok }: { cwd: string; branch?: string; ok: boolean }) {\n  return (\n    <span className=\"select-none\">\n      <span className=\"text-sky-700 dark:text-sky-400\">{cwd}</span>\n      {branch && <span className=\"text-violet-600 dark:text-violet-400\"> {branch}</span>}\n      <span className={ok ? \"text-emerald-700 dark:text-emerald-400\" : \"text-red-600 dark:text-red-400\"}> ❯ </span>\n    </span>\n  );\n}\n\nconst fmt = (ms: number) => `${(ms / 1000).toFixed(1)}s`;\n\nexport function Terminal({\n  steps,\n  title = \"zsh\",\n  cwd = \"~/acme-web\",\n  branch = \"main\",\n  autoPlay = true,\n  speed = 1,\n  onDone,\n  className,\n}: TerminalProps) {\n  const [lines, setLines] = useState<Line[]>([]);\n  const [status, setStatus] = useState<\"idle\" | \"running\" | \"done\" | \"failed\">(\"idle\");\n  const [run, setRun] = useState(autoPlay ? 1 : 0);\n  const [elapsed, setElapsed] = useState(0);\n  const [current, setCurrent] = useState<string | null>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const stick = useRef(true);\n  const skip = useRef(false);\n  const wakers = useRef(new Set<() => void>());\n  const doneRef = useRef(onDone);\n  doneRef.current = onDone;\n\n  const commands = steps.filter((s) => s.type === \"command\").length;\n\n  useEffect(() => {\n    if (!run) return;\n    let alive = true;\n    const timers = new Set<ReturnType<typeof setTimeout>>();\n    skip.current = false;\n    stick.current = true;\n    const sleep = (ms: number) =>\n      new Promise<void>((resolve) => {\n        if (skip.current || ms <= 0) return resolve();\n        const wake = () => {\n          clearTimeout(t);\n          timers.delete(t);\n          wakers.current.delete(wake);\n          resolve();\n        };\n        const t = setTimeout(wake, ms / speed);\n        timers.add(t);\n        wakers.current.add(wake);\n      });\n    const patchLast = (fn: (l: Line) => Line) =>\n      setLines((ls) => (ls.length ? [...ls.slice(0, -1), fn(ls[ls.length - 1])] : ls));\n\n    const started = performance.now();\n    const tick = setInterval(() => setElapsed(performance.now() - started), 100);\n\n    (async () => {\n      setLines([]);\n      setStatus(\"running\");\n      setElapsed(0);\n      let ok = true;\n      await sleep(350);\n      for (const step of steps) {\n        if (!alive) return;\n        if (step.type === \"command\") {\n          setLines((ls) => [...ls, { kind: \"prompt\", text: step.text, typed: 0, ok }]);\n          await sleep(380);\n          for (let i = 1; i <= step.text.length && alive; i++) {\n            patchLast((l) => (l.kind === \"prompt\" ? { ...l, typed: i } : l));\n            const ch = step.text[i - 1];\n            await sleep(ch === \" \" ? 70 : 28 + Math.random() * 46);\n          }\n          setCurrent(step.text);\n          await sleep(260);\n        } else if (step.type === \"output\") {\n          for (const text of step.text.split(\"\\n\")) {\n            if (!alive) return;\n            setLines((ls) => [...ls, { kind: \"out\", text }]);\n            await sleep(step.pace ?? 45);\n          }\n        } else if (step.type === \"spinner\") {\n          setLines((ls) => [...ls, { kind: \"spin\", text: step.text }]);\n          await sleep(step.duration);\n          const passed = step.ok ?? true;\n          if (!passed) ok = false;\n          patchLast((l) => (l.kind === \"spin\" ? { ...l, done: step.done, ok: passed } : l));\n          await sleep(120);\n        } else {\n          await sleep(step.ms);\n        }\n      }\n      if (!alive) return;\n      clearInterval(tick);\n      setElapsed(performance.now() - started);\n      setLines((ls) => [...ls, { kind: \"prompt\", text: \"\", typed: 0, ok }]);\n      setCurrent(null);\n      setStatus(ok ? \"done\" : \"failed\");\n      doneRef.current?.();\n    })();\n\n    return () => {\n      alive = false;\n      clearInterval(tick);\n      timers.forEach(clearTimeout);\n      wakers.current.clear();\n    };\n  }, [run, steps, speed]);\n\n  useLayoutEffect(() => {\n    const el = bodyRef.current;\n    if (el && stick.current) el.scrollTop = el.scrollHeight;\n  }, [lines]);\n\n  const fastForward = () => {\n    skip.current = true;\n    for (const wake of [...wakers.current]) wake();\n  };\n\n  const reduce = useReducedMotion();\n  const running = status === \"running\";\n  const last = lines[lines.length - 1];\n\n  return (\n    <div\n      className={cn(\"flex w-full min-w-0 max-w-[540px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\", className)}\n    >\n      <div className=\"grid h-10 grid-cols-[1fr_auto_1fr] items-center border-b ps-3.5 pe-1.5\">\n        <div aria-hidden className=\"flex gap-1.5\">\n          <span className=\"size-2.5 rounded-full bg-muted-foreground/20\" />\n          <span className=\"size-2.5 rounded-full bg-muted-foreground/20\" />\n          <span className=\"size-2.5 rounded-full bg-muted-foreground/20\" />\n        </div>\n        <span className=\"truncate font-mono text-muted-foreground text-xs\">\n          {cwd.split(\"/\").pop()} — {title}\n        </span>\n        <div className=\"flex justify-end\">\n          <Button\n            aria-label={running ? \"Skip to end\" : status === \"idle\" ? \"Run session\" : \"Replay session\"}\n            className=\"gap-1.5 text-muted-foreground text-xs transition-[color,background-color,scale] active:scale-[0.97] sm:text-xs [@media(hover:hover)]:hover:text-foreground\"\n            onClick={() => (running ? fastForward() : setRun((n) => n + 1))}\n            size=\"sm\"\n            variant=\"ghost\"\n          >\n            {running ? <FastForwardIcon className=\"size-3.5\" /> : status === \"idle\" ? <PlayIcon className=\"size-3.5\" /> : <RotateCcwIcon className=\"size-3.5\" />}\n            <span className=\"w-11 text-start\">{running ? \"Skip\" : status === \"idle\" ? \"Run\" : \"Replay\"}</span>\n          </Button>\n        </div>\n      </div>\n\n      <div\n        aria-busy={running}\n        aria-label=\"Terminal output\"\n        aria-live=\"off\"\n        className=\"h-[300px] overflow-auto bg-code px-4 py-3 font-mono text-[12.5px] leading-5 outline-none [scrollbar-width:thin] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n        onScroll={(e) => {\n          const el = e.currentTarget;\n          stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;\n        }}\n        ref={bodyRef}\n        role=\"log\"\n        tabIndex={0}\n      >\n        {lines.map((line, i) => {\n          const isLast = i === lines.length - 1;\n          if (line.kind === \"prompt\") {\n            return (\n              <div className={cn(\"whitespace-pre-wrap break-all\", i > 0 && \"mt-2\")} key={i}>\n                <Prompt branch={branch} cwd={cwd} ok={line.ok} />\n                <span className=\"text-foreground\">{line.text.slice(0, line.typed)}</span>\n                {isLast && <Caret idle={!running || line.typed === 0 || line.typed === line.text.length} />}\n              </div>\n            );\n          }\n          if (line.kind === \"spin\") {\n            return (\n              <div className=\"whitespace-pre-wrap\" key={i}>\n                {line.done ? (\n                  <>\n                    <span className={line.ok ? \"text-emerald-700 dark:text-emerald-400\" : \"text-red-600 dark:text-red-400\"}>\n                      {line.ok ? \"✓\" : \"✗\"}\n                    </span>{\" \"}\n                    <Ansi text={line.done} />\n                  </>\n                ) : (\n                  <>\n                    <Spinner /> <span className=\"text-muted-foreground\">{line.text}</span>\n                  </>\n                )}\n              </div>\n            );\n          }\n          return (\n            <div className=\"min-h-5 w-max min-w-full whitespace-pre text-foreground/90\" key={i}>\n              <Ansi text={line.text} />\n            </div>\n          );\n        })}\n        {status === \"idle\" && (\n          <div>\n            <Prompt branch={branch} cwd={cwd} ok />\n            <Caret idle />\n          </div>\n        )}\n        {last?.kind === \"out\" && running && <span className=\"sr-only\">streaming</span>}\n      </div>\n\n      <div className=\"flex h-9 items-center gap-2 border-t px-3.5 text-xs\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, scale: 1 }}\n            className=\"flex min-w-0 flex-1 items-center gap-2\"\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}\n            key={status}\n            transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}\n          >\n            {status === \"running\" && (\n              <>\n                <span className=\"relative flex size-2 shrink-0\">\n                  <span className=\"absolute inset-0 animate-ping rounded-full bg-info/60 motion-reduce:hidden\" />\n                  <span className=\"relative size-2 rounded-full bg-info\" />\n                </span>\n                <span className=\"truncate text-muted-foreground\">\n                  Running <span className=\"font-mono text-foreground\">{current ?? \"…\"}</span>\n                </span>\n              </>\n            )}\n            {(status === \"done\" || status === \"failed\") && (\n              <>\n                <motion.span\n                  animate={{ scale: 1, opacity: 1 }}\n                  className={cn(\n                    \"grid size-4 shrink-0 place-items-center rounded-full text-white\",\n                    status === \"done\" ? \"bg-success\" : \"bg-destructive\",\n                  )}\n                  initial={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n                  transition={{ type: \"spring\", stiffness: 600, damping: 32 }}\n                >\n                  {status === \"done\" ? <CheckIcon className=\"size-2.5\" strokeWidth={3.5} /> : <XIcon className=\"size-2.5\" strokeWidth={3.5} />}\n                </motion.span>\n                <span className=\"truncate\">\n                  {status === \"done\" ? \"All commands succeeded\" : \"A command failed\"}\n                  <span className=\"text-muted-foreground\">\n                    {\" \"}\n                    · {commands} command{commands === 1 ? \"\" : \"s\"}\n                  </span>\n                </span>\n              </>\n            )}\n            {status === \"idle\" && <span className=\"text-muted-foreground\">Ready · press Run to start</span>}\n          </motion.span>\n        </AnimatePresence>\n        <span className=\"shrink-0 font-mono text-muted-foreground tabular-nums\">{fmt(elapsed)}</span>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}