const ESC = /\x1b\[([0-9;]*)m/g const STYLES: Record = { 1: 'font-weight:bold', 2: 'opacity:0.7', 30: 'color:#666', 31: 'color:#f87171', 32: 'color:#4ade80', 33: 'color:#facc15', 34: 'color:#60a5fa', 35: 'color:#c084fc', 36: 'color:#22d3ee', 37: 'color:#e5e5e5', 90: 'color:#999', 91: 'color:#fca5a5', 92: 'color:#86efac', 93: 'color:#fde047', 94: 'color:#93c5fd', 95: 'color:#d8b4fe', 96: 'color:#67e8f9', 97: 'color:#fff', } const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>') export const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '') export function ansiToHtml(text: string): string { if (!text.includes('\x1b')) return escape(text) ESC.lastIndex = 0 let result = '' let last = 0 let open = false const styles: string[] = [] let match: RegExpExecArray | null while ((match = ESC.exec(text)) !== null) { result += escape(text.slice(last, match.index)) last = match.index + match[0].length const codes = match[1] ? match[1].split(';').map(Number) : [0] for (const code of codes) { if (code === 0) { styles.length = 0 if (open) { result += ''; open = false } } else if (code === 39) { const filtered = styles.filter(s => !s.startsWith('color:')) styles.length = 0 styles.push(...filtered) if (open) { result += ''; open = false } } else if (STYLES[code]) { styles.push(STYLES[code]) } } if (styles.length) { if (open) result += '' result += `` open = true } } result += escape(text.slice(last)) if (open) result += '' return result }