"use client"; import { CheckIcon, FastForwardIcon, PlayIcon, RotateCcwIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type TerminalStep = | { type: "command"; text: string } | { type: "output"; text: string; pace?: number } | { type: "spinner"; text: string; duration: number; done: string; ok?: boolean } | { type: "wait"; ms: number }; export interface TerminalProps { steps: TerminalStep[]; title?: string; cwd?: string; branch?: string; autoPlay?: boolean; speed?: number; onDone?: () => void; className?: string; } type Line = | { kind: "prompt"; text: string; typed: number; ok: boolean } | { kind: "out"; text: string } | { kind: "spin"; text: string; done?: string; ok?: boolean }; const fg: Record = { 30: "text-foreground", 31: "text-red-600 dark:text-red-400", 32: "text-emerald-700 dark:text-emerald-400", 33: "text-amber-700 dark:text-amber-300", 34: "text-sky-700 dark:text-sky-400", 35: "text-violet-600 dark:text-violet-400", 36: "text-cyan-700 dark:text-cyan-300", 37: "text-foreground", 90: "text-muted-foreground", }; function Ansi({ text }: { text: string }) { const parts: ReactNode[] = []; let color = ""; let bold = false; let dim = false; let k = 0; for (const chunk of text.split(/(\x1b\[[\d;]*m)/)) { const code = chunk.match(/^\x1b\[([\d;]*)m$/); if (code) { for (const n of (code[1] || "0").split(";").map(Number)) { if (n === 0) { color = ""; bold = false; dim = false; } else if (n === 1) bold = true; else if (n === 2) dim = true; else if (n === 22) { bold = false; dim = false; } else if (n === 39) color = ""; else if (fg[n] || fg[n - 60]) color = fg[n] ?? fg[n - 60]; } continue; } if (!chunk) continue; parts.push( {chunk} , ); } return <>{parts}; } const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; function Spinner() { const [i, setI] = useState(0); useEffect(() => { const t = setInterval(() => setI((n) => (n + 1) % FRAMES.length), 80); return () => clearInterval(t); }, []); return {FRAMES[i]}; } function Caret({ idle }: { idle: boolean }) { const reduce = useReducedMotion(); return ( ); } function Prompt({ cwd, branch, ok }: { cwd: string; branch?: string; ok: boolean }) { return ( {cwd} {branch && {branch}} ❯ ); } const fmt = (ms: number) => `${(ms / 1000).toFixed(1)}s`; export function Terminal({ steps, title = "zsh", cwd = "~/acme-web", branch = "main", autoPlay = true, speed = 1, onDone, className, }: TerminalProps) { const [lines, setLines] = useState([]); const [status, setStatus] = useState<"idle" | "running" | "done" | "failed">("idle"); const [run, setRun] = useState(autoPlay ? 1 : 0); const [elapsed, setElapsed] = useState(0); const [current, setCurrent] = useState(null); const bodyRef = useRef(null); const stick = useRef(true); const skip = useRef(false); const wakers = useRef(new Set<() => void>()); const doneRef = useRef(onDone); doneRef.current = onDone; const commands = steps.filter((s) => s.type === "command").length; useEffect(() => { if (!run) return; let alive = true; const timers = new Set>(); skip.current = false; stick.current = true; const sleep = (ms: number) => new Promise((resolve) => { if (skip.current || ms <= 0) return resolve(); const wake = () => { clearTimeout(t); timers.delete(t); wakers.current.delete(wake); resolve(); }; const t = setTimeout(wake, ms / speed); timers.add(t); wakers.current.add(wake); }); const patchLast = (fn: (l: Line) => Line) => setLines((ls) => (ls.length ? [...ls.slice(0, -1), fn(ls[ls.length - 1])] : ls)); const started = performance.now(); const tick = setInterval(() => setElapsed(performance.now() - started), 100); (async () => { setLines([]); setStatus("running"); setElapsed(0); let ok = true; await sleep(350); for (const step of steps) { if (!alive) return; if (step.type === "command") { setLines((ls) => [...ls, { kind: "prompt", text: step.text, typed: 0, ok }]); await sleep(380); for (let i = 1; i <= step.text.length && alive; i++) { patchLast((l) => (l.kind === "prompt" ? { ...l, typed: i } : l)); const ch = step.text[i - 1]; await sleep(ch === " " ? 70 : 28 + Math.random() * 46); } setCurrent(step.text); await sleep(260); } else if (step.type === "output") { for (const text of step.text.split("\n")) { if (!alive) return; setLines((ls) => [...ls, { kind: "out", text }]); await sleep(step.pace ?? 45); } } else if (step.type === "spinner") { setLines((ls) => [...ls, { kind: "spin", text: step.text }]); await sleep(step.duration); const passed = step.ok ?? true; if (!passed) ok = false; patchLast((l) => (l.kind === "spin" ? { ...l, done: step.done, ok: passed } : l)); await sleep(120); } else { await sleep(step.ms); } } if (!alive) return; clearInterval(tick); setElapsed(performance.now() - started); setLines((ls) => [...ls, { kind: "prompt", text: "", typed: 0, ok }]); setCurrent(null); setStatus(ok ? "done" : "failed"); doneRef.current?.(); })(); return () => { alive = false; clearInterval(tick); timers.forEach(clearTimeout); wakers.current.clear(); }; }, [run, steps, speed]); useLayoutEffect(() => { const el = bodyRef.current; if (el && stick.current) el.scrollTop = el.scrollHeight; }, [lines]); const fastForward = () => { skip.current = true; for (const wake of [...wakers.current]) wake(); }; const reduce = useReducedMotion(); const running = status === "running"; const last = lines[lines.length - 1]; return (
{cwd.split("/").pop()} — {title}
{ const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; }} ref={bodyRef} role="log" tabIndex={0} > {lines.map((line, i) => { const isLast = i === lines.length - 1; if (line.kind === "prompt") { return (
0 && "mt-2")} key={i}> {line.text.slice(0, line.typed)} {isLast && }
); } if (line.kind === "spin") { return (
{line.done ? ( <> {line.ok ? "✓" : "✗"} {" "} ) : ( <> {line.text} )}
); } return (
); })} {status === "idle" && (
)} {last?.kind === "out" && running && streaming}
{status === "running" && ( <> Running {current ?? "…"} )} {(status === "done" || status === "failed") && ( <> {status === "done" ? : } {status === "done" ? "All commands succeeded" : "A command failed"} {" "} · {commands} command{commands === 1 ? "" : "s"} )} {status === "idle" && Ready · press Run to start} {fmt(elapsed)}
); }