// ANSI to HTML converter - processes escape sequences and renders as inline styled spans const colors: Record = { 30: "#0d1117", 31: "#f85149", 32: "#3fb950", 33: "#d29922", 34: "#58a6ff", 35: "#bc8cff", 36: "#39c5cf", 37: "#c9d1d9", 90: "#6e7681", 91: "#ff7b72", 92: "#7ee787", 93: "#e3b341", 94: "#79c0ff", 95: "#d2a8ff", 96: "#56d4dd", 97: "#ffffff", }; const bgColors: Record = { 40: "#0d1117", 41: "#f85149", 42: "#3fb950", 43: "#d29922", 44: "#58a6ff", 45: "#bc8cff", 46: "#39c5cf", 47: "#c9d1d9", 100: "#6e7681", 101: "#ff7b72", 102: "#7ee787", 103: "#e3b341", 104: "#79c0ff", 105: "#d2a8ff", 106: "#56d4dd", 107: "#ffffff", }; interface AnsiState { fg: string | null; bg: string | null; bold: boolean; dim: boolean; italic: boolean; underline: boolean; inverse: boolean; } interface ExtendedColorResult { color: string; skip: number; } // Trim trailing whitespace from each line to prevent background color bleeding. // Terminal buffers often pad lines with spaces. Preserve trailing ANSI resets. export function trimLineEndPreserveAnsi(line: string): string { let end = line.length; let ansiSuffix = ""; while (end > 0) { // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character is intentional for ANSI sequences const match = line.slice(0, end).match(/\u001b\[[0-9;?]*[A-Za-z]$/); if (!match) break; ansiSuffix = match[0] + ansiSuffix; end -= match[0].length; } return line.slice(0, end).trimEnd() + ansiSuffix; } // Parse extended color: 38;2;R;G;B (24-bit) or 38;5;N (256-color) // Returns { color, skip } where skip is additional codes to skip function parseExtendedColor( codes: number[], idx: number, ): ExtendedColorResult | null { const mode = codes[idx + 1]; // 24-bit RGB: 38;2;R;G;B if (mode === 2 && codes[idx + 4] !== undefined) { const r = codes[idx + 2]; const g = codes[idx + 3]; const b = codes[idx + 4]; return { color: `rgb(${r},${g},${b})`, skip: 4 }; } // 256-color palette: 38;5;N if (mode === 5) { const colorNum = codes[idx + 2]; if (colorNum === undefined) return null; let color: string; if (colorNum < 16) { const basic = [ "#0d1117", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5", "#666666", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff", ]; color = basic[colorNum] || "#ffffff"; } else if (colorNum < 232) { const n = colorNum - 16; const ri = Math.floor(n / 36); const gi = Math.floor((n % 36) / 6); const bi = n % 6; const r = ri === 0 ? 0 : ri * 40 + 55; const g = gi === 0 ? 0 : gi * 40 + 55; const b = bi === 0 ? 0 : bi * 40 + 55; color = `rgb(${r},${g},${b})`; } else { const gray = (colorNum - 232) * 10 + 8; color = `rgb(${gray},${gray},${gray})`; } return { color, skip: 2 }; } return null; } function resetState(): AnsiState { return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, inverse: false, }; } function buildStyle(state: AnsiState): string { let fg = state.fg; let bg = state.bg; // For inverse, swap fg and bg if (state.inverse) { const tmp = fg; fg = bg || "#ffffff"; bg = tmp || "#0d1117"; } const styles: string[] = []; if (fg) styles.push(`color:${fg}`); if (bg) styles.push(`background-color:${bg}`); if (state.bold) styles.push("font-weight:bold"); if (state.dim) styles.push("opacity:0.5"); if (state.italic) styles.push("font-style:italic"); if (state.underline) styles.push("text-decoration:underline"); return styles.join(";"); } export function ansiToHtml(text: string): string { if (!text) return ""; // Trim trailing whitespace from each line to prevent background color bleeding text = text.split("\n").map(trimLineEndPreserveAnsi).join("\n"); let result = ""; let inSpan = false; let i = 0; let currentStyle = ""; let state = resetState(); function applyStyle(): void { const nextStyle = buildStyle(state); if (nextStyle === currentStyle) return; if (inSpan) { result += ""; inSpan = false; } if (nextStyle) { result += ``; inSpan = true; } currentStyle = nextStyle; } while (i < text.length) { // Check for ESC character (char code 27) if (text.charCodeAt(i) === 27 && text[i + 1] === "[") { // Parse CSI sequence: ESC [ params command let j = i + 2; let params = ""; while (j < text.length && /[0-9;]/.test(text[j] || "")) { params += text[j]; j++; } const command = text[j] || ""; j++; if (command === "m") { // SGR - Select Graphic Rendition const codes = params ? params.split(";").map(Number) : [0]; for (let k = 0; k < codes.length; k++) { const code = codes[k]; if (code === 0) { state = resetState(); } else if (code === 1) state.bold = true; else if (code === 2) state.dim = true; else if (code === 3) state.italic = true; else if (code === 4) state.underline = true; else if (code === 7) state.inverse = true; else if (code === 22) { state.bold = false; state.dim = false; } else if (code === 23) state.italic = false; else if (code === 24) state.underline = false; else if (code === 27) state.inverse = false; else if (code === 39) state.fg = null; else if (code === 49) state.bg = null; else if (code === 38) { const extColor = parseExtendedColor(codes, k); if (extColor) { state.fg = extColor.color; k += extColor.skip; } } else if (code === 48) { const extColor = parseExtendedColor(codes, k); if (extColor) { state.bg = extColor.color; k += extColor.skip; } } else if (code !== undefined && colors[code]) { state.fg = colors[code]; } else if (code !== undefined && bgColors[code]) { state.bg = bgColors[code]; } } applyStyle(); } // Skip other escape sequences (H, f, J, K, etc.) - they don't affect our line-based output i = j; continue; } // Track newlines in the content if (text[i] === "\n" || text[i] === "\r") { if (text[i] === "\n") { // Close span before newline to prevent background color bleeding if (inSpan) { result += ""; } result += "\n"; // Reopen span after newline if we had styling if (inSpan) { result += ``; } } // Skip carriage return - we only care about line feeds i++; continue; } // Regular character - escape HTML const ch = text[i]; if (ch === "<") result += "<"; else if (ch === ">") result += ">"; else if (ch === "&") result += "&"; else result += ch; i++; } if (inSpan) result += ""; return result; }