import React from 'react'; import Link from '../components/Link.js'; import Text from '../components/Text.js'; import type { Color } from './styles.js'; import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js'; type Props = { children: string; /** When true, force all text to be rendered with dim styling */ dimColor?: boolean; }; type SpanProps = { color?: Color; backgroundColor?: Color; dim?: boolean; bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; inverse?: boolean; hyperlink?: string; }; /** * Component that parses ANSI escape codes and renders them using Text components. * * Use this as an escape hatch when you have pre-formatted ANSI strings from * external tools (like cli-highlight) that need to be rendered in Ink. * * Memoized to prevent re-renders when parent changes but children string is the same. */ export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): React.ReactNode { if (typeof children !== 'string') { return dimColor ? {String(children)} : {String(children)}; } if (children === '') { return null; } const spans = parseToSpans(children); if (spans.length === 0) { return null; } if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) { return dimColor ? {spans[0]!.text} : {spans[0]!.text}; } const content = spans.map((span, i) => { const hyperlink = span.props.hyperlink; // When dimColor is forced, override the span's dim prop if (dimColor) { span.props.dim = true; } const hasTextProps = hasAnyTextProps(span.props); if (hyperlink) { return hasTextProps ? ( {span.text} ) : ( {span.text} ); } return hasTextProps ? ( {span.text} ) : ( span.text ); }); return dimColor ? {content} : {content}; }); type Span = { text: string; props: SpanProps; }; /** * Parse an ANSI string into spans using the termio parser. */ function parseToSpans(input: string): Span[] { const parser = new Parser(); const actions = parser.feed(input); const spans: Span[] = []; let currentHyperlink: string | undefined; for (const action of actions) { if (action.type === 'link') { if (action.action.type === 'start') { currentHyperlink = action.action.url; } else { currentHyperlink = undefined; } continue; } if (action.type === 'text') { const text = action.graphemes.map(g => g.value).join(''); if (!text) continue; const props = textStyleToSpanProps(action.style); if (currentHyperlink) { props.hyperlink = currentHyperlink; } // Try to merge with previous span if props match const lastSpan = spans[spans.length - 1]; if (lastSpan && propsEqual(lastSpan.props, props)) { lastSpan.text += text; } else { spans.push({ text, props }); } } } return spans; } /** * Convert termio's TextStyle to SpanProps. */ function textStyleToSpanProps(style: TextStyle): SpanProps { const props: SpanProps = {}; if (style.bold) props.bold = true; if (style.dim) props.dim = true; if (style.italic) props.italic = true; if (style.underline !== 'none') props.underline = true; if (style.strikethrough) props.strikethrough = true; if (style.inverse) props.inverse = true; const fgColor = colorToString(style.fg); if (fgColor) props.color = fgColor; const bgColor = colorToString(style.bg); if (bgColor) props.backgroundColor = bgColor; return props; } // Map termio named colors to the ansi: format const NAMED_COLOR_MAP: Record = { black: 'ansi:black', red: 'ansi:red', green: 'ansi:green', yellow: 'ansi:yellow', blue: 'ansi:blue', magenta: 'ansi:magenta', cyan: 'ansi:cyan', white: 'ansi:white', brightBlack: 'ansi:blackBright', brightRed: 'ansi:redBright', brightGreen: 'ansi:greenBright', brightYellow: 'ansi:yellowBright', brightBlue: 'ansi:blueBright', brightMagenta: 'ansi:magentaBright', brightCyan: 'ansi:cyanBright', brightWhite: 'ansi:whiteBright', }; /** * Convert termio's Color to the string format used by Ink. */ function colorToString(color: TermioColor): Color | undefined { switch (color.type) { case 'named': return NAMED_COLOR_MAP[color.name] as Color; case 'indexed': return `ansi256(${color.index})` as Color; case 'rgb': return `rgb(${color.r},${color.g},${color.b})` as Color; case 'default': return undefined; } } /** * Check if two SpanProps are equal for merging. */ function propsEqual(a: SpanProps, b: SpanProps): boolean { return ( a.color === b.color && a.backgroundColor === b.backgroundColor && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && a.strikethrough === b.strikethrough && a.inverse === b.inverse && a.hyperlink === b.hyperlink ); } function hasAnyProps(props: SpanProps): boolean { return ( props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true || props.hyperlink !== undefined ); } function hasAnyTextProps(props: SpanProps): boolean { return ( props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true ); } // Text style props without weight (bold/dim) - these are handled separately type BaseTextStyleProps = { color?: Color; backgroundColor?: Color; italic?: boolean; underline?: boolean; strikethrough?: boolean; inverse?: boolean; }; // Wrapper component that handles bold/dim mutual exclusivity for Text function StyledText({ bold, dim, children, ...rest }: BaseTextStyleProps & { bold?: boolean; dim?: boolean; children: string; }): React.ReactNode { // dim takes precedence over bold when both are set (terminals treat them as mutually exclusive) if (dim) { return ( {children} ); } if (bold) { return ( {children} ); } return {children}; }