// Adapted from jalcoui (MIT) — github.com/jal-co/ui // Minimal ANSI SGR (Select Graphic Rendition) parser. // // Scope: 8-color and 16-color (bright) foreground / background codes plus // the structural codes (`0` reset, `1` bold, `2` dim, `3` italic, `4` // underline). 256-color and truecolor sequences (`38;5;N`, `38;2;R;G;B`) // are recognized at the parser level — we step over their parameters so // they don't corrupt the next span — but we do not honor the exact RGB; // they fall back to the default foreground. The 16-color palette is more // than enough for the typical CLI log surface (`chalk`, `consola`, // `pino-pretty`, …), and we trade fidelity for guaranteed semantic-token // routing. import type { LogTone } from '../types'; import { TONE_CLASSES } from '../types'; /** A contiguous text segment with resolved style attributes. */ export interface AnsiSegment { text: string; /** Resolved tone for color routing — `undefined` means default * foreground. */ tone?: LogTone; bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean; } /** * Map a raw ANSI SGR color code to a {@link LogTone}. The mapping mirrors * the standard chalk-style palette so a log line that says `chalk.red(…)` * lights up as `destructive` under every preset. * * - 30/40 (black) → muted * - 31/41 (red) → destructive * - 32/42 (green) → primary-tinted via the `success` token family — * LogViewer routes green to `info` family via the * debug bucket? No: green logs are *positive*, so we * reuse the existing semantic `success` (we don't * ship a separate `success` tone in TONE_CLASSES, so * we collapse to `info` — green is rare and `info` * reads as positive on every default preset). For * callers who need a true success color, that's what * `level: "info"` with `message: "✓ …"` is for. * - 33/43 (yellow) → warning * - 34/44 (blue) → info * - 35/45 (magenta) → debug (categorical, themed via --primary) * - 36/46 (cyan) → info * - 37/47 (white) → undefined (default fg) * * The `90+` bright variants and `40+` backgrounds collapse onto the same * tones — bright/normal differentiation goes through the `bold` flag and * Tailwind weight, not a fresh color scale. */ const COLOR_TONE: Record = { 30: 'muted', 31: 'destructive', 32: 'info', // see note above 33: 'warning', 34: 'info', 35: 'debug', 36: 'info', 37: undefined, 90: 'muted', 91: 'destructive', 92: 'info', 93: 'warning', 94: 'info', 95: 'debug', 96: 'info', 97: undefined, }; // Background colors carry no semantic weight in our log surface — we // strip them entirely. Recognise them for parser-correctness only. const BG_CODES = new Set([40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107]); interface State { tone?: LogTone; bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean; } function applyCode(state: State, code: number): void { if (code === 0) { state.tone = undefined; state.bold = false; state.dim = false; state.italic = false; state.underline = false; return; } if (code === 1) { state.bold = true; return; } if (code === 2) { state.dim = true; return; } if (code === 3) { state.italic = true; return; } if (code === 4) { state.underline = true; return; } if (code === 22) { state.bold = false; state.dim = false; return; } if (code === 23) { state.italic = false; return; } if (code === 24) { state.underline = false; return; } if (code === 39) { state.tone = undefined; return; } if (BG_CODES.has(code) || code === 49) { // background — ignore return; } if (code in COLOR_TONE) { state.tone = COLOR_TONE[code]; } } // Matches a single SGR escape: `\x1b[…m`. The CSI parameter bytes are // digits separated by `;`. The terminator `m` distinguishes SGR from // cursor / mode escapes — those are dropped silently. // // eslint-disable-next-line no-control-regex const ANSI_REGEX = /\x1b\[((?:\d+;?)*)m/g; /** * Parse an ANSI-tinted string into styled segments. The returned array is * cheap to memoize per log entry — segments are short and immutable. */ export function parseAnsi(input: string): AnsiSegment[] { if (!input) return []; // Fast path — no escape byte at all. if (input.indexOf('\x1b') === -1) { return [{ text: input }]; } const segments: AnsiSegment[] = []; const state: State = {}; let cursor = 0; // Reset the global regex's lastIndex (it persists across calls). ANSI_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = ANSI_REGEX.exec(input)) !== null) { const slice = input.slice(cursor, match.index); if (slice) { segments.push({ text: slice, ...state }); } const params = match[1]; if (params === '') { // `\x1b[m` ≡ `\x1b[0m` applyCode(state, 0); } else { const codes = params.split(';').map((p) => Number.parseInt(p, 10) || 0); // 256-color / truecolor: step over their tail so the rest of the // sequence doesn't get misread. for (let i = 0; i < codes.length; i += 1) { const code = codes[i]; if ((code === 38 || code === 48) && codes[i + 1] === 5) { // 256-color — skip the index byte i += 2; continue; } if ((code === 38 || code === 48) && codes[i + 1] === 2) { // truecolor — skip 3 RGB bytes i += 4; continue; } applyCode(state, code); } } cursor = match.index + match[0].length; } const tail = input.slice(cursor); if (tail) { segments.push({ text: tail, ...state }); } // If the entire string was escape sequences, return at least one empty // segment so the renderer has something to key off of. return segments.length > 0 ? segments : [{ text: '' }]; } /** * Strip ANSI sequences for plain-text use (copy-to-clipboard, search, * download). Keeps every printable character. */ export function stripAnsi(input: string): string { if (!input || input.indexOf('\x1b') === -1) return input; return input.replace(ANSI_REGEX, ''); } /** * Resolve a segment's Tailwind classes against the LogViewer tone table. * Bold / dim / italic / underline come from Tailwind utilities and stack * orthogonally on the tone color. */ export function classesForSegment(segment: AnsiSegment): string { const parts: string[] = []; if (segment.tone) { parts.push(TONE_CLASSES[segment.tone].text); } if (segment.bold) parts.push('font-semibold'); if (segment.dim) parts.push('opacity-70'); if (segment.italic) parts.push('italic'); if (segment.underline) parts.push('underline'); return parts.join(' '); }