{
  "name": "terminal-surface",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "terminal-surface.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, keyframes, themeVars as theme } from \"@yugnex/core\";\r\nimport {\r\n  useEffect,\r\n  useLayoutEffect,\r\n  useMemo,\r\n  useRef,\r\n  useState,\r\n  type CSSProperties,\r\n  type ReactNode,\r\n} from \"react\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * ANSI parsing\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface AnsiStyle {\r\n  color?: string;\r\n  background?: string;\r\n  bold?: boolean;\r\n  dim?: boolean;\r\n  italic?: boolean;\r\n  underline?: boolean;\r\n  /** SGR 7 — swap foreground and background at render time. */\r\n  inverse?: boolean;\r\n  /** SGR 8 — rendered as a placeholder rather than actually hidden. */\r\n  hidden?: boolean;\r\n  strike?: boolean;\r\n}\r\n\r\nexport interface AnsiSpan {\r\n  text: string;\r\n  style: AnsiStyle;\r\n}\r\n\r\n/**\r\n * The 16 base colours as CSS variables, so a consumer can retheme the palette\r\n * without patching the parser. Values are set in `ANSI_CSS` below.\r\n */\r\nconst BASE_COLORS = [\r\n  \"black\",\r\n  \"red\",\r\n  \"green\",\r\n  \"yellow\",\r\n  \"blue\",\r\n  \"magenta\",\r\n  \"cyan\",\r\n  \"white\",\r\n  \"bright-black\",\r\n  \"bright-red\",\r\n  \"bright-green\",\r\n  \"bright-yellow\",\r\n  \"bright-blue\",\r\n  \"bright-magenta\",\r\n  \"bright-cyan\",\r\n  \"bright-white\",\r\n] as const;\r\n\r\nfunction baseColor(index: number): string {\r\n  return `var(--nx-ansi-${BASE_COLORS[index] ?? \"white\"})`;\r\n}\r\n\r\n/**\r\n * Resolves an xterm-256 index to a colour.\r\n *\r\n * 0-15 map to the themeable base palette; 16-231 are a 6x6x6 RGB cube;\r\n * 232-255 are a 24-step greyscale ramp. Computing the cube and ramp rather\r\n * than shipping a 256-entry table keeps this to a few lines and makes the\r\n * derivation auditable.\r\n */\r\nfunction xterm256(index: number): string {\r\n  if (index < 16) return baseColor(index);\r\n\r\n  if (index < 232) {\r\n    const n = index - 16;\r\n    const steps = [0, 95, 135, 175, 215, 255];\r\n    const r = steps[Math.floor(n / 36) % 6] as number;\r\n    const g = steps[Math.floor(n / 6) % 6] as number;\r\n    const b = steps[n % 6] as number;\r\n    return `rgb(${r} ${g} ${b})`;\r\n  }\r\n\r\n  const level = 8 + (index - 232) * 10;\r\n  return `rgb(${level} ${level} ${level})`;\r\n}\r\n\r\n/** Applies one SGR parameter run to a style, returning the next style. */\r\nfunction applySgr(params: number[], style: AnsiStyle): AnsiStyle {\r\n  const next: AnsiStyle = { ...style };\r\n\r\n  for (let i = 0; i < params.length; i++) {\r\n    const code = params[i] as number;\r\n\r\n    if (code === 0) {\r\n      // Reset clears everything, so return a fresh object rather than\r\n      // deleting keys one at a time.\r\n      for (const key of Object.keys(next) as Array<keyof AnsiStyle>) delete next[key];\r\n      continue;\r\n    }\r\n\r\n    if (code === 1) next.bold = true;\r\n    else if (code === 2) next.dim = true;\r\n    else if (code === 3) next.italic = true;\r\n    else if (code === 4) next.underline = true;\r\n    else if (code === 7) next.inverse = true;\r\n    else if (code === 8) next.hidden = true;\r\n    else if (code === 9) next.strike = true;\r\n    else if (code === 21 || code === 22) {\r\n      delete next.bold;\r\n      delete next.dim;\r\n    } else if (code === 23) delete next.italic;\r\n    else if (code === 24) delete next.underline;\r\n    else if (code === 27) delete next.inverse;\r\n    else if (code === 28) delete next.hidden;\r\n    else if (code === 29) delete next.strike;\r\n    else if (code >= 30 && code <= 37) next.color = baseColor(code - 30);\r\n    else if (code === 39) delete next.color;\r\n    else if (code >= 40 && code <= 47) next.background = baseColor(code - 40);\r\n    else if (code === 49) delete next.background;\r\n    else if (code >= 90 && code <= 97) next.color = baseColor(code - 90 + 8);\r\n    else if (code >= 100 && code <= 107) next.background = baseColor(code - 100 + 8);\r\n    else if (code === 38 || code === 48) {\r\n      // Extended colour: 5;n for 256-colour, 2;r;g;b for truecolor.\r\n      const mode = params[i + 1];\r\n      if (mode === 5 && params[i + 2] !== undefined) {\r\n        const value = xterm256(params[i + 2] as number);\r\n        if (code === 38) next.color = value;\r\n        else next.background = value;\r\n        i += 2;\r\n      } else if (mode === 2 && params[i + 4] !== undefined) {\r\n        const value = `rgb(${params[i + 2]} ${params[i + 3]} ${params[i + 4]})`;\r\n        if (code === 38) next.color = value;\r\n        else next.background = value;\r\n        i += 4;\r\n      }\r\n      // Malformed extended sequence (38/48 not followed by a 2 or 5 mode, or\r\n      // truncated mid-stream): consume only the introducer and let the loop\r\n      // advance normally. Skipping ahead would swallow the *next* parameter,\r\n      // so `ESC[38;1m` would silently lose its bold.\r\n    }\r\n  }\r\n\r\n  return next;\r\n}\r\n\r\n// ESC and BEL as explicit escapes rather than literal control bytes. These\r\n// files are distributed by copy-paste, and a raw 0x1B in source is exactly the\r\n// kind of thing an editor or a clipboard round-trip drops silently — leaving a\r\n// parser that looks right and matches nothing.\r\nconst ESC = \"\\u001b\";\r\nconst BEL = \"\\u0007\";\r\n\r\n// CSI sequences (ESC [ ... final-byte). SGR (`m`) is interpreted; every other\r\n// final byte is recognised only so it can be dropped rather than printed as\r\n// mojibake.\r\nconst CSI_RE = new RegExp(`${ESC}\\\\[([0-9;:?]*)([A-Za-z])`, \"g\");\r\n\r\n// OSC sequences (ESC ] ... BEL | ESC \\\\) — window titles, hyperlinks. Bounded\r\n// so an unterminated OSC cannot swallow the rest of the output.\r\nconst OSC_RE = new RegExp(`${ESC}\\\\][^${ESC}${BEL}]*(?:${BEL}|${ESC}\\\\\\\\)`, \"g\");\r\n\r\n/**\r\n * Parses a chunk of terminal output into styled spans.\r\n *\r\n * Handles SGR colour/attribute codes, xterm-256 and truecolor, and strips the\r\n * non-SGR escape sequences that real tool output is full of (cursor moves,\r\n * line erases, OSC titles) instead of rendering them as garbage. Carriage\r\n * returns are applied as line rewrites, which is what makes progress bars and\r\n * spinners collapse to their final state rather than stacking up.\r\n */\r\nexport function parseAnsi(input: string, initial: AnsiStyle = {}): { spans: AnsiSpan[]; style: AnsiStyle } {\r\n  const withoutOsc = input.replace(OSC_RE, \"\");\r\n\r\n  const spans: AnsiSpan[] = [];\r\n  let style = initial;\r\n  let cursor = 0;\r\n\r\n  CSI_RE.lastIndex = 0;\r\n  let match: RegExpExecArray | null;\r\n\r\n  const push = (text: string) => {\r\n    if (text.length === 0) return;\r\n    const last = spans[spans.length - 1];\r\n    if (last && sameStyle(last.style, style)) last.text += text;\r\n    else spans.push({ text, style: { ...style } });\r\n  };\r\n\r\n  while ((match = CSI_RE.exec(withoutOsc)) !== null) {\r\n    if (match.index > cursor) push(withoutOsc.slice(cursor, match.index));\r\n\r\n    if (match[2] === \"m\") {\r\n      const raw = match[1] ?? \"\";\r\n      const params = raw === \"\" ? [0] : raw.split(\";\").map((part) => Number.parseInt(part, 10) || 0);\r\n      style = applySgr(params, style);\r\n    }\r\n    // Every other final byte (cursor movement, erase, scroll) is consumed and\r\n    // discarded — this is a log surface, not a screen emulator.\r\n\r\n    cursor = match.index + match[0].length;\r\n  }\r\n\r\n  if (cursor < withoutOsc.length) push(withoutOsc.slice(cursor));\r\n\r\n  return { spans, style };\r\n}\r\n\r\nfunction sameStyle(a: AnsiStyle, b: AnsiStyle): boolean {\r\n  return (\r\n    a.color === b.color &&\r\n    a.background === b.background &&\r\n    a.bold === b.bold &&\r\n    a.dim === b.dim &&\r\n    a.italic === b.italic &&\r\n    a.underline === b.underline &&\r\n    a.inverse === b.inverse &&\r\n    a.hidden === b.hidden &&\r\n    a.strike === b.strike\r\n  );\r\n}\r\n\r\n/**\r\n * Splits output into lines, applying carriage-return rewrites.\r\n *\r\n * A `\\r` without a following `\\n` means \"go back to column 0 and overwrite\",\r\n * which is how spinners and progress bars work. Applying it means a hundred\r\n * progress frames collapse into the one line the user would actually have\r\n * seen, instead of a hundred stacked lines.\r\n */\r\nexport function splitTerminalLines(text: string): string[] {\r\n  const out: string[] = [];\r\n\r\n  for (const rawLine of text.split(\"\\n\")) {\r\n    if (!rawLine.includes(\"\\r\")) {\r\n      out.push(rawLine);\r\n      continue;\r\n    }\r\n    // Later segments overwrite earlier ones from column 0; a shorter\r\n    // overwrite leaves the tail of the longer one visible, as a real\r\n    // terminal would.\r\n    let line = \"\";\r\n    for (const segment of rawLine.split(\"\\r\")) {\r\n      line = segment + line.slice(segment.length);\r\n    }\r\n    out.push(line);\r\n  }\r\n\r\n  return out;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Model\r\n * ------------------------------------------------------------------ */\r\n\r\nexport type RunStatus = \"running\" | \"success\" | \"failed\" | \"cancelled\";\r\n\r\nexport interface TerminalRun {\r\n  id: string;\r\n  /** The command as typed, e.g. \"pnpm test --run\". */\r\n  command: string;\r\n  /** Raw output, ANSI escapes included. Safe to grow while streaming. */\r\n  output: string;\r\n  status: RunStatus;\r\n  exitCode?: number;\r\n  /** Milliseconds; shown in the header when present. */\r\n  durationMs?: number;\r\n  /** Working directory shown before the prompt. */\r\n  cwd?: string;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst ANSI_CSS = `\r\n:root{\r\n--nx-ansi-black:#3b3b45;--nx-ansi-red:#c8332c;--nx-ansi-green:#2c8a52;\r\n--nx-ansi-yellow:#a8760a;--nx-ansi-blue:#2f5fd0;--nx-ansi-magenta:#9b3fb5;\r\n--nx-ansi-cyan:#1a7f8e;--nx-ansi-white:#c8c8d0;\r\n--nx-ansi-bright-black:#6b6b78;--nx-ansi-bright-red:#e0554d;--nx-ansi-bright-green:#3aa866;\r\n--nx-ansi-bright-yellow:#c9900f;--nx-ansi-bright-blue:#4a7ae8;--nx-ansi-bright-magenta:#b558cd;\r\n--nx-ansi-bright-cyan:#2199aa;--nx-ansi-bright-white:#f0f0f4;\r\n}\r\n[data-theme=\"dark\"]{\r\n--nx-ansi-black:#2a2a33;--nx-ansi-red:#f0736b;--nx-ansi-green:#5fd18a;\r\n--nx-ansi-yellow:#e0b341;--nx-ansi-blue:#7aa2f7;--nx-ansi-magenta:#d18ae8;\r\n--nx-ansi-cyan:#56c8d8;--nx-ansi-white:#d8d8e0;\r\n--nx-ansi-bright-black:#5a5a68;--nx-ansi-bright-red:#ff8b83;--nx-ansi-bright-green:#7de0a3;\r\n--nx-ansi-bright-yellow:#f2c95c;--nx-ansi-bright-blue:#9ab8ff;--nx-ansi-bright-magenta:#e0a5f5;\r\n--nx-ansi-bright-cyan:#7adcea;--nx-ansi-bright-white:#ffffff;\r\n}`;\r\n\r\nlet paletteInserted = false;\r\n\r\nfunction ensurePalette(): void {\r\n  if (paletteInserted || typeof document === \"undefined\") return;\r\n  const style = document.createElement(\"style\");\r\n  style.setAttribute(\"data-nx-ansi-palette\", \"\");\r\n  style.textContent = ANSI_CSS;\r\n  document.head.appendChild(style);\r\n  paletteInserted = true;\r\n}\r\n\r\nconst blink = keyframes({ \"0%, 100%\": { opacity: 1 }, \"50%\": { opacity: 0.25 } });\r\n\r\nconst rootClass = css({\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  borderRadius: theme.radius.md,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.card,\r\n  overflow: \"hidden\",\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n});\r\n\r\nconst runClass = css({\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n  \"&:first-of-type\": { borderTop: \"none\" },\r\n});\r\n\r\nconst headerClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  width: \"100%\",\r\n  padding: `${theme.space[2]} ${theme.space[3]}`,\r\n  border: \"none\",\r\n  background: \"transparent\",\r\n  color: theme.color.foreground,\r\n  font: \"inherit\",\r\n  textAlign: \"left\",\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { backgroundColor: theme.color.muted },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"-2px\" },\r\n});\r\n\r\nconst chevronClass = css({\r\n  flexShrink: 0,\r\n  transitionProperty: \"transform\",\r\n  transitionDuration: theme.duration.fast,\r\n  '[data-open=\"true\"] > &': { transform: \"rotate(90deg)\" },\r\n});\r\n\r\nconst promptClass = css({ color: theme.color.mutedForeground, flexShrink: 0, userSelect: \"none\" });\r\n\r\nconst commandClass = css({\r\n  flex: 1,\r\n  minWidth: 0,\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  fontWeight: theme.fontWeight.medium,\r\n});\r\n\r\nconst cwdClass = css({\r\n  color: theme.color.mutedForeground,\r\n  flexShrink: 0,\r\n  maxWidth: \"12rem\",\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n});\r\n\r\nconst badgeClass = css({\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[1],\r\n  flexShrink: 0,\r\n  padding: `1px ${theme.space[1.5]}`,\r\n  borderRadius: theme.radius.sm,\r\n  fontSize: \"0.6875rem\",\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontWeight: theme.fontWeight.medium,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n});\r\n\r\nconst durationClass = css({\r\n  color: theme.color.mutedForeground,\r\n  flexShrink: 0,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n});\r\n\r\nconst outputClass = css({\r\n  margin: 0,\r\n  padding: `${theme.space[2]} ${theme.space[3]} ${theme.space[3]}`,\r\n  overflowX: \"auto\",\r\n  overflowY: \"auto\",\r\n  whiteSpace: \"pre\",\r\n  lineHeight: 1.55,\r\n  color: theme.color.foreground,\r\n  backgroundColor: theme.color.muted,\r\n});\r\n\r\nconst runningDotClass = css({\r\n  width: \"6px\",\r\n  height: \"6px\",\r\n  borderRadius: \"9999px\",\r\n  backgroundColor: \"currentColor\",\r\n  animation: `${blink} 1s ${theme.easing.standard} infinite`,\r\n});\r\n\r\nconst caretClass = css({\r\n  display: \"inline-block\",\r\n  width: \"0.5em\",\r\n  height: \"1em\",\r\n  verticalAlign: \"text-bottom\",\r\n  backgroundColor: theme.color.primary,\r\n  animation: `${blink} 1.1s steps(1, end) infinite`,\r\n});\r\n\r\nconst emptyClass = css({\r\n  padding: `${theme.space[3]} ${theme.space[3]} ${theme.space[4]}`,\r\n  color: theme.color.mutedForeground,\r\n  fontStyle: \"italic\",\r\n});\r\n\r\nconst STATUS_COLOR: Record<RunStatus, string> = {\r\n  running: theme.color.primary,\r\n  success: theme.color.success,\r\n  failed: theme.color.destructive,\r\n  cancelled: theme.color.mutedForeground,\r\n};\r\n\r\nfunction statusLabel(run: TerminalRun): string {\r\n  if (run.status === \"running\") return \"running\";\r\n  if (run.status === \"cancelled\") return \"cancelled\";\r\n  if (run.status === \"failed\") return run.exitCode !== undefined ? `exit ${run.exitCode}` : \"failed\";\r\n  return run.exitCode !== undefined && run.exitCode !== 0 ? `exit ${run.exitCode}` : \"exit 0\";\r\n}\r\n\r\nfunction formatDuration(ms: number): string {\r\n  if (ms < 1000) return `${Math.round(ms)}ms`;\r\n  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;\r\n  const minutes = Math.floor(ms / 60000);\r\n  const seconds = Math.round((ms % 60000) / 1000);\r\n  return `${minutes}m ${seconds}s`;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Rendering\r\n * ------------------------------------------------------------------ */\r\n\r\nfunction styleToCss(style: AnsiStyle): CSSProperties {\r\n  // Inverse swaps fg/bg at render time rather than at parse time, so toggling\r\n  // SGR 27 later restores the original colours instead of losing them.\r\n  const color = style.inverse ? (style.background ?? theme.color.background) : style.color;\r\n  const background = style.inverse ? (style.color ?? theme.color.foreground) : style.background;\r\n\r\n  return {\r\n    color,\r\n    backgroundColor: background,\r\n    fontWeight: style.bold ? 600 : undefined,\r\n    opacity: style.dim ? 0.6 : undefined,\r\n    fontStyle: style.italic ? \"italic\" : undefined,\r\n    textDecoration:\r\n      style.underline && style.strike\r\n        ? \"underline line-through\"\r\n        : style.underline\r\n          ? \"underline\"\r\n          : style.strike\r\n            ? \"line-through\"\r\n            : undefined,\r\n  };\r\n}\r\n\r\nfunction AnsiOutput({ text, showCaret }: { text: string; showCaret: boolean }) {\r\n  ensurePalette();\r\n\r\n  const lines = useMemo(() => {\r\n    const collapsed = splitTerminalLines(text);\r\n    // Style carries across lines: a colour opened on one line stays open until\r\n    // reset, which multi-line tool output relies on.\r\n    let style: AnsiStyle = {};\r\n    return collapsed.map((line) => {\r\n      const parsed = parseAnsi(line, style);\r\n      style = parsed.style;\r\n      return parsed.spans;\r\n    });\r\n  }, [text]);\r\n\r\n  return (\r\n    <>\r\n      {lines.map((spans, lineIndex) => (\r\n        <div key={lineIndex}>\r\n          {spans.map((span, spanIndex) =>\r\n            span.style.hidden ? (\r\n              <span key={spanIndex} aria-hidden=\"true\">\r\n                {\" \".repeat(span.text.length)}\r\n              </span>\r\n            ) : (\r\n              <span key={spanIndex} style={styleToCss(span.style)}>\r\n                {span.text}\r\n              </span>\r\n            ),\r\n          )}\r\n          {showCaret && lineIndex === lines.length - 1 ? (\r\n            <span className={caretClass} aria-hidden=\"true\" />\r\n          ) : null}\r\n        </div>\r\n      ))}\r\n    </>\r\n  );\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface TerminalSurfaceProps {\r\n  runs: TerminalRun[];\r\n  /** Run ids to render expanded. Defaults to the last run plus any failures. */\r\n  defaultOpen?: string[];\r\n  /** Max height of each run's output before it scrolls. */\r\n  maxOutputHeight?: number | string;\r\n  /** Follow output as it streams. Pauses automatically when scrolled up. */\r\n  autoScroll?: boolean;\r\n  /** Prompt glyph shown before each command. */\r\n  prompt?: string;\r\n  label?: string;\r\n  className?: string;\r\n  /** Rendered when there are no runs. */\r\n  empty?: ReactNode;\r\n}\r\n\r\n/**\r\n * Streaming command output with ANSI colour, exit codes, and collapsible runs.\r\n *\r\n * Failed runs default to expanded and successful ones to collapsed: a green\r\n * build is noise, a red one is the reason the user is looking at all.\r\n */\r\nexport function TerminalSurface({\r\n  runs,\r\n  defaultOpen,\r\n  maxOutputHeight = 320,\r\n  autoScroll = true,\r\n  prompt = \"$\",\r\n  label = \"Command output\",\r\n  className,\r\n  empty,\r\n}: TerminalSurfaceProps) {\r\n  const initialOpen = useMemo(() => {\r\n    if (defaultOpen) return new Set(defaultOpen);\r\n    const open = new Set<string>();\r\n    for (const run of runs) {\r\n      if (run.status === \"failed\" || run.status === \"running\") open.add(run.id);\r\n    }\r\n    const last = runs[runs.length - 1];\r\n    if (last) open.add(last.id);\r\n    return open;\r\n  }, [defaultOpen, runs]);\r\n\r\n  const [open, setOpen] = useState<Set<string>>(initialOpen);\r\n\r\n  // Newly-arriving runs should follow the same default as the initial ones,\r\n  // without clobbering what the user has since toggled.\r\n  const seenRef = useRef<Set<string>>(new Set(runs.map((run) => run.id)));\r\n  useEffect(() => {\r\n    const unseen = runs.filter((run) => !seenRef.current.has(run.id));\r\n    if (unseen.length === 0) return;\r\n    for (const run of unseen) seenRef.current.add(run.id);\r\n    setOpen((prev) => {\r\n      const next = new Set(prev);\r\n      for (const run of unseen) {\r\n        if (run.status === \"failed\" || run.status === \"running\") next.add(run.id);\r\n      }\r\n      return next;\r\n    });\r\n  }, [runs]);\r\n\r\n  const toggle = (id: string) => {\r\n    setOpen((prev) => {\r\n      const next = new Set(prev);\r\n      if (next.has(id)) next.delete(id);\r\n      else next.add(id);\r\n      return next;\r\n    });\r\n  };\r\n\r\n  if (runs.length === 0) {\r\n    return (\r\n      <div className={className ? `${rootClass} ${className}` : rootClass} aria-label={label}>\r\n        <div className={emptyClass}>{empty ?? \"No commands run yet.\"}</div>\r\n      </div>\r\n    );\r\n  }\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass} aria-label={label} role=\"log\">\r\n      {runs.map((run) => {\r\n        const isOpen = open.has(run.id);\r\n        const color = STATUS_COLOR[run.status];\r\n\r\n        return (\r\n          <div key={run.id} className={runClass}>\r\n            <button\r\n              type=\"button\"\r\n              className={headerClass}\r\n              data-open={isOpen}\r\n              aria-expanded={isOpen}\r\n              onClick={() => toggle(run.id)}\r\n            >\r\n              <svg className={chevronClass} width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden=\"true\">\r\n                <path d=\"M4.5 3L8 6l-3.5 3\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\r\n              </svg>\r\n\r\n              {run.cwd ? <span className={cwdClass}>{run.cwd}</span> : null}\r\n              <span className={promptClass}>{prompt}</span>\r\n              <span className={commandClass}>{run.command}</span>\r\n\r\n              {run.durationMs !== undefined && run.status !== \"running\" ? (\r\n                <span className={durationClass}>{formatDuration(run.durationMs)}</span>\r\n              ) : null}\r\n\r\n              <span\r\n                className={badgeClass}\r\n                style={{ color, backgroundColor: \"transparent\", border: `1px solid ${color}` }}\r\n              >\r\n                {run.status === \"running\" ? <span className={runningDotClass} aria-hidden=\"true\" /> : null}\r\n                {statusLabel(run)}\r\n              </span>\r\n            </button>\r\n\r\n            {isOpen ? (\r\n              <RunOutput\r\n                text={run.output}\r\n                running={run.status === \"running\"}\r\n                autoScroll={autoScroll}\r\n                maxHeight={maxOutputHeight}\r\n              />\r\n            ) : null}\r\n          </div>\r\n        );\r\n      })}\r\n    </div>\r\n  );\r\n}\r\n\r\nfunction RunOutput({\r\n  text,\r\n  running,\r\n  autoScroll,\r\n  maxHeight,\r\n}: {\r\n  text: string;\r\n  running: boolean;\r\n  autoScroll: boolean;\r\n  maxHeight: number | string;\r\n}) {\r\n  const ref = useRef<HTMLPreElement | null>(null);\r\n  const pinnedRef = useRef(true);\r\n\r\n  // Follow the tail only while the user is already at the bottom. Yanking the\r\n  // view back down while someone is reading earlier output is the single most\r\n  // irritating thing a log pane can do.\r\n  useLayoutEffect(() => {\r\n    const node = ref.current;\r\n    if (!node || !autoScroll || !pinnedRef.current) return;\r\n    node.scrollTop = node.scrollHeight;\r\n  }, [text, autoScroll]);\r\n\r\n  const onScroll = () => {\r\n    const node = ref.current;\r\n    if (!node) return;\r\n    // A small slack so a fractional scroll position still counts as pinned.\r\n    pinnedRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;\r\n  };\r\n\r\n  return (\r\n    <pre\r\n      ref={ref}\r\n      className={outputClass}\r\n      style={{ maxHeight: typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight }}\r\n      onScroll={onScroll}\r\n      tabIndex={0}\r\n    >\r\n      {text.length === 0 && running ? (\r\n        <span className={caretClass} aria-hidden=\"true\" />\r\n      ) : (\r\n        <AnsiOutput text={text} showCaret={running} />\r\n      )}\r\n    </pre>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}