{
  "name": "ai-code-run",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @kokonutui\n * @description: AI Code Run\n * @version: 1.1.0\n * @date: 2026-11-05\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { RotateCcw } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CodeRunBar {\n  label: string;\n  value: number;\n}\n\n/**\n * Where the run is: the model `writing` the code, the code `running`, then\n * `done` or `error`. Matches a code tool's part going from input-streaming\n * to input-available to output-available or output-error.\n */\nexport type CodeRunStatus = \"writing\" | \"running\" | \"done\" | \"error\";\n\nexport interface AICodeRunProps {\n  /** The user's message, drawn as a bubble. Only the demo draws one by default. */\n  prompt?: string;\n  /** What the assistant says before the code. */\n  message?: string;\n  filename?: string;\n  /** The script. While `writing`, pass it as it streams in. */\n  code?: string;\n  /** What the script prints, or the error when it fails. */\n  stdout?: string;\n  /** The chart the script draws, shown once it's done. */\n  bars?: CodeRunBar[];\n  /** The reply under the run, shown once it's done. */\n  answer?: ReactNode;\n  /**\n   * Pass it to drive the card from your run; leave it out and the code\n   * plays through line by line on `lineMs`.\n   */\n  status?: CodeRunStatus;\n  /** The line running now, from 1, while `running`. */\n  activeLine?: number;\n  /** How long the run took, in seconds, shown once it's done. */\n  duration?: number;\n  /** Shows Run again once the run is over. The demo replays by itself. */\n  onRunAgain?: () => void;\n  /** Demo only: how long each line takes, in ms. */\n  lineMs?: number;\n  className?: string;\n}\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\nconst LINE_HEIGHT = 20;\n/** Comments, strings, keywords, numbers and calls, in that order. */\nconst TOKEN =\n  /(#.*$)|(f?\"[^\"]*\"|f?'[^']*')|\\b(import|from|as|def|return|for|in|if|else|with|lambda|None|True|False)\\b|(\\b\\d[\\d_]*(?:\\.\\d+)?\\b)|([A-Za-z_]\\w*)(?=\\()/g;\n\nconst DEMO_CODE = `import pandas as pd\n\ndf = pd.read_csv(\"signups.csv\")\nweekly = df.groupby(\"week\").size().tail(6)\ngrowth = weekly.iloc[-1] / weekly.iloc[0] - 1\n\nprint(f\"{weekly.sum():,} signups, {growth:+.0%}\")\nweekly.plot.bar()`;\n\nconst DEMO_BARS: CodeRunBar[] = [\n  { label: \"Oct 5\", value: 690 },\n  { label: \"Oct 12\", value: 742 },\n  { label: \"Oct 19\", value: 788 },\n  { label: \"Oct 26\", value: 856 },\n  { label: \"Nov 2\", value: 784 },\n  { label: \"Nov 9\", value: 952 },\n];\n\nconst DEMO_ANSWER =\n  \"Signups grew 38% over six weeks, to 952 last week. The one dip, the week of Nov 2, lines up with the pricing page being down for a day.\";\n\n/** Splits a line of Python into coloured spans. */\nconst highlight = (line: string): ReactNode[] => {\n  const out: ReactNode[] = [];\n  let last = 0;\n  for (const m of line.matchAll(TOKEN)) {\n    const at = m.index ?? 0;\n    if (at > last) {\n      out.push(line.slice(last, at));\n    }\n    let tone = \"text-blue-600 dark:text-blue-400\";\n    if (m[1]) {\n      tone = \"text-zinc-400 dark:text-zinc-500\";\n    } else if (m[2]) {\n      tone = \"text-emerald-700 dark:text-emerald-400\";\n    } else if (m[3]) {\n      tone = \"text-violet-600 dark:text-violet-400\";\n    } else if (m[4]) {\n      tone = \"text-amber-700 dark:text-amber-400\";\n    }\n    out.push(\n      <span className={tone} key={at}>\n        {m[0]}\n      </span>\n    );\n    last = at + m[0].length;\n  }\n  if (last < line.length) {\n    out.push(line.slice(last));\n  }\n  return out;\n};\n\nconst DEMO = {\n  prompt: \"How did signups do over the last six weeks?\",\n  message: \"I’ll pull them from the export and plot it.\",\n  filename: \"signups.py\",\n  stdout: \"4,812 signups, +38%\",\n};\n\n/** Plays the code line by line, as the demo and for code with no status. */\nfunction usePlayback(lines: string[], lineMs: number, active: boolean) {\n  /** Lines that actually run; blank ones are skipped. */\n  const steps = lines.flatMap((l, i) => (l.trim() ? [i] : []));\n  const [run, setRun] = useState(0);\n  const [step, setStep] = useState(0);\n  const finished = step >= steps.length;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: run restarts the effect\n  useEffect(() => {\n    if (finished || !active) {\n      return;\n    }\n    const t = setTimeout(() => setStep((s) => s + 1), lineMs);\n    return () => clearTimeout(t);\n  }, [step, finished, lineMs, run, active]);\n\n  /** stdout shows once the print line has run. */\n  const printAt = lines.findIndex((l) => l.trimStart().startsWith(\"print\"));\n  const current = finished ? -1 : steps[step];\n  return {\n    run,\n    status: (finished ? \"done\" : \"running\") as CodeRunStatus,\n    current,\n    ranUpTo: (i: number) => steps.includes(i) && steps.indexOf(i) < step,\n    printed: finished || (printAt >= 0 && current > printAt),\n    seconds: (steps.length * lineMs) / 1000,\n    again: () => {\n      setStep(0);\n      setRun((r) => r + 1);\n    },\n  };\n}\n\n/** What the card shows: the demo's content only when it's the demo. */\nfunction shownContent(\n  props: Pick<\n    AICodeRunProps,\n    \"prompt\" | \"message\" | \"filename\" | \"stdout\" | \"bars\" | \"answer\"\n  >,\n  isDemo: boolean\n) {\n  if (isDemo) {\n    return {\n      prompt: props.prompt ?? DEMO.prompt,\n      message: props.message ?? DEMO.message,\n      filename: props.filename ?? DEMO.filename,\n      stdout: props.stdout ?? DEMO.stdout,\n      bars: props.bars ?? DEMO_BARS,\n      answer: props.answer ?? DEMO_ANSWER,\n    };\n  }\n  return {\n    ...props,\n    filename: props.filename ?? \"script.py\",\n    bars: props.bars ?? [],\n  };\n}\n\n/** The card's state from props, in the same shape playback gives. */\nfunction drivenView(\n  status: CodeRunStatus,\n  activeLine: number | undefined,\n  stdout: string | undefined\n) {\n  // A failed run keeps its marker on the line that failed.\n  const marked = status === \"running\" || status === \"error\";\n  const current = marked && activeLine !== undefined ? activeLine - 1 : -1;\n  return {\n    current,\n    ranUpTo: (i: number) => !marked || current < 0 || i < current,\n    printed: Boolean(stdout),\n  };\n}\n\nexport default function AICodeRun({\n  prompt,\n  message,\n  filename,\n  code,\n  stdout,\n  bars,\n  answer,\n  status: statusProp,\n  activeLine,\n  duration,\n  onRunAgain,\n  lineMs = 320,\n  className,\n}: AICodeRunProps) {\n  const outputId = useId();\n  const driven = statusProp !== undefined;\n  const isDemo = !driven && code === undefined;\n  const shown = shownContent(\n    { prompt, message, filename, stdout, bars, answer },\n    isDemo\n  );\n  const lines = (code ?? DEMO_CODE).split(\"\\n\");\n  const playback = usePlayback(lines, lineMs, !driven);\n  const status = statusProp ?? playback.status;\n  const view = driven ? drivenView(status, activeLine, shown.stdout) : playback;\n  const { current, printed } = view;\n  const finished = status === \"done\" || status === \"error\";\n  const run = playback.run;\n  const again = driven ? onRunAgain : playback.again;\n  const seconds = driven ? duration : playback.seconds;\n  const max = Math.max(1, ...shown.bars.map((b) => b.value));\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div\n        className={cn(\"flex w-full max-w-md flex-col gap-4\", className)}\n        data-state={status}\n      >\n        {shown.prompt ? (\n          <p className=\"max-w-[85%] self-end rounded-2xl rounded-br-md bg-zinc-100 px-3.5 py-2 text-[14px] text-zinc-800 leading-snug dark:bg-zinc-800 dark:text-zinc-100\">\n            {shown.prompt}\n          </p>\n        ) : null}\n        {shown.message ? (\n          <p className=\"text-[15px] text-zinc-800 leading-7 dark:text-zinc-200\">\n            {shown.message}\n          </p>\n        ) : null}\n\n        <AutoHeight className=\"rounded-[22px] bg-black/[0.04] dark:bg-white/[0.06]\">\n          <div className=\"flex h-8 items-center gap-2 pr-1 pl-3 text-[12px] text-zinc-500 dark:text-zinc-400\">\n            <span className=\"rounded-[4px] bg-black/[0.06] px-1 font-medium font-mono text-[10px] text-zinc-600 leading-4 dark:bg-white/[0.08] dark:text-zinc-300\">\n              py\n            </span>\n            <span className=\"min-w-0 truncate font-mono text-[11.5px] text-zinc-600 dark:text-zinc-300\">\n              {shown.filename}\n            </span>\n            <span className=\"ml-auto flex shrink-0 items-center gap-1.5\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ opacity: 1, y: 0 }}\n                  className=\"flex items-center gap-1.5\"\n                  exit={{ opacity: 0, y: -4 }}\n                  initial={{ opacity: 0, y: 4 }}\n                  key={status}\n                  transition={{ duration: 0.2, ease: EASE_OUT }}\n                >\n                  <StatusLabel seconds={seconds} status={status} />\n                </motion.span>\n              </AnimatePresence>\n              <button\n                aria-label=\"Run again\"\n                className=\"flex size-6 items-center justify-center rounded-md text-zinc-500 outline-hidden transition-[color,background-color,opacity] duration-150 hover:bg-black/[0.05] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-blue-500/40 active:bg-black/[0.08] disabled:pointer-events-none disabled:opacity-0 dark:text-zinc-400 dark:active:bg-white/[0.12] dark:hover:bg-white/[0.08] dark:hover:text-zinc-100\"\n                disabled={!(finished && again)}\n                onClick={again}\n                type=\"button\"\n              >\n                <RotateCcw aria-hidden=\"true\" className=\"size-3.5\" />\n              </button>\n            </span>\n          </div>\n\n          <div className=\"overflow-hidden rounded-[18px] bg-white shadow-[0_1px_2px_rgb(0_0_0/0.04)] ring-1 ring-black/[0.06] dark:bg-zinc-900 dark:ring-white/[0.08]\">\n            <pre className=\"relative overflow-x-auto py-2.5 font-mono text-[11.5px] leading-[20px]\">\n              {/* One marker that slides between lines, on transform only. */}\n              <motion.span\n                animate={{\n                  y: Math.max(current, 0) * LINE_HEIGHT,\n                  opacity: current >= 0 ? 1 : 0,\n                }}\n                aria-hidden=\"true\"\n                className={cn(\n                  \"absolute inset-x-0 top-2.5 h-5 border-l-2\",\n                  status === \"error\"\n                    ? \"border-rose-500 bg-rose-500/[0.07] dark:border-rose-400 dark:bg-rose-400/10\"\n                    : \"border-violet-500 bg-violet-500/[0.07] dark:border-violet-400 dark:bg-violet-400/10\"\n                )}\n                initial={false}\n                transition={{ duration: 0.18, ease: EASE_OUT }}\n              />\n              <code className=\"relative block min-w-max\">\n                {lines.map((line, i) => {\n                  const ran = finished || view.ranUpTo(i);\n                  return (\n                    <span\n                      className=\"relative flex pr-4\"\n                      // biome-ignore lint/suspicious/noArrayIndexKey: lines are a fixed, ordered list\n                      key={i}\n                    >\n                      <span\n                        className={cn(\n                          \"relative w-8 shrink-0 select-none pr-3 text-right tabular-nums transition-colors duration-200\",\n                          lineTone(i === current, status)\n                        )}\n                      >\n                        {i + 1}\n                      </span>\n                      <span\n                        className={cn(\n                          \"relative whitespace-pre text-zinc-800 transition-opacity duration-200 dark:text-zinc-200\",\n                          !ran && i !== current && line.trim() && \"opacity-55\"\n                        )}\n                      >\n                        {highlight(line)}\n                        {line ? \"\" : \" \"}\n                      </span>\n                    </span>\n                  );\n                })}\n              </code>\n            </pre>\n\n            <AnimatePresence initial={false}>\n              {printed && (\n                <motion.div\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  className=\"border-black/[0.06] border-t dark:border-white/[0.08]\"\n                  exit={{ opacity: 0, filter: \"blur(2px)\" }}\n                  id={outputId}\n                  initial={{ opacity: 0, filter: \"blur(2px)\" }}\n                  key={`out-${run}`}\n                  transition={{ duration: 0.25, ease: EASE_OUT }}\n                >\n                  <p\n                    className={cn(\n                      \"flex items-baseline gap-2 whitespace-pre-wrap px-3.5 pt-3 font-mono text-[11.5px]\",\n                      status === \"error\"\n                        ? \"pb-3 text-rose-600 dark:text-rose-400\"\n                        : \"text-zinc-700 dark:text-zinc-300\"\n                    )}\n                  >\n                    <span className=\"select-none text-zinc-300 dark:text-zinc-600\">\n                      ›\n                    </span>\n                    {shown.stdout}\n                  </p>\n                  {status === \"done\" && shown.bars.length > 0 ? (\n                    <Chart bars={shown.bars} key={`chart-${run}`} max={max} />\n                  ) : (\n                    <div className=\"h-3\" />\n                  )}\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        </AutoHeight>\n\n        <AnimatePresence>\n          {finished && shown.answer ? (\n            <motion.div\n              animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n              className=\"text-[15px] text-zinc-800 leading-7 dark:text-zinc-200\"\n              initial={{ opacity: 0, filter: \"blur(4px)\", y: 4 }}\n              key={`answer-${run}`}\n              transition={{ duration: 0.4, ease: EASE_OUT, delay: 0.7 }}\n            >\n              {shown.answer}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n        <p aria-live=\"polite\" className=\"sr-only\">\n          {finished\n            ? `${statusText(status, seconds)}. ${shown.stdout ?? \"\"}`\n            : statusText(status, seconds)}\n        </p>\n      </div>\n    </MotionConfig>\n  );\n}\n\n/** The line number's colour: marked lines take the run's colour. */\nconst lineTone = (marked: boolean, status: CodeRunStatus) => {\n  if (!marked) {\n    return \"text-zinc-300 dark:text-zinc-600\";\n  }\n  return status === \"error\"\n    ? \"text-rose-600 dark:text-rose-400\"\n    : \"text-violet-600 dark:text-violet-400\";\n};\n\nconst statusText = (status: CodeRunStatus, seconds?: number) => {\n  if (status === \"writing\") {\n    return \"Writing\";\n  }\n  if (status === \"running\") {\n    return \"Running\";\n  }\n  if (status === \"error\") {\n    return \"Failed\";\n  }\n  return seconds === undefined ? \"Ran\" : `Ran in ${seconds.toFixed(1)}s`;\n};\n\nfunction StatusLabel({\n  status,\n  seconds,\n}: {\n  status: CodeRunStatus;\n  seconds?: number;\n}) {\n  const ended = status === \"done\" || status === \"error\";\n  return (\n    <>\n      {ended ? (\n        <span\n          className={cn(\n            \"size-1.5 rounded-full\",\n            status === \"done\" ? \"bg-emerald-500\" : \"bg-rose-500\"\n          )}\n        />\n      ) : (\n        <span className=\"relative flex size-1.5\">\n          <motion.span\n            animate={{ opacity: [1, 0.3, 1] }}\n            className={cn(\n              \"size-1.5 rounded-full\",\n              status === \"writing\" ? \"bg-zinc-400\" : \"bg-violet-500\"\n            )}\n            transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY }}\n          />\n        </span>\n      )}\n      {statusText(status, seconds)}\n    </>\n  );\n}\n\n/** The chart the script drew. Bars grow in one after another. */\nfunction Chart({ bars, max }: { bars: CodeRunBar[]; max: number }) {\n  const last = bars.length - 1;\n  return (\n    <figure className=\"px-3.5 pt-3 pb-3.5\">\n      <div className=\"relative flex h-28 items-end gap-1.5 sm:gap-2\">\n        <span className=\"pointer-events-none absolute inset-x-0 top-0 border-black/[0.06] border-t border-dashed dark:border-white/[0.08]\" />\n        <span className=\"pointer-events-none absolute inset-x-0 top-1/2 border-black/[0.06] border-t border-dashed dark:border-white/[0.08]\" />\n        {bars.map((bar, i) => (\n          <div\n            className=\"relative flex h-full min-w-0 flex-1 flex-col justify-end\"\n            key={bar.label}\n          >\n            {i === last && (\n              <motion.span\n                animate={{ opacity: 1, y: 0 }}\n                className=\"mb-1 self-center font-medium text-[11px] text-zinc-900 tabular-nums dark:text-zinc-100\"\n                initial={{ opacity: 0, y: 4 }}\n                transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.6 }}\n              >\n                {bar.value}\n              </motion.span>\n            )}\n            <motion.div\n              animate={{ clipPath: \"inset(0% 0 0 0)\" }}\n              className={cn(\n                \"rounded-t-[4px] rounded-b-[2px]\",\n                i === last\n                  ? \"bg-violet-500 dark:bg-violet-400\"\n                  : \"bg-zinc-200 dark:bg-zinc-700\"\n              )}\n              initial={{ clipPath: \"inset(100% 0 0 0)\" }}\n              style={{ height: `${(bar.value / max) * 82}%` }}\n              title={`${bar.label}: ${bar.value}`}\n              transition={{ duration: 0.45, ease: EASE_OUT, delay: i * 0.05 }}\n            />\n          </div>\n        ))}\n      </div>\n      <figcaption className=\"mt-1.5 flex gap-1.5 sm:gap-2\">\n        {bars.map((bar) => (\n          <span\n            className=\"min-w-0 flex-1 truncate text-center text-[10px] text-zinc-400 tabular-nums dark:text-zinc-500\"\n            key={bar.label}\n          >\n            {bar.label}\n          </span>\n        ))}\n      </figcaption>\n    </figure>\n  );\n}\n\n/** Eases the card to its content's height when the content grows. */\nfunction AutoHeight({\n  className,\n  children,\n}: {\n  className?: string;\n  children: ReactNode;\n}) {\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n\n  useEffect(() => {\n    const inner = innerRef.current;\n    if (!inner) {\n      return;\n    }\n    const observer = new ResizeObserver(() => setHeight(inner.offsetHeight));\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <motion.div\n      animate={{ height }}\n      className={cn(\"overflow-hidden\", className)}\n      initial={false}\n      transition={{ duration: 0.3, ease: EASE_DRAWER }}\n    >\n      <div className=\"p-1\" ref={innerRef}>\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n",
      "path": "/components/kokonutui/ai-code-run.tsx",
      "target": "components/kokonutui/ai-code-run.tsx"
    }
  ]
}