|
| 1 | +/** Parse ANSI escape codes into styled segments for rendering */ |
| 2 | + |
| 3 | +export interface AnsiSegment { |
| 4 | + text: string; |
| 5 | + fg?: string; |
| 6 | + bg?: string; |
| 7 | + bold?: boolean; |
| 8 | + dim?: boolean; |
| 9 | + italic?: boolean; |
| 10 | + underline?: boolean; |
| 11 | +} |
| 12 | + |
| 13 | +const ANSI_COLORS: Record<number, string> = { |
| 14 | + 30: "#000000", 31: "#cc0000", 32: "#4e9a06", 33: "#c4a000", |
| 15 | + 34: "#3465a4", 35: "#75507b", 36: "#06989a", 37: "#d3d7cf", |
| 16 | + 90: "#555753", 91: "#ef2929", 92: "#8ae234", 93: "#fce94f", |
| 17 | + 94: "#729fcf", 95: "#ad7fa8", 96: "#34e2e2", 97: "#eeeeec", |
| 18 | +}; |
| 19 | + |
| 20 | +const ANSI_BG_COLORS: Record<number, string> = { |
| 21 | + 40: "#000000", 41: "#cc0000", 42: "#4e9a06", 43: "#c4a000", |
| 22 | + 44: "#3465a4", 45: "#75507b", 46: "#06989a", 47: "#d3d7cf", |
| 23 | + 100: "#555753", 101: "#ef2929", 102: "#8ae234", 103: "#fce94f", |
| 24 | + 104: "#729fcf", 105: "#ad7fa8", 106: "#34e2e2", 107: "#eeeeec", |
| 25 | +}; |
| 26 | + |
| 27 | +// 256-color palette (indices 0-255) |
| 28 | +const COLOR_256: string[] = (() => { |
| 29 | + const palette: string[] = []; |
| 30 | + // 0-7: standard colors |
| 31 | + palette.push("#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf"); |
| 32 | + // 8-15: bright colors |
| 33 | + palette.push("#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec"); |
| 34 | + // 16-231: 6x6x6 color cube |
| 35 | + for (let r = 0; r < 6; r++) { |
| 36 | + for (let g = 0; g < 6; g++) { |
| 37 | + for (let b = 0; b < 6; b++) { |
| 38 | + const rv = r === 0 ? 0 : 55 + r * 40; |
| 39 | + const gv = g === 0 ? 0 : 55 + g * 40; |
| 40 | + const bv = b === 0 ? 0 : 55 + b * 40; |
| 41 | + palette.push(`#${rv.toString(16).padStart(2, "0")}${gv.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`); |
| 42 | + } |
| 43 | + } |
| 44 | + } |
| 45 | + // 232-255: grayscale |
| 46 | + for (let i = 0; i < 24; i++) { |
| 47 | + const v = 8 + i * 10; |
| 48 | + palette.push(`#${v.toString(16).padStart(2, "0")}${v.toString(16).padStart(2, "0")}${v.toString(16).padStart(2, "0")}`); |
| 49 | + } |
| 50 | + return palette; |
| 51 | +})(); |
| 52 | + |
| 53 | +// Regex to match ANSI escape sequences |
| 54 | +const ANSI_RE = /\x1b\[([0-9;]*)m/g; |
| 55 | + |
| 56 | +interface AnsiState { |
| 57 | + fg?: string; |
| 58 | + bg?: string; |
| 59 | + bold?: boolean; |
| 60 | + dim?: boolean; |
| 61 | + italic?: boolean; |
| 62 | + underline?: boolean; |
| 63 | +} |
| 64 | + |
| 65 | +function applyCode(state: AnsiState, codes: number[]): void { |
| 66 | + let i = 0; |
| 67 | + while (i < codes.length) { |
| 68 | + const code = codes[i]!; |
| 69 | + |
| 70 | + if (code === 0) { |
| 71 | + state.fg = undefined; |
| 72 | + state.bg = undefined; |
| 73 | + state.bold = undefined; |
| 74 | + state.dim = undefined; |
| 75 | + state.italic = undefined; |
| 76 | + state.underline = undefined; |
| 77 | + } else if (code === 1) { |
| 78 | + state.bold = true; |
| 79 | + } else if (code === 2) { |
| 80 | + state.dim = true; |
| 81 | + } else if (code === 3) { |
| 82 | + state.italic = true; |
| 83 | + } else if (code === 4) { |
| 84 | + state.underline = true; |
| 85 | + } else if (code === 22) { |
| 86 | + state.bold = undefined; |
| 87 | + state.dim = undefined; |
| 88 | + } else if (code === 23) { |
| 89 | + state.italic = undefined; |
| 90 | + } else if (code === 24) { |
| 91 | + state.underline = undefined; |
| 92 | + } else if (code === 39) { |
| 93 | + state.fg = undefined; |
| 94 | + } else if (code === 49) { |
| 95 | + state.bg = undefined; |
| 96 | + } else if (code >= 30 && code <= 37) { |
| 97 | + state.fg = ANSI_COLORS[code]; |
| 98 | + } else if (code >= 90 && code <= 97) { |
| 99 | + state.fg = ANSI_COLORS[code]; |
| 100 | + } else if (code >= 40 && code <= 47) { |
| 101 | + state.bg = ANSI_BG_COLORS[code]; |
| 102 | + } else if (code >= 100 && code <= 107) { |
| 103 | + state.bg = ANSI_BG_COLORS[code]; |
| 104 | + } else if (code === 38 && codes[i + 1] === 5 && codes[i + 2] !== undefined) { |
| 105 | + state.fg = COLOR_256[codes[i + 2]!] ?? undefined; |
| 106 | + i += 2; |
| 107 | + } else if (code === 48 && codes[i + 1] === 5 && codes[i + 2] !== undefined) { |
| 108 | + state.bg = COLOR_256[codes[i + 2]!] ?? undefined; |
| 109 | + i += 2; |
| 110 | + } else if (code === 38 && codes[i + 1] === 2 && codes.length >= i + 5) { |
| 111 | + const r = codes[i + 2]!; |
| 112 | + const g = codes[i + 3]!; |
| 113 | + const b = codes[i + 4]!; |
| 114 | + state.fg = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; |
| 115 | + i += 4; |
| 116 | + } else if (code === 48 && codes[i + 1] === 2 && codes.length >= i + 5) { |
| 117 | + const r = codes[i + 2]!; |
| 118 | + const g = codes[i + 3]!; |
| 119 | + const b = codes[i + 4]!; |
| 120 | + state.bg = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; |
| 121 | + i += 4; |
| 122 | + } |
| 123 | + |
| 124 | + i++; |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +export function parseAnsi(text: string): AnsiSegment[] { |
| 129 | + const segments: AnsiSegment[] = []; |
| 130 | + const state: AnsiState = {}; |
| 131 | + let lastIndex = 0; |
| 132 | + |
| 133 | + ANSI_RE.lastIndex = 0; |
| 134 | + let match: RegExpExecArray | null; |
| 135 | + |
| 136 | + while ((match = ANSI_RE.exec(text)) !== null) { |
| 137 | + if (match.index > lastIndex) { |
| 138 | + const chunk = text.slice(lastIndex, match.index); |
| 139 | + if (chunk) { |
| 140 | + segments.push({ |
| 141 | + text: chunk, |
| 142 | + ...(state.fg && { fg: state.fg }), |
| 143 | + ...(state.bg && { bg: state.bg }), |
| 144 | + ...(state.bold && { bold: true }), |
| 145 | + ...(state.dim && { dim: true }), |
| 146 | + ...(state.italic && { italic: true }), |
| 147 | + ...(state.underline && { underline: true }), |
| 148 | + }); |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + const codeStr = match[1]!; |
| 153 | + const codes = codeStr === "" ? [0] : codeStr.split(";").map(Number); |
| 154 | + applyCode(state, codes); |
| 155 | + |
| 156 | + lastIndex = ANSI_RE.lastIndex; |
| 157 | + } |
| 158 | + |
| 159 | + if (lastIndex < text.length) { |
| 160 | + const chunk = text.slice(lastIndex); |
| 161 | + if (chunk) { |
| 162 | + segments.push({ |
| 163 | + text: chunk, |
| 164 | + ...(state.fg && { fg: state.fg }), |
| 165 | + ...(state.bg && { bg: state.bg }), |
| 166 | + ...(state.bold && { bold: true }), |
| 167 | + ...(state.dim && { dim: true }), |
| 168 | + ...(state.italic && { italic: true }), |
| 169 | + ...(state.underline && { underline: true }), |
| 170 | + }); |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + if (segments.length === 0 && text) { |
| 175 | + segments.push({ text }); |
| 176 | + } |
| 177 | + |
| 178 | + return segments; |
| 179 | +} |
| 180 | + |
| 181 | +/** Check if text contains any ANSI escape codes */ |
| 182 | +export function hasAnsi(text: string): boolean { |
| 183 | + return /\x1b\[/.test(text); |
| 184 | +} |
| 185 | + |
| 186 | +/** Strip ANSI codes from text (for search, MCP output, etc.) */ |
| 187 | +export function stripAnsi(text: string): string { |
| 188 | + return text.replace(/\x1b\[[0-9;]*m/g, ""); |
| 189 | +} |
0 commit comments