{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "terminal",
  "type": "registry:ui",
  "title": "Terminal",
  "description": "Streamed command output that behaves like a real terminal: ANSI colours, progress-bar rewrites, stick-to-bottom, live clock and exit-code badge.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/icon-button.json",
    "https://kuro.abstergo.fyi/r/kura-format.json",
    "https://kuro.abstergo.fyi/r/status-glyph.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "https://kuro.abstergo.fyi/r/use-elapsed.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/terminal/ansi.ts",
      "type": "registry:ui",
      "target": "components/kura/terminal/ansi.ts",
      "content": "/**\n * A small terminal \"screen\" for rendering command output: SGR colours and text styles, plus the\n * cursor controls real CLIs use for progress bars and spinners (`\\r`, erase line, cursor up,\n * backspace). Fed incrementally, so streaming output is parsed once. Pure, no DOM.\n */\n\nexport type AnsiColor =\n  | \"black\"\n  | \"red\"\n  | \"green\"\n  | \"yellow\"\n  | \"blue\"\n  | \"magenta\"\n  | \"cyan\"\n  | \"white\"\n  | \"gray\";\n\nexport interface AnsiStyle {\n  fg?: AnsiColor;\n  bg?: AnsiColor;\n  bold?: boolean;\n  dim?: boolean;\n  italic?: boolean;\n  underline?: boolean;\n  inverse?: boolean;\n  strike?: boolean;\n}\n\nexport interface AnsiSpan {\n  text: string;\n  style: AnsiStyle;\n}\n\nexport interface ScreenLine {\n  text: string;\n  /** Style id per character (index into the screen's style table). */\n  styles: number[];\n  /** Bumped on every change, for memoised rendering. */\n  version: number;\n}\n\nconst BASIC: AnsiColor[] = [\"black\", \"red\", \"green\", \"yellow\", \"blue\", \"magenta\", \"cyan\", \"white\"];\n\nfunction styleKey(s: AnsiStyle): string {\n  return `${s.fg ?? \"\"}|${s.bg ?? \"\"}|${s.bold ? 1 : 0}${s.dim ? 1 : 0}${s.italic ? 1 : 0}${s.underline ? 1 : 0}${s.inverse ? 1 : 0}${s.strike ? 1 : 0}`;\n}\n\n/** Map a 256-colour index to the nearest basic colour (0–15 exactly, greys to gray). */\nfunction color256(n: number): AnsiColor | undefined {\n  if (n < 8) return BASIC[n];\n  if (n === 8) return \"gray\";\n  if (n < 16) return BASIC[n - 8];\n  if (n >= 232) return n < 244 ? \"gray\" : \"white\";\n  // 6×6×6 cube: pick the dominant channel.\n  const v = n - 16;\n  const r = Math.floor(v / 36);\n  const g = Math.floor((v % 36) / 6);\n  const b = v % 6;\n  if (r === g && g === b) return r < 3 ? \"gray\" : \"white\";\n  const max = Math.max(r, g, b);\n  const hi = [r === max, g === max, b === max];\n  if (hi[0] && hi[1]) return \"yellow\";\n  if (hi[0] && hi[2]) return \"magenta\";\n  if (hi[1] && hi[2]) return \"cyan\";\n  return hi[0] ? \"red\" : hi[1] ? \"green\" : \"blue\";\n}\n\nfunction applySgr(style: AnsiStyle, params: number[]): AnsiStyle {\n  let s: AnsiStyle = { ...style };\n  if (params.length === 0) params = [0];\n  for (let i = 0; i < params.length; i++) {\n    const p = params[i]!;\n    if (p === 0) s = {};\n    else if (p === 1) s.bold = true;\n    else if (p === 2) s.dim = true;\n    else if (p === 3) s.italic = true;\n    else if (p === 4) s.underline = true;\n    else if (p === 7) s.inverse = true;\n    else if (p === 9) s.strike = true;\n    else if (p === 22) {\n      s.bold = false;\n      s.dim = false;\n    } else if (p === 23) s.italic = false;\n    else if (p === 24) s.underline = false;\n    else if (p === 27) s.inverse = false;\n    else if (p === 29) s.strike = false;\n    else if (p >= 30 && p <= 37) s.fg = BASIC[p - 30];\n    else if (p === 39) s.fg = undefined;\n    else if (p >= 40 && p <= 47) s.bg = BASIC[p - 40];\n    else if (p === 49) s.bg = undefined;\n    else if (p >= 90 && p <= 97) s.fg = p === 90 ? \"gray\" : BASIC[p - 90];\n    else if (p >= 100 && p <= 107) s.bg = p === 100 ? \"gray\" : BASIC[p - 100];\n    else if (p === 38 || p === 48) {\n      // 38;5;n / 38;2;r;g;b — extended colours, approximated to the nearest basic colour.\n      const mode = params[i + 1];\n      let c: AnsiColor | undefined;\n      if (mode === 5) {\n        c = color256(params[i + 2] ?? 0);\n        i += 2;\n      } else if (mode === 2) {\n        const [r = 0, g = 0, b = 0] = params.slice(i + 2, i + 5);\n        const max = Math.max(r, g, b);\n        const n =\n          16 +\n          36 * Math.round((r / 255) * 5) +\n          6 * Math.round((g / 255) * 5) +\n          Math.round((b / 255) * 5);\n        c = max < 40 ? \"black\" : color256(n);\n        i += 4;\n      }\n      if (p === 38) s.fg = c;\n      else s.bg = c;\n    }\n  }\n  for (const k of Object.keys(s) as Array<keyof AnsiStyle>) if (!s[k]) delete s[k];\n  return s;\n}\n\nexport class AnsiScreen {\n  lines: ScreenLine[] = [{ text: \"\", styles: [], version: 0 }];\n  /** Style table; id 0 is the default style. */\n  styles: AnsiStyle[] = [{}];\n  private ids = new Map<string, number>([[\"||000000\", 0]]);\n  private style: AnsiStyle = {};\n  private styleId = 0;\n  private row = 0;\n  private col = 0;\n  /** An escape sequence split across chunks. */\n  private pending = \"\";\n  private version = 0;\n\n  /** Feed more output. */\n  write(chunk: string): void {\n    const s = this.pending + chunk;\n    this.pending = \"\";\n    let i = 0;\n    let run = \"\";\n    const flush = () => {\n      if (run) this.put(run);\n      run = \"\";\n    };\n    while (i < s.length) {\n      const ch = s[i]!;\n      if (ch === \"\\x1b\") {\n        flush();\n        const next = s[i + 1];\n        if (next === undefined) {\n          this.pending = s.slice(i);\n          return;\n        }\n        if (next === \"[\") {\n          // CSI: ESC [ params intermediates final\n          const m = /^\\[([0-?]*)([ -/]*)([@-~])/.exec(s.slice(i + 1, i + 64));\n          if (!m) {\n            if (s.length - i < 64 && /^\\[[0-?]*[ -/]*$/.test(s.slice(i + 1))) {\n              this.pending = s.slice(i);\n              return;\n            }\n            i += 2;\n            continue;\n          }\n          this.csi(m[1]!, m[3]!);\n          i += m[0].length + 1;\n          continue;\n        }\n        if (next === \"]\") {\n          // OSC (titles, hyperlinks): skip to BEL or ESC \\\n          const bel = s.indexOf(\"\\x07\", i);\n          const st = s.indexOf(\"\\x1b\\\\\", i);\n          const end = bel < 0 ? st : st < 0 ? bel : Math.min(bel, st);\n          if (end < 0) {\n            this.pending = s.slice(i);\n            return;\n          }\n          i = end + (end === st ? 2 : 1);\n          continue;\n        }\n        // Two- or three-byte escapes (charset selection, save/restore cursor…): ignore.\n        i += next === \"(\" || next === \")\" ? 3 : 2;\n        continue;\n      }\n      if (ch === \"\\n\") {\n        flush();\n        this.row++;\n        this.col = 0;\n        this.ensureRow();\n      } else if (ch === \"\\r\") {\n        flush();\n        // \\r\\n is just a newline.\n        if (s[i + 1] !== \"\\n\") this.col = 0;\n      } else if (ch === \"\\b\") {\n        flush();\n        this.col = Math.max(0, this.col - 1);\n      } else if (ch === \"\\x07\" || (ch < \" \" && ch !== \"\\t\")) {\n        flush();\n      } else {\n        run += ch;\n      }\n      i++;\n    }\n    flush();\n  }\n\n  private ensureRow() {\n    while (this.lines.length <= this.row)\n      this.lines.push({ text: \"\", styles: [], version: ++this.version });\n  }\n\n  private touch(line: ScreenLine) {\n    line.version = ++this.version;\n  }\n\n  private put(text: string) {\n    this.ensureRow();\n    const line = this.lines[this.row]!;\n    if (this.col > line.text.length) {\n      const pad = this.col - line.text.length;\n      line.text += \" \".repeat(pad);\n      for (let k = 0; k < pad; k++) line.styles.push(0);\n    }\n    const end = this.col + text.length;\n    line.text = line.text.slice(0, this.col) + text + line.text.slice(end);\n    line.styles = line.styles\n      .slice(0, this.col)\n      .concat(new Array<number>(text.length).fill(this.styleId), line.styles.slice(end));\n    this.col = end;\n    this.touch(line);\n  }\n\n  private csi(rawParams: string, final: string) {\n    const params = rawParams\n      .replace(/^[?>=]/, \"\")\n      .split(/[;:]/)\n      .filter((p) => p !== \"\")\n      .map(Number);\n    const n = params[0] ?? 1;\n    switch (final) {\n      case \"m\": {\n        this.style = applySgr(\n          this.style,\n          rawParams === \"\" ? [] : rawParams.split(/[;:]/).map((p) => Number(p) || 0),\n        );\n        const key = styleKey(this.style);\n        let id = this.ids.get(key);\n        if (id === undefined) {\n          id = this.styles.length;\n          this.styles.push(this.style);\n          this.ids.set(key, id);\n        }\n        this.styleId = id;\n        break;\n      }\n      case \"A\": // cursor up\n      case \"F\":\n        this.row = Math.max(0, this.row - n);\n        if (final === \"F\") this.col = 0;\n        break;\n      case \"B\": // cursor down\n      case \"E\":\n        this.row += n;\n        this.ensureRow();\n        if (final === \"E\") this.col = 0;\n        break;\n      case \"C\":\n        this.col += n;\n        break;\n      case \"D\":\n        this.col = Math.max(0, this.col - n);\n        break;\n      case \"G\":\n        this.col = Math.max(0, n - 1);\n        break;\n      case \"K\": {\n        // erase in line: 0 = to end, 1 = to start, 2 = whole line\n        this.ensureRow();\n        const line = this.lines[this.row]!;\n        const mode = params[0] ?? 0;\n        if (mode === 0) {\n          line.text = line.text.slice(0, this.col);\n          line.styles.length = Math.min(line.styles.length, this.col);\n        } else if (mode === 1) {\n          line.text = \" \".repeat(Math.min(this.col, line.text.length)) + line.text.slice(this.col);\n          line.styles.fill(0, 0, this.col);\n        } else {\n          line.text = \"\";\n          line.styles = [];\n        }\n        this.touch(line);\n        break;\n      }\n      case \"J\": {\n        // erase in display: 0 = below (and rest of line), 2/3 = everything\n        const mode = params[0] ?? 0;\n        if (mode === 0) {\n          this.csi(\"0\", \"K\");\n          this.lines.length = this.row + 1;\n        } else if (mode >= 2) {\n          this.lines = [{ text: \"\", styles: [], version: ++this.version }];\n          this.row = 0;\n          this.col = 0;\n        }\n        break;\n      }\n      default:\n        // Other CSI sequences (cursor show/hide, scroll regions, modes) don't affect text.\n        break;\n    }\n  }\n\n  /** The lines as styled spans. */\n  spans(line: ScreenLine): AnsiSpan[] {\n    const out: AnsiSpan[] = [];\n    let start = 0;\n    for (let k = 1; k <= line.text.length; k++) {\n      if (k === line.text.length || line.styles[k] !== line.styles[start]) {\n        out.push({ text: line.text.slice(start, k), style: this.styles[line.styles[start] ?? 0]! });\n        start = k;\n      }\n    }\n    return out;\n  }\n}\n\n/** Parse a complete string into lines of styled spans. */\nexport function parseAnsi(text: string): AnsiSpan[][] {\n  const screen = new AnsiScreen();\n  screen.write(text);\n  return screen.lines.map((l) => screen.spans(l));\n}\n\n/** Remove escape sequences and apply carriage returns: the text a user would copy. */\nexport function stripAnsi(text: string): string {\n  const screen = new AnsiScreen();\n  screen.write(text);\n  return screen.lines.map((l) => l.text.replace(/\\s+$/, \"\")).join(\"\\n\");\n}\n"
    },
    {
      "path": "registry/kura/terminal/index.ts",
      "type": "registry:ui",
      "target": "components/kura/terminal/index.ts",
      "content": "export {\n  type AnsiColor,\n  AnsiScreen,\n  type AnsiSpan,\n  type AnsiStyle,\n  parseAnsi,\n  stripAnsi,\n} from \"./ansi\";\nexport { Terminal, type TerminalProps, type TerminalStatus } from \"./terminal\";\n"
    },
    {
      "path": "registry/kura/terminal/terminal.tsx",
      "type": "registry:ui",
      "target": "components/kura/terminal/terminal.tsx",
      "content": "\"use client\";\n\nimport { ArrowDown, Check, ChevronsUpDown, Copy } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock, formatDuration } from \"@/lib/kura/format\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { StatusGlyph } from \"@/components/kura/status-glyph\";\nimport { type AnsiColor, AnsiScreen, type AnsiStyle, type ScreenLine } from \"./ansi\";\n\nexport type TerminalStatus = \"idle\" | \"running\" | \"done\" | \"failed\";\n\nexport interface TerminalProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\" | \"onCopy\"> {\n  /** The command that was run, shown on the prompt line, e.g. \"pnpm test\". */\n  command?: string;\n  /** Output: an array of lines, or one (growing) string. ANSI colour codes and `\\r` progress updates are rendered. */\n  lines?: readonly string[] | string;\n  /** Output as an async stream of chunks (e.g. a child process's stdout). Takes precedence over `lines`. */\n  stream?: AsyncIterable<string>;\n  /** Called once `stream` has ended. */\n  onStreamEnd?: () => void;\n  /** Lifecycle. Default: from `exitCode` (0 → done, else failed), \"running\" while `stream` is open, otherwise \"idle\". */\n  status?: TerminalStatus;\n  /** Process exit code, shown as a badge once finished. */\n  exitCode?: number | null;\n  /** When the command started, for the live clock. Default: when status became \"running\". */\n  startedAt?: Date | number;\n  /** Final duration in ms (persisted runs). Default: measured from running → finished. */\n  duration?: number;\n  /** Working directory shown in the header, e.g. \"~/acme/web\". */\n  cwd?: string;\n  /** Header title. Default: the cwd, or \"Terminal\". */\n  title?: React.ReactNode;\n  /** Prompt glyph before the command. Default \"$\". */\n  prompt?: string;\n  /** Height of the scrolling viewport in px. Default 320. Output sticks to the bottom while you're there. */\n  maxHeight?: number;\n  /** Collapse to the last N lines with a \"Show all N lines\" control. */\n  maxLines?: number;\n  /** Showing every line past `maxLines` (controlled). */\n  expanded?: boolean;\n  /** Initial expanded state when uncontrolled. Default false. */\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Soft-wrap long lines. Default true. */\n  wrap?: boolean;\n  /** Show the copy button (copies the command and plain output). Default true. */\n  copyable?: boolean;\n  /** Called with the copied text after a successful copy. */\n  onCopy?: (text: string) => void;\n  /** Show the header row. Default true. */\n  header?: boolean;\n  /** Extra header controls, e.g. a Stop button. */\n  actions?: React.ReactNode;\n}\n\nconst COLOR_VAR: Record<AnsiColor, string> = {\n  black: \"var(--kura-terminal-black, var(--kura-fg-subtle))\",\n  red: \"var(--kura-terminal-red, var(--kura-danger-text))\",\n  green: \"var(--kura-terminal-green, var(--kura-success-text))\",\n  yellow: \"var(--kura-terminal-yellow, var(--kura-warning-text))\",\n  blue: \"var(--kura-terminal-blue, var(--kura-accent-text))\",\n  magenta: \"var(--kura-terminal-magenta, oklch(from var(--kura-fg-muted) l 0.15 330))\",\n  cyan: \"var(--kura-terminal-cyan, oklch(from var(--kura-fg-muted) l 0.1 205))\",\n  white: \"var(--kura-terminal-white, var(--kura-fg))\",\n  gray: \"var(--kura-terminal-gray, var(--kura-fg-subtle))\",\n};\n\nfunction spanStyle(s: AnsiStyle): { className?: string; style?: React.CSSProperties } {\n  const fg = s.fg && COLOR_VAR[s.fg];\n  const bg = s.bg && COLOR_VAR[s.bg];\n  const style: React.CSSProperties = {};\n  if (s.inverse) {\n    style.backgroundColor = fg ?? \"var(--kura-fg)\";\n    style.color = bg ?? \"var(--kura-surface-sunken)\";\n  } else {\n    if (fg) style.color = fg;\n    if (bg) {\n      style.backgroundColor = bg;\n      if (!fg) style.color = \"var(--kura-surface-sunken)\";\n    }\n  }\n  const className = cn(\n    s.bold && \"font-semibold\",\n    s.dim && \"opacity-60\",\n    s.italic && \"italic\",\n    s.underline && \"underline underline-offset-2\",\n    s.strike && \"line-through\",\n    (s.inverse || s.bg) && \"rounded-[2px]\",\n  );\n  return {\n    className: className || undefined,\n    style: Object.keys(style).length ? style : undefined,\n  };\n}\n\n/** Incrementally feeds append-only text into a screen, re-parsing only when the text was replaced. */\nfunction useScreen(text: string) {\n  const ref = React.useRef<{ fed: string; screen: AnsiScreen } | null>(null);\n  if (!ref.current || !text.startsWith(ref.current.fed)) {\n    ref.current = { fed: \"\", screen: new AnsiScreen() };\n  }\n  const cur = ref.current;\n  if (text.length > cur.fed.length) {\n    cur.screen.write(text.slice(cur.fed.length));\n    cur.fed = text;\n  }\n  return cur.screen;\n}\n\nconst Row = React.memo(function Row({\n  screen,\n  line,\n  cursor,\n}: {\n  screen: AnsiScreen;\n  line: ScreenLine;\n  version: number;\n  cursor: boolean;\n}) {\n  const spans = screen.spans(line);\n  return (\n    <div data-slot=\"terminal-line\" className=\"min-h-5\">\n      {spans.map((s, i) => {\n        const { className, style } = spanStyle(s.style);\n        return className || style ? (\n          <span key={i} className={className} style={style}>\n            {s.text}\n          </span>\n        ) : (\n          <React.Fragment key={i}>{s.text}</React.Fragment>\n        );\n      })}\n      {cursor && <Cursor />}\n    </div>\n  );\n});\n\nfunction Cursor() {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"terminal-cursor\"\n      className=\"ml-px inline-block h-[15px] w-[7.5px] translate-y-[3px] bg-kura-fg/75 animate-kura-pulse motion-reduce:animate-none\"\n    />\n  );\n}\n\n/**\n * Streamed command output that looks and behaves like a real terminal: a prompt line, ANSI\n * colours mapped to Kura tokens, carriage-return progress bars, a live clock and exit-code badge,\n * stick-to-bottom scrolling with \"Jump to latest\", a collapsed tail with \"Show all N lines\", and\n * copy of the plain text.\n */\nexport function Terminal({\n  command,\n  lines,\n  stream,\n  onStreamEnd,\n  status: statusProp,\n  exitCode,\n  startedAt,\n  duration,\n  cwd,\n  title,\n  prompt = \"$\",\n  maxHeight = 320,\n  maxLines,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  wrap = true,\n  copyable = true,\n  onCopy,\n  header = true,\n  actions,\n  className,\n  style,\n  ...props\n}: TerminalProps) {\n  // --- output ---------------------------------------------------------------------------------\n  const [streamed, setStreamed] = React.useState(\"\");\n  const [streamOpen, setStreamOpen] = React.useState(() => stream != null);\n  const onStreamEndRef = React.useRef(onStreamEnd);\n  onStreamEndRef.current = onStreamEnd;\n  React.useEffect(() => {\n    if (!stream) return;\n    let cancelled = false;\n    setStreamed(\"\");\n    setStreamOpen(true);\n    (async () => {\n      try {\n        for await (const chunk of stream) {\n          if (cancelled) return;\n          setStreamed((prev) => prev + chunk);\n        }\n      } catch {\n        // A failing stream ends the output; the caller reports the error via status/exitCode.\n      }\n      if (cancelled) return;\n      setStreamOpen(false);\n      onStreamEndRef.current?.();\n    })();\n    return () => {\n      cancelled = true;\n    };\n  }, [stream]);\n\n  const text = stream ? streamed : typeof lines === \"string\" ? lines : (lines ?? []).join(\"\\n\");\n  const screen = useScreen(text);\n\n  const status: TerminalStatus =\n    statusProp ??\n    (exitCode != null\n      ? exitCode === 0\n        ? \"done\"\n        : \"failed\"\n      : stream && streamOpen\n        ? \"running\"\n        : \"idle\");\n  const running = status === \"running\";\n\n  // Drop the empty line after a trailing newline, except where the cursor sits while running.\n  let rows = screen.lines;\n  if (rows.length > 0 && rows[rows.length - 1]!.text === \"\" && (!running || !command))\n    rows = rows.slice(0, -1);\n  if (rows.length === 1 && rows[0]!.text === \"\" && !running) rows = [];\n  const total = rows.length;\n\n  // --- timing ---------------------------------------------------------------------------------\n  const runStart = React.useRef<number | null>(null);\n  if (running && runStart.current == null) runStart.current = Date.now();\n  const since = startedAt ?? runStart.current;\n  const elapsed = useElapsed(since, running, 1000);\n  const shownDuration = duration ?? (status === \"done\" || status === \"failed\" ? elapsed : null);\n\n  // --- collapse -------------------------------------------------------------------------------\n  const [expanded, setExpanded] = useControllableState({\n    value: expandedProp,\n    defaultValue: defaultExpanded,\n    onChange: onExpandedChange,\n  });\n  const collapsed = maxLines != null && total > maxLines && !expanded;\n  const visible = collapsed ? rows.slice(total - maxLines) : rows;\n\n  // --- stick to bottom ------------------------------------------------------------------------\n  const viewport = React.useRef<HTMLDivElement>(null);\n  const stuck = React.useRef(true);\n  const [atBottom, setAtBottom] = React.useState(true);\n  const [unseen, setUnseen] = React.useState(0);\n  const lastTotal = React.useRef(total);\n  const lastVersion = rows[rows.length - 1]?.version ?? 0;\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the last line changes (lastVersion) or the collapse changes the height\n  React.useLayoutEffect(() => {\n    const el = viewport.current;\n    const added = total - lastTotal.current;\n    lastTotal.current = total;\n    if (!el) return;\n    if (stuck.current) el.scrollTop = el.scrollHeight;\n    else if (added > 0) setUnseen((n) => n + added);\n  }, [total, lastVersion, collapsed]);\n  const onScroll = () => {\n    const el = viewport.current;\n    if (!el) return;\n    const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;\n    stuck.current = bottom;\n    setAtBottom(bottom);\n    if (bottom) setUnseen(0);\n  };\n  const jumpToLatest = () => {\n    const el = viewport.current;\n    if (!el) return;\n    stuck.current = true;\n    setAtBottom(true);\n    setUnseen(0);\n    el.scrollTo?.({ top: el.scrollHeight, behavior: \"smooth\" });\n    if (!el.scrollTo) el.scrollTop = el.scrollHeight;\n  };\n\n  // --- copy -----------------------------------------------------------------------------------\n  const [copied, setCopied] = React.useState(false);\n  const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(copyTimer.current), []);\n  const copy = async () => {\n    const out = [\n      command ? `${prompt} ${command}` : null,\n      ...rows.map((l) => l.text.replace(/\\s+$/, \"\")),\n    ]\n      .filter((l) => l != null)\n      .join(\"\\n\");\n    try {\n      await navigator.clipboard.writeText(out);\n      setCopied(true);\n      onCopy?.(out);\n      clearTimeout(copyTimer.current);\n      copyTimer.current = setTimeout(() => setCopied(false), 1600);\n    } catch {\n      // Clipboard unavailable (insecure context): nothing to do.\n    }\n  };\n\n  // --- announcements (only on finish, never per line) -----------------------------------------\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const prevStatus = React.useRef(status);\n  React.useEffect(() => {\n    if (prevStatus.current === status) return;\n    prevStatus.current = status;\n    const what = command ?? \"Command\";\n    if (status === \"running\") setAnnouncement(`${what} running`);\n    else if (status === \"done\")\n      setAnnouncement(`${what} finished${exitCode != null ? `, exit code ${exitCode}` : \"\"}`);\n    else if (status === \"failed\")\n      setAnnouncement(`${what} failed${exitCode != null ? `, exit code ${exitCode}` : \"\"}`);\n  }, [status, command, exitCode]);\n\n  const finished = status === \"done\" || status === \"failed\";\n  const heading = title ?? cwd ?? \"Terminal\";\n\n  return (\n    <div\n      data-slot=\"terminal\"\n      data-status={status}\n      data-state={\n        maxLines != null && total > maxLines ? (expanded ? \"expanded\" : \"collapsed\") : undefined\n      }\n      className={cn(\n        \"@container/terminal relative flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      style={style}\n      {...props}\n    >\n      {header && (\n        <div\n          data-slot=\"terminal-header\"\n          className=\"flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b pr-1.5 pl-3.5\"\n        >\n          {status !== \"idle\" && (\n            <StatusGlyph\n              status={status === \"running\" ? \"running\" : status === \"done\" ? \"done\" : \"failed\"}\n              size=\"sm\"\n              label={null}\n            />\n          )}\n          <span\n            data-slot=\"terminal-title\"\n            className=\"min-w-0 truncate font-medium text-[13px] tracking-[-0.006em]\"\n          >\n            {heading}\n          </span>\n          {title != null && cwd && (\n            <span className=\"hidden min-w-0 truncate font-kura-mono text-[12px] text-kura-fg-subtle @[24rem]/terminal:inline\">\n              {cwd}\n            </span>\n          )}\n          <span className=\"flex-1\" />\n          <span\n            data-slot=\"terminal-meta\"\n            className=\"flex shrink-0 items-center gap-2 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            {running && elapsed != null && (\n              <span data-slot=\"terminal-elapsed\" className=\"min-w-[4ch] text-right\">\n                {formatClock(elapsed)}\n              </span>\n            )}\n            {finished && shownDuration != null && (\n              <span data-slot=\"terminal-elapsed\">{formatDuration(shownDuration)}</span>\n            )}\n            {finished && exitCode != null && (\n              <span\n                data-slot=\"terminal-exit-code\"\n                className={cn(\n                  \"inline-flex h-5 items-center rounded-kura-xs px-1.5 animate-kura-fade-in\",\n                  exitCode === 0\n                    ? \"bg-kura-hover text-kura-fg-muted\"\n                    : \"bg-kura-danger/10 text-kura-danger-text\",\n                )}\n              >\n                exit {exitCode}\n              </span>\n            )}\n          </span>\n          {(copyable || actions) && (\n            <span className=\"flex shrink-0 items-center gap-0.5\">\n              {actions}\n              {copyable && (\n                <IconButton\n                  size=\"sm\"\n                  label={copied ? \"Copied\" : \"Copy output\"}\n                  data-copied={copied}\n                  onClick={copy}\n                  icon={\n                    <span className=\"relative grid size-3.5 place-items-center [&>svg]:col-start-1 [&>svg]:row-start-1\">\n                      <Copy\n                        className={cn(\n                          \"size-3.5 transition-[opacity,scale] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\",\n                          copied && \"scale-75 opacity-0 motion-reduce:scale-100\",\n                        )}\n                      />\n                      <Check\n                        className={cn(\n                          \"size-3.5 text-kura-success-text transition-[opacity,scale] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\",\n                          !copied && \"scale-75 opacity-0 motion-reduce:scale-100\",\n                        )}\n                      />\n                    </span>\n                  }\n                />\n              )}\n            </span>\n          )}\n        </div>\n      )}\n\n      <div className=\"relative min-h-0 flex-1 bg-kura-surface-sunken\">\n        <div\n          ref={viewport}\n          data-slot=\"terminal-output\"\n          role=\"log\"\n          aria-live=\"off\"\n          aria-label={command ? `Output of ${command}` : \"Terminal output\"}\n          // biome-ignore lint/a11y/noNoninteractiveTabindex: the scrollable log must be keyboard-scrollable\n          tabIndex={0}\n          onScroll={onScroll}\n          className={cn(\n            \"h-full overflow-y-auto overscroll-contain px-3.5 py-3 font-kura-mono text-[12.5px] text-kura-fg-muted leading-5 outline-none [scrollbar-width:thin] [tab-size:4]\",\n            \"focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n            wrap ? \"whitespace-pre-wrap break-all\" : \"whitespace-pre\",\n          )}\n          style={{ maxHeight }}\n        >\n          <div className={wrap ? undefined : \"w-max min-w-full\"}>\n            {command && (\n              <div data-slot=\"terminal-command\" className=\"min-h-5 text-kura-fg\">\n                <span aria-hidden className=\"mr-2 select-none text-kura-fg-subtle\">\n                  {prompt}\n                </span>\n                <span className=\"font-medium\">{command}</span>\n                {running && total === 0 && <Cursor />}\n              </div>\n            )}\n            {collapsed && (\n              <button\n                type=\"button\"\n                data-slot=\"terminal-expand\"\n                aria-expanded={false}\n                onClick={() => setExpanded(true)}\n                className=\"my-1 flex h-6 w-full cursor-pointer items-center gap-2 rounded-kura-sm px-1.5 -mx-1.5 font-kura-sans text-[12px] text-kura-fg-subtle outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover hover:text-kura-fg-muted focus-visible:shadow-kura-focus motion-reduce:transition-none\"\n                style={{ width: \"calc(100% + 0.75rem)\" }}\n              >\n                <ChevronsUpDown aria-hidden className=\"size-3.5\" strokeWidth={1.75} />\n                Show all {total} lines\n                <span className=\"h-px flex-1 bg-kura-border-subtle\" />\n              </button>\n            )}\n            {visible.map((line, i) => (\n              <Row\n                key={collapsed ? total - maxLines + i : i}\n                screen={screen}\n                line={line}\n                version={line.version}\n                cursor={running && i === visible.length - 1 && (total > 0 || !command)}\n              />\n            ))}\n            {!command && total === 0 && !running && (\n              <div className=\"font-kura-sans text-[13px] text-kura-fg-subtle\">No output</div>\n            )}\n            {expanded && maxLines != null && total > maxLines && (\n              <button\n                type=\"button\"\n                data-slot=\"terminal-collapse\"\n                aria-expanded\n                onClick={() => setExpanded(false)}\n                className=\"mt-1 inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-kura-sm px-1.5 -mx-1.5 font-kura-sans text-[12px] text-kura-fg-subtle outline-none hover:bg-kura-hover hover:text-kura-fg-muted focus-visible:shadow-kura-focus\"\n              >\n                Show last {maxLines} lines\n              </button>\n            )}\n          </div>\n        </div>\n        {!atBottom && (\n          <button\n            type=\"button\"\n            data-slot=\"terminal-jump\"\n            onClick={jumpToLatest}\n            className={cn(\n              \"absolute bottom-3 left-1/2 inline-flex h-7 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full bg-kura-surface-raised px-3 text-[12px] text-kura-fg-muted shadow-kura-md outline-none animate-kura-pop-in\",\n              \"transition-colors duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus motion-reduce:transition-none\",\n            )}\n          >\n            <ArrowDown aria-hidden className=\"size-3.5\" strokeWidth={1.75} />\n            {unseen > 0 ? `${unseen} new ${unseen === 1 ? \"line\" : \"lines\"}` : \"Jump to latest\"}\n          </button>\n        )}\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n        {copied ? \" Copied to clipboard\" : \"\"}\n      </span>\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "workspace",
    "terminal",
    "shell",
    "logs",
    "ansi"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/terminal · Markdown for agents: https://kuro.abstergo.fyi/components/terminal.md",
  "meta": {
    "kura": {
      "status": "experimental",
      "whenToUse": [
        "Show a shell command an agent ran (pnpm test, git status, a build) with its output, while it runs and after.",
        "Stream a child process's stdout (AsyncIterable<string>) or a growing log string into the UI.",
        "Long logs where the tail matters: collapse to the last N lines with “Show all N lines”.",
        "A dev-server or watcher log with a Stop control in the header."
      ],
      "whenNotToUse": [
        "Source code or a snippet to copy — use code-block.",
        "Reviewing file edits — use diff-view.",
        "A tool call whose output is structured JSON — use tool-call.",
        "Interactive shells that take keyboard input — this renders output only (no PTY, no input line)."
      ]
    }
  }
}
