{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"terminal-block","type":"registry:ui","title":"TerminalBlock","description":"Live command transcript: the prompt line, output as it streams in with its ANSI colours read, a block cursor while it runs, and an exit-status footer when it stops","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-agents"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/terminal-block/TerminalBlock.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useAutoscroll } from \"../../internals/use-autoscroll\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { parseAnsi, type AnsiSegment } from \"./ansi\";\nimport \"./terminal-block.css\";\n\n/**\n * Props for TerminalBlock\n */\nexport interface TerminalBlockProps extends Omit<HTMLAttributes<HTMLDivElement>, \"className\"> {\n\t/** Everything the command has printed so far, not the latest chunk. Reassign as lines arrive. */\n\toutput: string;\n\t/** The command that produced the output, shown on the first line after the prompt glyph. */\n\tcommand?: string;\n\t/** Prompt glyph in front of the command. */\n\tprompt?: string;\n\t/** Whether the command is still running. Shows the cursor and keeps the view pinned. */\n\trunning?: boolean;\n\t/** Exit status. Anything other than null or undefined ends the run and shows the footer. */\n\texitCode?: number | null;\n\t/** How long the run took, shown next to the exit status. */\n\tdurationMs?: number;\n\t/** Read the SGR subset and colour the output. False renders the same text, unstyled. */\n\tansi?: boolean;\n\t/** Height at which the output starts scrolling. */\n\tmaxHeight?: string;\n\t/** Title bar above the output — window dots, a file name, a copy button. */\n\theader?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n/**\n * The palette a consumer overrides by setting `--ft-terminal-*` anywhere above\n * the block. Each entry is the readable-on-dark version of its colour, which\n * is also why the bright SGR codes fold onto the same eight values.\n */\nconst FALLBACK: Record<string, string> = {\n\t\"--ft-terminal-black\": \"oklch(0.44 0.01 260)\",\n\t\"--ft-terminal-red\": \"oklch(0.7 0.18 22)\",\n\t\"--ft-terminal-green\": \"oklch(0.78 0.17 150)\",\n\t\"--ft-terminal-yellow\": \"oklch(0.85 0.15 90)\",\n\t\"--ft-terminal-blue\": \"oklch(0.74 0.13 250)\",\n\t\"--ft-terminal-magenta\": \"oklch(0.74 0.18 330)\",\n\t\"--ft-terminal-cyan\": \"oklch(0.82 0.11 200)\",\n\t\"--ft-terminal-white\": \"oklch(0.95 0.005 260)\",\n};\n\n/**\n * Parse once over the whole stream, then cut the segments at the newlines:\n * a colour opened on one line and closed three lines later stays in effect\n * for the lines in between, the way a terminal would show it.\n */\nfunction toRows(src: string, styled: boolean): AnsiSegment[][] {\n\tif (src === \"\") return [];\n\t// CRLF becomes one newline; a lone CR is dropped rather than rewinding\n\t// the line, so a progress bar collapses instead of exploding into rows.\n\tconst normalized = src.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\");\n\tconst lines: AnsiSegment[][] = [[]];\n\tfor (const segment of parseAnsi(normalized)) {\n\t\tconst parts = segment.text.split(\"\\n\");\n\t\tfor (let i = 0; i < parts.length; i++) {\n\t\t\tif (i > 0) lines.push([]);\n\t\t\tconst part = parts[i] as string;\n\t\t\tif (part === \"\") continue;\n\t\t\t(lines[lines.length - 1] as AnsiSegment[]).push({\n\t\t\t\ttext: part,\n\t\t\t\tbold: styled && segment.bold,\n\t\t\t\tfg: styled ? segment.fg : null,\n\t\t\t});\n\t\t}\n\t}\n\treturn lines;\n}\n\nfunction colour(fg: string | null): string | undefined {\n\tif (fg === null) return undefined;\n\treturn `var(${fg}, ${FALLBACK[fg] ?? \"inherit\"})`;\n}\n\n/**\n * Command durations live in the sub-second range often enough that a\n * stopwatch reading of \"0s\" would be the wrong answer most of the time.\n */\nfunction formatDuration(ms: number | undefined): string | null {\n\tif (ms === undefined || !Number.isFinite(ms) || ms < 0) return null;\n\tif (ms < 1000) return `${Math.round(ms)}ms`;\n\tif (ms < 60_000) {\n\t\t// Rounded to a tenth first: a value that rounds up to a full 60.0s has\n\t\t// to fall through to the minute form rather than print a second count\n\t\t// that does not exist.\n\t\tconst deciseconds = Math.round(ms / 100);\n\t\tif (deciseconds < 600) return `${(deciseconds / 10).toFixed(1)}s`;\n\t}\n\t// Rounding to whole seconds before splitting means the carry happens on its\n\t// own: rounding each unit separately is what produced \"1m 60s\".\n\tconst totalSeconds = Math.round(ms / 1000);\n\tconst minutes = Math.floor(totalSeconds / 60);\n\tconst seconds = totalSeconds % 60;\n\treturn `${minutes}m ${seconds < 10 ? \"0\" : \"\"}${seconds}s`;\n}\n\nexport const TerminalBlock = forwardRef<HTMLDivElement, TerminalBlockProps>(function TerminalBlock(\n\t{\n\t\toutput,\n\t\tcommand,\n\t\tprompt = \"$\",\n\t\trunning = false,\n\t\texitCode = null,\n\t\tdurationMs,\n\t\tansi = true,\n\t\tmaxHeight = \"20rem\",\n\t\theader,\n\t\tclassName,\n\t\t...rest\n\t},\n\tref\n) {\n\tconst finished = exitCode !== null && exitCode !== undefined;\n\tconst ok = exitCode === 0;\n\tconst duration = formatDuration(durationMs);\n\n\tconst lines = toRows(output, ansi);\n\t// A stream that has printed nothing yet still needs a line to put the\n\t// cursor on; a finished one should not end on a blank line just because\n\t// its last write ended in a newline.\n\tlet rows: AnsiSegment[][];\n\tif (lines.length === 0) {\n\t\trows = running ? [[]] : [];\n\t} else {\n\t\tconst last = lines[lines.length - 1] as AnsiSegment[];\n\t\tif (!running && lines.length > 1 && last.length === 0) lines.pop();\n\t\trows = lines;\n\t}\n\n\tconst [logNode, logRef] = useElementRef<HTMLDivElement>();\n\tuseAutoscroll(logNode, { enabled: running, pinOnConnect: true });\n\n\treturn (\n\t\t<div ref={ref} className={cn(\"ft-terminal overflow-hidden rounded-lg font-mono text-sm\", className)} {...rest}>\n\t\t\t{header ? <div className=\"ft-rule flex items-center gap-2 border-b px-4 py-2\">{header}</div> : null}\n\n\t\t\t<div\n\t\t\t\tref={logRef}\n\t\t\t\trole=\"log\"\n\t\t\t\tclassName=\"overflow-y-auto px-4 py-3 leading-relaxed\"\n\t\t\t\tstyle={{ maxHeight }}\n\t\t\t>\n\t\t\t\t{command ? (\n\t\t\t\t\t<div className=\"ft-line flex gap-2\">\n\t\t\t\t\t\t<span className=\"ft-prompt shrink-0 select-none\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{prompt}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"ft-wrap\">{command}</span>\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t\t{rows.map((segments, row) => (\n\t\t\t\t\t// eslint-disable-next-line react/no-array-index-key\n\t\t\t\t\t<div key={row} className=\"ft-line ft-wrap\">\n\t\t\t\t\t\t{segments.map((segment, i) => (\n\t\t\t\t\t\t\t// eslint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t<span key={i} className={cn(segment.bold && \"ft-bold\")} style={{ color: colour(segment.fg) }}>\n\t\t\t\t\t\t\t\t{segment.text}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\t{running && row === rows.length - 1 ? (\n\t\t\t\t\t\t\t<span className=\"ft-cursor\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t▊\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t{finished ? (\n\t\t\t\t<div role=\"status\" className={cn(\"ft-rule ft-status flex items-center gap-2 border-t px-4 py-2 text-xs\", ok && \"ft-ok\")}>\n\t\t\t\t\t<span aria-hidden=\"true\">{ok ? \"✓\" : \"✗\"}</span>\n\t\t\t\t\t<span>exited {exitCode}</span>\n\t\t\t\t\t{duration ? <span className=\"ft-muted ml-auto tabular-nums\">{duration}</span> : null}\n\t\t\t\t</div>\n\t\t\t) : null}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/terminal-block/TerminalBlock.tsx"},{"path":"react/src/components/terminal-block/ansi.ts","content":"\"use client\";\n\n/**\n * A deliberately small ANSI reader for terminal output surfaces.\n *\n * It produces an array of styled segments, never a string of HTML: the renderer\n * walks the segments with plain markup, so escape sequences can only ever\n * become styling or nothing at all — never markup.\n *\n * Supported: SGR reset (`0`), bold (`1`), and the eight foreground colours in\n * both their normal (`30`–`37`) and bright (`90`–`97`) ranges, alone or\n * compounded (`ESC[1;32m`). Everything else — cursor moves, erase sequences,\n * OSC titles, background colours, 256/truecolour, unknown SGR parameters — is\n * stripped without leaving a trace in the text.\n *\n * Every entry point is total: it returns a value for any input, including a\n * half-written escape sequence at the end of a stream.\n */\n\n/** A run of text sharing one set of attributes. */\nexport interface AnsiSegment {\n\t/** The text itself, with every escape sequence already removed. */\n\ttext: string;\n\t/** Whether SGR bold is in effect for this run. */\n\tbold: boolean;\n\t/** Name of the CSS custom property holding the colour, or null for the default. */\n\tfg: string | null;\n}\n\n/** The eight colour names the SGR foreground codes map onto. */\nexport const ANSI_COLORS = [\n\t\"black\",\n\t\"red\",\n\t\"green\",\n\t\"yellow\",\n\t\"blue\",\n\t\"magenta\",\n\t\"cyan\",\n\t\"white\",\n] as const;\n\nexport type AnsiColor = (typeof ANSI_COLORS)[number];\n\nconst ESC = \"\\u001b\";\nconst BEL = \"\\u0007\";\n\n/** CSI parameter bytes, per ECMA-48: `0-9 : ; < = > ?`. */\nconst PARAM_MIN = 0x30;\nconst PARAM_MAX = 0x3f;\n/** CSI/escape intermediate bytes: space through `/`. */\nconst INTERMEDIATE_MIN = 0x20;\nconst INTERMEDIATE_MAX = 0x2f;\n\n/**\n * Bright codes reuse the same eight variables rather than doubling the palette:\n * on a dark surface the \"normal\" set is already the readable one.\n */\nfunction fgVar(code: number): string | null {\n\tif (code >= 30 && code <= 37) return `--ft-terminal-${ANSI_COLORS[code - 30]}`;\n\tif (code >= 90 && code <= 97) return `--ft-terminal-${ANSI_COLORS[code - 90]}`;\n\treturn null;\n}\n\ninterface Sgr {\n\tbold: boolean;\n\tfg: string | null;\n}\n\nfunction applySgr(params: string, state: Sgr): void {\n\tconst parts = params.split(\";\");\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i];\n\t\t// An omitted parameter means 0, so `ESC[m` and `ESC[;32m` both behave.\n\t\tconst code = part === \"\" ? 0 : Number(part);\n\t\tif (!Number.isInteger(code)) continue;\n\t\tif (code === 0) {\n\t\t\tstate.bold = false;\n\t\t\tstate.fg = null;\n\t\t\tcontinue;\n\t\t}\n\t\tif (code === 1) {\n\t\t\tstate.bold = true;\n\t\t\tcontinue;\n\t\t}\n\t\t// An extended colour carries its value in the parameters that follow it —\n\t\t// `38;5;n` or `38;2;r;g;b` — and those are data, not codes: the 31 in\n\t\t// `38;5;31` is a palette index, not \"red\". Both forms are dropped either\n\t\t// way; taking the whole run at once is what stops the payload being read\n\t\t// back out as styling. A truncated run consumes only what is there, since\n\t\t// the loop bound catches an index past the end.\n\t\tif (code === 38 || code === 48) {\n\t\t\ti += parts[i + 1] === \"5\" ? 2 : parts[i + 1] === \"2\" ? 4 : 1;\n\t\t\tcontinue;\n\t\t}\n\t\tconst colour = fgVar(code);\n\t\tif (colour !== null) state.fg = colour;\n\t}\n}\n\n/** Split a chunk of terminal output into styled runs. Never throws. */\nexport function parseAnsi(src: string): AnsiSegment[] {\n\tif (typeof src !== \"string\" || src.length === 0) return [];\n\t// Most lines of output carry no styling at all.\n\tif (!src.includes(ESC)) return [{ text: src, bold: false, fg: null }];\n\n\tconst out: AnsiSegment[] = [];\n\tconst state: Sgr = { bold: false, fg: null };\n\tlet buf = \"\";\n\tlet i = 0;\n\n\tfunction flush(): void {\n\t\tif (buf === \"\") return;\n\t\t// A stripped sequence (a cursor move mid-line, say) splits the buffer\n\t\t// without changing the attributes; those two runs are one run.\n\t\tconst last = out[out.length - 1];\n\t\tif (last !== undefined && last.bold === state.bold && last.fg === state.fg) {\n\t\t\tlast.text += buf;\n\t\t} else {\n\t\t\tout.push({ text: buf, bold: state.bold, fg: state.fg });\n\t\t}\n\t\tbuf = \"\";\n\t}\n\n\twhile (i < src.length) {\n\t\tif (src[i] !== ESC) {\n\t\t\tbuf += src[i];\n\t\t\ti += 1;\n\t\t\tcontinue;\n\t\t}\n\n\t\tflush();\n\t\tconst next = src[i + 1];\n\n\t\tif (next === \"[\") {\n\t\t\t// CSI: parameter bytes, then intermediates, then exactly one final byte.\n\t\t\tlet j = i + 2;\n\t\t\twhile (j < src.length && src.charCodeAt(j) >= PARAM_MIN && src.charCodeAt(j) <= PARAM_MAX) {\n\t\t\t\tj += 1;\n\t\t\t}\n\t\t\tconst params = src.slice(i + 2, j);\n\t\t\twhile (\n\t\t\t\tj < src.length &&\n\t\t\t\tsrc.charCodeAt(j) >= INTERMEDIATE_MIN &&\n\t\t\t\tsrc.charCodeAt(j) <= INTERMEDIATE_MAX\n\t\t\t) {\n\t\t\t\tj += 1;\n\t\t\t}\n\t\t\t// Truncated mid-sequence: the rest of the stream is escape, not text.\n\t\t\tif (j >= src.length) break;\n\t\t\tif (src[j] === \"m\") applySgr(params, state);\n\t\t\ti = j + 1;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (next === \"]\") {\n\t\t\t// OSC: a string terminated by BEL or ST, or by the end of the stream.\n\t\t\tlet j = i + 2;\n\t\t\twhile (j < src.length) {\n\t\t\t\tif (src[j] === BEL) {\n\t\t\t\t\tj += 1;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (src[j] === ESC && src[j + 1] === \"\\\\\") {\n\t\t\t\t\tj += 2;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tj += 1;\n\t\t\t}\n\t\t\ti = j;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// A lone ESC at the very end has nothing left to introduce.\n\t\tif (next === undefined) break;\n\n\t\t// Two- and three-byte escapes: optional intermediates, then one final byte.\n\t\tlet j = i + 1;\n\t\twhile (\n\t\t\tj < src.length &&\n\t\t\tsrc.charCodeAt(j) >= INTERMEDIATE_MIN &&\n\t\t\tsrc.charCodeAt(j) <= INTERMEDIATE_MAX\n\t\t) {\n\t\t\tj += 1;\n\t\t}\n\t\ti = j < src.length ? j + 1 : src.length;\n\t}\n\n\tflush();\n\treturn out;\n}\n","type":"registry:ui","target":"@ui/fancy/components/terminal-block/ansi.ts"},{"path":"react/src/components/terminal-block/index.ts","content":"\"use client\";\n\nexport { TerminalBlock } from \"./TerminalBlock\";\nexport type { TerminalBlockProps } from \"./TerminalBlock\";\n","type":"registry:ui","target":"@ui/fancy/components/terminal-block/index.ts"},{"path":"react/src/components/terminal-block/terminal-block.css","content":"/*\n * A terminal is dark in a light page too, so the surface is its own colour\n * rather than the theme's. Everything inside inherits from it — including\n * `color-scheme`, which is declared dark for exactly that reason: the shared\n * `--ft-status-*` fallbacks are `light-dark()` pairs, and on this surface the\n * dark half is the legible one whatever the page around it is doing.\n */\n.ft-terminal {\n\tcolor-scheme: dark;\n\tbackground-color: var(--ft-terminal-bg, oklch(0.18 0.02 260));\n\tcolor: var(--ft-terminal-fg, oklch(0.92 0.01 260));\n}\n\n.ft-rule {\n\tborder-color: color-mix(in oklab, currentColor 16%, transparent);\n}\n\n.ft-prompt {\n\tcolor: var(--ft-terminal-green, oklch(0.78 0.17 150));\n}\n\n/* Output is preformatted, but a long line wraps rather than scrolling sideways. */\n.ft-wrap {\n\twhite-space: pre-wrap;\n\toverflow-wrap: anywhere;\n}\n\n/* Keeps a blank line the height of a written one. Matches leading-relaxed. */\n.ft-line {\n\tmin-height: 1.625em;\n}\n\n.ft-bold {\n\tfont-weight: 700;\n}\n\n.ft-cursor {\n\tdisplay: inline-block;\n\topacity: 0.85;\n}\n\n/*\n * The exit verdict is a status, not terminal output: it reads off the\n * `--ft-status-*` vocabulary this component family shares rather than the ANSI\n * palette above, so a run that failed here looks like a failure everywhere else.\n */\n.ft-status {\n\tcolor: var(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)));\n}\n\n.ft-status.ft-ok {\n\tcolor: var(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)));\n}\n\n.ft-muted {\n\tcolor: var(--ft-terminal-fg, oklch(0.92 0.01 260));\n\topacity: 0.55;\n}\n\n/*\n * The cursor is a steady block first and a blinking one second: reduced\n * motion still gets the \"this is still running\" signal, without the blink.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-cursor {\n\t\tanimation: ft-terminal-cursor 1.1s step-end infinite;\n\t}\n\n\t@keyframes ft-terminal-cursor {\n\t\t0%,\n\t\t100% {\n\t\t\topacity: 0.85;\n\t\t}\n\t\t50% {\n\t\t\topacity: 0;\n\t\t}\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/terminal-block/terminal-block.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/terminal-block?framework=react"}}