Terminal
Read-only, horizontally scrollable monospace log view with ANSI colors mapped to theme tokens (UC-CODE-02 AC-1/AC-4). Port-adapted per the PRD verdict — an interactive pseudo-terminal is explicitly NOT part of this: the caller hands a string, the component renders it. Upstream part set at parity (Header/Title/Status/Actions/ClearButton/CopyButton/Content; output/isStreaming/autoScroll/onClear). ansi-to-react is replaced by a pure SGR tokenizer (terminal.logic.ts, Vitest-owned) whose 16-color map compresses onto the house palette — RNR roles plus the three sanctioned status colors; bright is a weight bump, 256/truecolor resolves to default, non-SGR escapes strip, attributes persist across newlines. Copy hands over stripAnsi(output) — escape codes are useless on a clipboard. autoScroll sticks to the bottom while on; the clear button ENABLES only when onClear is wired; isStreaming shows the reduced-motion-gated shimmer pulse.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/terminal.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/terminal.jsonDependencies
expo-clipboard
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/mono.json |
https://rnraielements.com/r/nativewind/shimmer.json |
https://rnraielements.com/r/nativewind/status.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/mono.json |
https://rnraielements.com/r/uniwind/shimmer.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Shimmer } from '@/registry/nativewind/components/ai/shimmer';
import { Button } from '@/registry/nativewind/components/ui/button';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Text, TextClassContext } from '@/registry/nativewind/components/ui/text';
import { monoStyle } from '@/registry/nativewind/lib/mono';
import { cn } from '@/registry/nativewind/lib/utils';
import * as Clipboard from 'expo-clipboard';
import { CheckIcon, CopyIcon, Trash2Icon } from 'lucide-react-native';
import * as React from 'react';
import { ScrollView, View, type ViewProps } from 'react-native';
import { parseAnsiLines, stripAnsi } from './terminal.logic';
/**
* Terminal — a read-only log surface for run output (UC-CODE-02 AC-1: monospace output
* that scrolls horizontally without wrapping and vertically without dropping frames;
* AC-4: any run output block copies with one tap).
*
* THE PRD VERDICT IS PORT-ADAPTED and its line is the constitution of this file: "ships
* as a read-only, horizontally scrollable monospace log view with ANSI colors mapped to
* theme tokens. An interactive pseudo-terminal is explicitly not part of this." There is
* no stdin, no prompt, no PTY — the caller hands a string, the component renders it.
*
* THE UPSTREAM PART SET, from the KB, ported part for part: Terminal root (output /
* isStreaming=false / autoScroll=true / onClear), TerminalHeader (TerminalTitle +
* TerminalStatus), TerminalActions (TerminalClearButton + TerminalCopyButton), and
* TerminalContent. The web renders `output` through ansi-to-react with hardcoded hex;
* the ANSI half is replaced by the pure SGR tokenizer in terminal.logic.ts (Vitest-
* owned) whose color map resolves onto fixed-palette classes — the RNR roles PLUS the
* Tailwind-default hues the web original uses, which the consumer declares in `@theme`
* (the remediation palette slice in the harness global.css). The DOM half is replaced by
* nested axis-scrolled ScrollViews: the vertical outer is a bounded host (max-h-64 by
* default, caller-overridable) and the horizontal inner carries the unwrapped line
* tails — different axes, so the two do not fight (code-block precedent).
*
* THE SURFACE IS SCHEME-INDEPENDENT (remediation row 2): the port's `bg-muted`
* rounded-md card converged to the web's always-dark `rounded-lg border-zinc-800
* bg-zinc-950 text-zinc-100` surface (web terminal.tsx:250). Every text class in this
* file is therefore a FIXED zinc value — a scheme-flipping token (text-foreground,
* text-muted-foreground) would render dark-on-dark in light mode, because the surface
* no longer flips with colorScheme.
*
* The line Text's `text-zinc-100` does NOT cascade to its spans: React Native
* nested-Text color inheritance is unreliable under an inline mono style, and the
* standing rule (wave 13) is that NO segment relies on inheritance. So every span
* Text in TerminalContent carries its own `text-zinc-100` default — without it a
* plain (non-ANSI) span fell back to the scheme default text color: near-black on
* this near-black surface, invisible until on-device capture zoom proved it. ANSI
* span classes still win — cn places span.classNames last, so twMerge keeps them
* (including the inverse pair's text-zinc-950).
*
* BEHAVIOR PRESERVED FROM THE WEB ORIGINAL:
* - autoScroll sticks to the bottom on every content change while on; off never moves
* the viewport (the web's boolean contract, nothing smarter is invented)
* - isStreaming shows the house shimmer pulse ("Running…") — the streaming indicator
* the KB credits the original with, rendered through our reduced-motion-gated
* Shimmer, not a spinner
* - TerminalClearButton is ENABLED ONLY when onClear is wired (the KB's documented
* gating); TerminalCopyButton copies stripAnsi(output) — escape codes are useless on
* a clipboard, so the copy hands over what the eye saw — and flip-and-reverts with
* onError on failure (snippet/code-block precedent)
* - text is selectable (code-block precedent), line by line
*
* NOT SHIPPED, on the record: an interactive pseudo-terminal (the verdict forbids it);
* xterm.js-style cursor addressing — cursor-movement escapes are stripped by the
* tokenizer, so progress-bar redraws accumulate as plain lines rather than repainting.
*/
type TerminalContextValue = {
output: string;
isStreaming: boolean;
autoScroll: boolean;
/** Unwired → the clear button DISABLES, never pretends. */
canClear: boolean;
clear: () => void;
};
const TerminalContext = React.createContext<TerminalContextValue | null>(null);
function useTerminal() {
const ctx = React.useContext(TerminalContext);
if (!ctx) throw new Error('Terminal sub-components must be used within <Terminal>');
return ctx;
}
export type TerminalProps = ViewProps & {
/** The log — ANSI escapes welcome, they tokenize into themed spans. */
output: string;
/** Shows the streaming pulse. The caller's stream state; nothing here listens. */
isStreaming?: boolean;
/** Stick to the bottom on content changes. The web default: true. */
autoScroll?: boolean;
/** Wired → the clear button enables and calls this. Absent → it disables. */
onClear?: () => void;
children?: React.ReactNode;
};
function Terminal({
output,
isStreaming = false,
autoScroll = true,
onClear,
className,
children,
...props
}: TerminalProps) {
const contextValue = React.useMemo<TerminalContextValue>(
() => ({
output,
isStreaming,
autoScroll,
canClear: Boolean(onClear),
clear: () => onClear?.(),
}),
[output, isStreaming, autoScroll, onClear],
);
return (
<TerminalContext.Provider value={contextValue}>
<View
className={cn('overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950', className)}
{...props}
>
{children}
</View>
</TerminalContext.Provider>
);
}
function TerminalHeader({ className, ...props }: ViewProps) {
return (
<View
className={cn('flex-row items-center gap-2 border-b border-zinc-800 px-3 py-2', className)}
{...props}
/>
);
}
/** The log's name. House mono, like the code-block filename it sits beside. Fixed
* zinc-100: the surface is always dark (remediation row 2). */
function TerminalTitle({ className, ...props }: React.ComponentProps<typeof Text>) {
return (
<Text
numberOfLines={1}
style={monoStyle}
className={cn('min-w-0 flex-1 text-xs font-medium text-zinc-100', className)}
{...props}
/>
);
}
/** Free status slot ("exit 0", "pid 4121") — zinc-500 so the title leads. Publishes
* the fixed zinc-xs pair so its Text children style without per-call classes; the
* surface never flips with colorScheme, so neither may its text. */
function TerminalStatus({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<TextClassContext.Provider value="text-xs text-zinc-500">
<View className={cn('shrink-0 flex-row items-center', className)} {...props}>
{children}
</View>
</TextClassContext.Provider>
);
}
function TerminalActions({ className, ...props }: ViewProps) {
return <View className={cn('shrink-0 flex-row items-center gap-1', className)} {...props} />;
}
type TerminalClearButtonProps = {
className?: string;
};
/**
* The KB's documented gate: enabled when the Terminal's onClear is set. Clearing is the
* CALLER's state change (output is caller-owned); this only fires the callback.
*/
function TerminalClearButton({ className }: TerminalClearButtonProps) {
const { canClear, clear } = useTerminal();
return (
<Button
variant="ghost"
size="icon"
onPress={clear}
disabled={!canClear}
accessibilityLabel="Clear terminal"
// House formula: RNR's h-10 (40pt) + hitSlop 2/side = the 44pt platform minimum.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={className}
>
<Icon as={Trash2Icon} size={14} className="text-zinc-500" />
</Button>
);
}
type TerminalCopyButtonProps = {
timeout?: number;
onCopy?: () => void;
onError?: (error: unknown) => void;
className?: string;
};
/** AC-4's one-tap copy: the WHOLE log, escapes stripped. Flip-and-revert. */
function TerminalCopyButton({ timeout = 2000, onCopy, onError, className }: TerminalCopyButtonProps) {
const { output } = useTerminal();
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
async function copy() {
try {
await Clipboard.setStringAsync(stripAnsi(output));
setCopied(true);
onCopy?.();
timer.current = setTimeout(() => setCopied(false), timeout);
} catch (error) {
// Never silent — the web original calls onError and so do we.
onError?.(error);
}
}
return (
<Button
variant="ghost"
size="icon"
onPress={copy}
accessibilityLabel={copied ? 'Copied' : 'Copy output'}
// House formula: RNR's h-10 (40pt) + hitSlop 2/side = the 44pt platform minimum.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={className}
>
<Icon as={copied ? CheckIcon : CopyIcon} size={14} className="text-zinc-500" />
</Button>
);
}
/**
* The log itself: vertical outer (bounded host — max-h-64 by default, override with
* className), horizontal inner carrying unwrapped line tails. Lines are themed span
* runs from the pure tokenizer; attributes persist across newlines in the tokenizer, so
* the renderer never decides color. Selectable per line (code-block precedent).
*/
function TerminalContent({ className }: { className?: string }) {
const { output, isStreaming, autoScroll } = useTerminal();
const lines = React.useMemo(() => parseAnsiLines(output), [output]);
const scrollRef = React.useRef<ScrollView>(null);
React.useEffect(() => {
if (autoScroll) scrollRef.current?.scrollToEnd({ animated: false });
}, [output, autoScroll]);
return (
<ScrollView
ref={scrollRef}
className={cn('max-h-64', className)}
nestedScrollEnabled
// Bounded-host law: the vertical bound is the component's default; the caller's
// className replaces it (e.g. max-h-40 in a dense card).
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="min-w-full px-3 py-2"
>
{/* A horizontal ScrollView is a ROW container — without this column wrapper
every log line lays out SIDE BY SIDE on one strip (review E1; code-block's
CodeBlockContent is the precedent being followed here). */}
<View className="flex-col">
{lines.map((spans, lineIndex) => (
<Text
key={`line-${lineIndex}`}
selectable
style={monoStyle}
className="text-xs leading-5 text-zinc-100"
>
{/* Every span carries its own text-zinc-100 — see the header: RN
nested-Text color inheritance is unreliable under an inline mono
style (wave 13's standing rule: no segment relies on
inheritance), and a span without an ANSI class otherwise falls
back to the scheme default (near-black on this near-black
surface; proven by device-capture zoom). cn puts
span.classNames last, so ANSI classes — including the inverse
pair's text-zinc-950 — still override via twMerge. */}
{spans.map((span, spanIndex) => (
<Text
key={`span-${lineIndex}-${spanIndex}`}
style={monoStyle}
className={cn('text-zinc-100', span.classNames)}
>
{span.text}
</Text>
))}
</Text>
))}
{isStreaming ? <ShimmerLine /> : null}
</View>
</ScrollView>
</ScrollView>
);
}
/** The streaming pulse — the house shimmer at log scale, gated on reduced motion.
* Fixed zinc-500: the surface is always dark (remediation row 2), so the caller
* class overrides Shimmer's scheme-flipping text-muted-foreground default. */
function ShimmerLine() {
return (
<Shimmer className="text-xs text-zinc-500" style={monoStyle}>
Running…
</Shimmer>
);
}
export {
Terminal,
TerminalActions,
TerminalClearButton,
TerminalContent,
TerminalCopyButton,
TerminalHeader,
TerminalStatus,
TerminalTitle,
useTerminal,
};
/**
* Terminal — pure logic. Zero react-native imports, so the Vitest tier owns it (see the
* header of lib/reasoning-lifecycle.ts for why component files themselves cannot load
* under Node).
*
* The web original renders its `output` string through `ansi-to-react`, which turns ANSI
* escape sequences into inline styles with hardcoded hex colors. Both halves are foreign
* here: React Native has no DOM spans, and the styling contract forbids color literals.
* So the port owns a small SGR tokenizer instead, and its COLOR MAP resolves onto
* fixed-palette classes the way the surface itself does (remediation row 2): the terminal
* converged to the web's always-dark `bg-zinc-950` surface (terminal.tsx web :250), so
* scheme-flipping RNR roles (text-foreground, text-muted-foreground, text-primary) would
* render invisible in light mode — dark text on the dark surface. The fixed zinc/blue
* Tailwind-default classes are the faithful equivalent of the web's always-dark terminal;
* the consumer declares them in `@theme` (the remediation palette slice in the harness
* global.css). The sanctioned lib/status.ts escape colors stay for red/green/orange —
* green-600/orange-600 read fine on zinc-950 and their dark: twins are already correct.
* No color literal exists anywhere in this file.
*
* THE COMPRESSION TABLE, DECLARED (color is never the sole channel — WCAG 1.4.1 — the
* log text itself carries the information):
* black → text-zinc-500 (the dim pole of the fixed ramp)
* red → statusColor.error (text-destructive)
* green → statusColor.success (text-green-600 dark:text-green-500)
* yellow → statusColor.denied (text-orange-600 dark:text-orange-500)
* blue → text-blue-400 (the web's bright ANSI blue, legible on zinc-950)
* magenta → text-blue-400 (declared compression — runs out of hues)
* cyan → text-blue-400 (declared compression)
* white → text-zinc-100
* The BRIGHT half (90–97) resolves to its base color's class plus a `font-medium` weight
* bump rather than a second hue — every rendered color stays inside the declared set.
* Extended palettes (38;5;N / 38;2;R;G;B) have no class counterpart at all, so 38/48
* resolve to the DEFAULT foreground and their parameter run is consumed (a stray `5`
* must never apply as a stray SGR).
*
* Backs: 40/100 → bg-zinc-800 (neutral); 47/107 → bg-zinc-100 forced with
* text-zinc-950 (the inverse look); the colored middles (41–46, 101–106) → bg-zinc-700
* forced with text-zinc-100. Backgrounds never ship a hue.
*
* ON THE RECORD: the web's streaming cursor (`ml-0.5 h-4 w-2 bg-zinc-100 animate-pulse`,
* terminal.tsx web :217) stays replaced by the port's reduced-motion-gated Shimmer text
* — the recorded keep from the original port; this remediation does not change it.
*
* Attributes PERSIST ACROSS NEWLINES — a color opened on one line is still open on the
* next (real terminal semantics, and how streamed logs actually arrive).
*/
import { statusColor } from '@/registry/nativewind/lib/status';
/** The 8 ANSI base colors, by name. Bright variants reuse these names. */
export type AnsiColorName =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white';
/**
* color name → precomposed text class. The only color table in the port; every entry is
* an RNR role, a fixed remediation-palette class, or a lib/status.ts escape, auditable
* right here. Fixed zinc/blue throughout: the surface is scheme-independent.
*/
export const ANSI_COLOR_CLASS: Record<AnsiColorName, string> = {
black: 'text-zinc-500',
red: statusColor.error,
green: statusColor.success,
yellow: statusColor.denied,
blue: 'text-blue-400',
magenta: 'text-blue-400',
cyan: 'text-blue-400',
white: 'text-zinc-100',
};
/** SGR 30–37 → name. */
const BASE_FG_CODES: Record<number, AnsiColorName> = {
30: 'black',
31: 'red',
32: 'green',
33: 'yellow',
34: 'blue',
35: 'magenta',
36: 'cyan',
37: 'white',
};
/** SGR 90–97 → the same names; the bright pole is a weight bump, not a hue. */
const BRIGHT_FG_CODES: Record<number, AnsiColorName> = {
90: 'black',
91: 'red',
92: 'green',
93: 'yellow',
94: 'blue',
95: 'magenta',
96: 'cyan',
97: 'white',
};
/**
* The three background shapes a fixed palette can express: the neutral wash, the
* inverse block, and the accent block. Compressed from the web's 16 — colored
* backgrounds have no class counterpart, so the whole middle band resolves to accent.
*/
export type AnsiBackground = 'neutral' | 'inverse' | 'accent';
/** background shape → precomposed background class. */
export const ANSI_BG_CLASS: Record<AnsiBackground, string> = {
neutral: 'bg-zinc-800',
inverse: 'bg-zinc-100',
accent: 'bg-zinc-700',
};
/** The foreground class a background forces, so text stays legible on its own block.
* Exported so the guard test can iterate it alongside the two color maps. */
export const BG_FORCED_FG: Record<AnsiBackground, string | null> = {
neutral: null,
inverse: 'text-zinc-950',
accent: 'text-zinc-100',
};
/** SGR 40–47 / 100–107 → shape. */
function bgShape(code: number): AnsiBackground {
const base = code >= 100 ? code - 60 : code; // 100..107 → 40..47
if (base === 40) return 'neutral';
if (base === 47) return 'inverse';
return 'accent';
}
/** One styled run of a log line, classes fully resolved — the renderer never decides. */
export type AnsiSpan = {
text: string;
/** Ordered classes: decorations, then foreground, then background (bg wins ties). */
classNames: string[];
};
type SpanState = {
bold: boolean;
italic: boolean;
underline: boolean;
bright: boolean;
/** SGR 2 — renders as the muted pole when no explicit foreground is set. */
dim: boolean;
fg: AnsiColorName | null;
bg: AnsiBackground | null;
};
const INITIAL_STATE: SpanState = {
bold: false,
italic: false,
underline: false,
bright: false,
dim: false,
fg: null,
bg: null,
};
/** state → ordered class list. Pure, and the exact thing the tests pin. */
export function spanClassNames(state: SpanState): string[] {
const classes: string[] = [];
if (state.bold) classes.push('font-bold');
if (state.italic) classes.push('italic');
if (state.underline) classes.push('underline');
if (state.bright) classes.push('font-medium');
if (state.fg) classes.push(ANSI_COLOR_CLASS[state.fg]);
else if (state.dim) classes.push('text-zinc-500');
if (state.bg) {
classes.push(ANSI_BG_CLASS[state.bg]);
const forced = BG_FORCED_FG[state.bg];
if (forced) classes.push(forced);
}
return classes;
}
/**
* Apply one SGR parameter list to the state, in order, mutating the working copy.
* `38`/`48` are extended-color introducers: `38;5;N` and `38;2;R;G;B` are consumed
* whole and resolve to the default (extended palettes have no token counterpart).
*/
function applySgr(params: number[], state: SpanState): void {
for (let i = 0; i < params.length; i++) {
const p = params[i];
if (p === 38 || p === 48) {
const mode = params[i + 1];
i += mode === 2 ? 4 : mode === 5 ? 2 : 0; // skip the color spec's parameters
continue;
}
switch (p) {
case 0:
Object.assign(state, INITIAL_STATE);
break;
case 1:
state.bold = true;
break;
case 2:
state.dim = true;
break;
case 3:
state.italic = true;
break;
case 4:
state.underline = true;
break;
// 21 is doubly-underlined in ECMA-48 and bold-off in xterm; the xterm reading is
// the one terminals actually emit, so it clears bold.
case 21:
state.bold = false;
break;
case 22:
state.bold = false;
state.dim = false;
break;
case 23:
state.italic = false;
break;
case 24:
state.underline = false;
break;
case 7:
state.bg = 'inverse';
break;
case 27:
state.bg = null;
break;
case 39:
state.fg = null;
break;
case 49:
state.bg = null;
break;
default: {
const base = BASE_FG_CODES[p];
if (base) {
state.fg = base;
state.bright = false;
break;
}
const bright = BRIGHT_FG_CODES[p];
if (bright) {
state.fg = bright;
state.bright = true;
break;
}
if ((p >= 40 && p <= 47) || (p >= 100 && p <= 107)) {
state.bg = bgShape(p);
}
// Anything else (blink, fonts, IDE-related modes) is ignored, never guessed.
}
}
}
}
/**
* `output` → lines of styled spans. The renderer's contract:
* - escape sequences never reach the rendered text
* - a line with no visible text yields ONE span holding a single space (the code-block
* `{line || ' '}` precedent — an empty Text collapses to zero height otherwise)
* - exactly one trailing newline ends the output; a second one is a visible blank line
* - a bare `\r` (progress-bar carriage returns) is dropped, never smuggled into a span
*/
export function parseAnsiLines(output: string): AnsiSpan[][] {
// OSC (ESC ] ... BEL or ESC \) strips whole. Complete CSI strips WHOLE unless its
// final byte is m (SGR — left for the line walker); an UNTERMINATED CSI (truncated
// mid-chunk) strips to the next ESC or end of string; finally any leftover ESC — one
// that opens no bracket sequence — strips alone. Order matters: a whole-sequence
// class that excludes 'm' would fail to match SGR and this bare-ESC pass would eat
// only the ESC, leaving raw "[31m" as visible text.
const cleaned = output
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[[0-9;:?<=> ]*([A-Za-z])/g, (sequence, final: string) => (final === 'm' ? sequence : ''))
.replace(/\x1b\[[0-9;:?<=> ]*(?=\x1b|$)/g, '')
.replace(/\x1b(?!\[)/g, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '');
const withoutTrailingNewline = cleaned.endsWith('\n') ? cleaned.slice(0, -1) : cleaned;
const rawLines = withoutTrailingNewline.split('\n');
let state: SpanState = { ...INITIAL_STATE };
return rawLines.map((line) => {
const spans: AnsiSpan[] = [];
let buffer = '';
const flush = () => {
if (buffer.length > 0) {
spans.push({ text: buffer, classNames: spanClassNames(state) });
buffer = '';
}
};
// Walk the line, splitting on SGR sequences. Non-SGR sequences were already
// stripped, so every remaining ESC here is an SGR.
const sgr = /\x1b\[([0-9;:]*)(m)/g;
let cursor = 0;
for (const match of line.matchAll(sgr)) {
buffer += line.slice(cursor, match.index);
flush();
const params = (match[1] || '')
.split(/[;:]/)
.map((token) => Number.parseInt(token, 10) || 0);
applySgr(params, state);
cursor = (match.index ?? 0) + match[0].length;
}
buffer += line.slice(cursor);
flush();
if (spans.length === 0) spans.push({ text: ' ', classNames: [] });
return spans;
});
}
/**
* The plain text behind a log — what TerminalCopyButton puts on the clipboard. Escape
* codes are useless once copied; the copy hands over what the eye saw.
*/
export function stripAnsi(output: string): string {
return output
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
// EVERY complete CSI goes — SGR included — then unterminated ones, then leftover
// ESCs, so the clipboard never sees a bracket sequence or a control char.
.replace(/\x1b\[[0-9;:?<=> ]*[A-Za-z]/g, '')
.replace(/\x1b\[[0-9;:?<=> ]*(?=\x1b|$)/g, '')
.replace(/\x1b/g, '');
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| output | string | — | The log — ANSI escapes welcome, they tokenize into themed spans. |
| isStreaming | boolean | undefined | false | Shows the streaming pulse. The caller's stream state; nothing here listens. |
| autoScroll | boolean | undefined | true | Stick to the bottom on content changes. The web default: true. |
| onClear | (() => void) | undefined | — | Wired → the clear button enables and calls this. Absent → it disables. |
| children | React.ReactNode | — | — |
Theming
Themed via bg-muted, text-foreground, text-muted-foreground, text-primary, text-destructive.
Usage
() => (
<Terminal output={ANSI_LOG} isStreaming={false} autoScroll onClear={() => {}}>
<TerminalHeader>
<TerminalTitle>registry — pnpm check:registry</TerminalTitle>
<TerminalStatus />
<TerminalActions>
<TerminalCopyButton />
<TerminalClearButton />
</TerminalActions>
</TerminalHeader>
<TerminalContent />
</Terminal>
)