File
Blob: src/client/lib/ansi.ts
| 1 | import { parse, type CODE } from "@ansi-tools/parser"; |
| 2 | |
| 3 | // --------------------------------------------------------------------------- |
| 4 | // Types |
| 5 | // --------------------------------------------------------------------------- |
| 6 | |
| 7 | export interface AnsiSpan { |
| 8 | text: string; |
| 9 | fg: string | null; |
| 10 | bg: string | null; |
| 11 | bold: boolean; |
| 12 | dim: boolean; |
| 13 | italic: boolean; |
| 14 | underline: boolean; |
| 15 | strikethrough: boolean; |
| 16 | } |
| 17 | |
| 18 | export interface AnsiProcessor { |
| 19 | feed(chunk: string): AnsiSpan[]; |
| 20 | } |
| 21 | |
| 22 | // --------------------------------------------------------------------------- |
| 23 | // 16-color palette – tuned for dark bg (zinc-950) |
| 24 | // --------------------------------------------------------------------------- |
| 25 | |
| 26 | const COLORS_16: readonly string[] = [ |
| 27 | "#423f42", // 0 black (zinc-700) |
| 28 | "#f87171", // 1 red (red-400) |
| 29 | "#4ade80", // 2 green (green-400) |
| 30 | "#facc15", // 3 yellow (yellow-400) |
| 31 | "#56a6f3", // 4 blue (accent-400) |
| 32 | "#c084fc", // 5 magenta (purple-400) |
| 33 | "#22d3ee", // 6 cyan (cyan-400) |
| 34 | "#d6d4d7", // 7 white (zinc-300) |
| 35 | "#747178", // 8 bright black (zinc-500) |
| 36 | "#fca5a5", // 9 bright red (red-300) |
| 37 | "#86efac", // 10 bright green (green-300) |
| 38 | "#fde047", // 11 bright yellow(yellow-300) |
| 39 | "#93c5fd", // 12 bright blue (blue-300) |
| 40 | "#d8b4fe", // 13 bright mag. (purple-300) |
| 41 | "#67e8f9", // 14 bright cyan (cyan-300) |
| 42 | "#fafaf9", // 15 bright white (zinc-50) |
| 43 | ]; |
| 44 | |
| 45 | function color256(n: number): string | null { |
| 46 | if (n < 0 || n > 255) return null; |
| 47 | if (n < 16) return COLORS_16[n]!; |
| 48 | if (n < 232) { |
| 49 | // 6×6×6 color cube (indices 16–231) |
| 50 | const idx = n - 16; |
| 51 | const r = Math.floor(idx / 36) * 51; |
| 52 | const g = (Math.floor(idx / 6) % 6) * 51; |
| 53 | const b = (idx % 6) * 51; |
| 54 | return `rgb(${r},${g},${b})`; |
| 55 | } |
| 56 | // Grayscale ramp (indices 232–255) |
| 57 | const level = (n - 232) * 10 + 8; |
| 58 | return `rgb(${level},${level},${level})`; |
| 59 | } |
| 60 | |
| 61 | function rgb(r: number, g: number, b: number): string { |
| 62 | const clamp = (v: number) => Math.max(0, Math.min(255, v)); |
| 63 | return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`; |
| 64 | } |
| 65 | |
| 66 | // --------------------------------------------------------------------------- |
| 67 | // Style state |
| 68 | // --------------------------------------------------------------------------- |
| 69 | |
| 70 | interface StyleState { |
| 71 | fg: string | null; |
| 72 | bg: string | null; |
| 73 | bold: boolean; |
| 74 | dim: boolean; |
| 75 | italic: boolean; |
| 76 | underline: boolean; |
| 77 | strikethrough: boolean; |
| 78 | } |
| 79 | |
| 80 | const DEFAULT_STYLE: Readonly<StyleState> = { |
| 81 | fg: null, |
| 82 | bg: null, |
| 83 | bold: false, |
| 84 | dim: false, |
| 85 | italic: false, |
| 86 | underline: false, |
| 87 | strikethrough: false, |
| 88 | }; |
| 89 | |
| 90 | function spanFromStyle(text: string, s: StyleState): AnsiSpan { |
| 91 | return { |
| 92 | text, |
| 93 | fg: s.fg, |
| 94 | bg: s.bg, |
| 95 | bold: s.bold, |
| 96 | dim: s.dim, |
| 97 | italic: s.italic, |
| 98 | underline: s.underline, |
| 99 | strikethrough: s.strikethrough, |
| 100 | }; |
| 101 | } |
| 102 | |
| 103 | function spanMatchesStyle(span: AnsiSpan, s: StyleState): boolean { |
| 104 | return ( |
| 105 | span.fg === s.fg && |
| 106 | span.bg === s.bg && |
| 107 | span.bold === s.bold && |
| 108 | span.dim === s.dim && |
| 109 | span.italic === s.italic && |
| 110 | span.underline === s.underline && |
| 111 | span.strikethrough === s.strikethrough |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | // --------------------------------------------------------------------------- |
| 116 | // SGR param interpretation |
| 117 | // --------------------------------------------------------------------------- |
| 118 | |
| 119 | /** |
| 120 | * Parse an extended color (256 or 24-bit RGB) from the param array starting |
| 121 | * after the `38`/`48` at position `i`. Returns the resolved color string and |
| 122 | * how many extra indices were consumed (so the caller can advance `i`). |
| 123 | */ |
| 124 | function parseExtendedColor(nums: number[], i: number): { color: string | null; skip: number } { |
| 125 | if (nums[i + 1] === 5 && i + 2 < nums.length) { |
| 126 | // 256-color: 38;5;n / 48;5;n |
| 127 | return { color: color256(nums[i + 2]!), skip: 2 }; |
| 128 | } |
| 129 | |
| 130 | if (nums[i + 1] === 2) { |
| 131 | // 24-bit RGB. The library normalises standalone `38;2;R;G;B` to |
| 132 | // `38;2;0;R;G;B` (inserting a color-space param), but does NOT normalise |
| 133 | // combined sequences like `1;38;2;R;G;B;4`. Heuristic: if the value |
| 134 | // immediately after `2` is 0 and we have 4+ remaining values, treat it |
| 135 | // as Cs;R;G;B (skip the color-space). Otherwise treat as R;G;B. |
| 136 | if (nums[i + 2] === 0 && i + 5 < nums.length) { |
| 137 | return { color: rgb(nums[i + 3]!, nums[i + 4]!, nums[i + 5]!), skip: 5 }; |
| 138 | } |
| 139 | if (i + 4 < nums.length) { |
| 140 | return { color: rgb(nums[i + 2]!, nums[i + 3]!, nums[i + 4]!), skip: 4 }; |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | return { color: null, skip: 0 }; |
| 145 | } |
| 146 | |
| 147 | function applySgr(style: StyleState, params: string[]): void { |
| 148 | // \x1b[m (empty params) is equivalent to reset |
| 149 | if (params.length === 0) { |
| 150 | Object.assign(style, DEFAULT_STYLE); |
| 151 | return; |
| 152 | } |
| 153 | |
| 154 | const nums = params.map(Number); |
| 155 | let i = 0; |
| 156 | while (i < nums.length) { |
| 157 | const code = nums[i]!; |
| 158 | |
| 159 | // Reset |
| 160 | if (code === 0) { |
| 161 | Object.assign(style, DEFAULT_STYLE); |
| 162 | } |
| 163 | // Decorations on |
| 164 | else if (code === 1) style.bold = true; |
| 165 | else if (code === 2) style.dim = true; |
| 166 | else if (code === 3) style.italic = true; |
| 167 | else if (code === 4) style.underline = true; |
| 168 | else if (code === 9) style.strikethrough = true; |
| 169 | // Decorations off |
| 170 | else if (code === 22) { |
| 171 | style.bold = false; |
| 172 | style.dim = false; |
| 173 | } else if (code === 23) style.italic = false; |
| 174 | else if (code === 24) style.underline = false; |
| 175 | else if (code === 29) style.strikethrough = false; |
| 176 | // Standard foreground (30–37) |
| 177 | else if (code >= 30 && code <= 37) style.fg = COLORS_16[code - 30]!; |
| 178 | // Extended foreground (38;5;n or 38;2;…) |
| 179 | else if (code === 38) { |
| 180 | const ext = parseExtendedColor(nums, i); |
| 181 | style.fg = ext.color; |
| 182 | i += ext.skip; |
| 183 | } |
| 184 | // Default foreground |
| 185 | else if (code === 39) style.fg = null; |
| 186 | // Standard background (40–47) |
| 187 | else if (code >= 40 && code <= 47) style.bg = COLORS_16[code - 40]!; |
| 188 | // Extended background (48;5;n or 48;2;…) |
| 189 | else if (code === 48) { |
| 190 | const ext = parseExtendedColor(nums, i); |
| 191 | style.bg = ext.color; |
| 192 | i += ext.skip; |
| 193 | } |
| 194 | // Default background |
| 195 | else if (code === 49) style.bg = null; |
| 196 | // Bright foreground (90–97) |
| 197 | else if (code >= 90 && code <= 97) style.fg = COLORS_16[code - 90 + 8]!; |
| 198 | // Bright background (100–107) |
| 199 | else if (code >= 100 && code <= 107) style.bg = COLORS_16[code - 100 + 8]!; |
| 200 | |
| 201 | i++; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // --------------------------------------------------------------------------- |
| 206 | // Trailing incomplete-escape detection |
| 207 | // --------------------------------------------------------------------------- |
| 208 | |
| 209 | /** |
| 210 | * Split `input` into a "complete" prefix that is safe to feed to the parser, |
| 211 | * and a "remainder" suffix that contains a trailing incomplete escape sequence |
| 212 | * (to be carried into the next chunk). |
| 213 | */ |
| 214 | function splitTrailingEscape(input: string): { complete: string; remainder: string } { |
| 215 | const lastEsc = input.lastIndexOf("\x1b"); |
| 216 | if (lastEsc === -1) return { complete: input, remainder: "" }; |
| 217 | |
| 218 | const tail = input.slice(lastEsc); |
| 219 | |
| 220 | // Complete CSI/SGR: \x1b[ <params> <letter> |
| 221 | if (/^\x1b\[[\d;:]*[A-Za-z]/.test(tail)) return { complete: input, remainder: "" }; |
| 222 | // Complete two-char ESC sequence: \x1b <letter> (but not \x1b[ or \x1b]) |
| 223 | if (tail.length >= 2 && /^[A-Za-z]/.test(tail[1]!) && tail[1] !== "[" && tail[1] !== "]") { |
| 224 | return { complete: input, remainder: "" }; |
| 225 | } |
| 226 | // Complete OSC: \x1b] … terminated by BEL or ST |
| 227 | if (/^\x1b\].*(?:\x07|\x1b\\)/s.test(tail)) return { complete: input, remainder: "" }; |
| 228 | |
| 229 | // The tail is an incomplete escape — buffer it for the next chunk |
| 230 | return { complete: input.slice(0, lastEsc), remainder: tail }; |
| 231 | } |
| 232 | |
| 233 | // --------------------------------------------------------------------------- |
| 234 | // Core: walk CODE[] from the library and emit AnsiSpan[] |
| 235 | // --------------------------------------------------------------------------- |
| 236 | |
| 237 | function processCodeArray(codes: CODE[], style: StyleState): AnsiSpan[] { |
| 238 | const spans: AnsiSpan[] = []; |
| 239 | |
| 240 | for (const code of codes) { |
| 241 | if (code.type === "TEXT") { |
| 242 | if (code.raw.length === 0) continue; |
| 243 | |
| 244 | // Coalesce with previous span when styles match |
| 245 | const prev = spans.at(-1); |
| 246 | if (prev && spanMatchesStyle(prev, style)) { |
| 247 | prev.text += code.raw; |
| 248 | } else { |
| 249 | spans.push(spanFromStyle(code.raw, style)); |
| 250 | } |
| 251 | } else if (code.type === "CSI" && code.command === "m") { |
| 252 | applySgr(style, code.params); |
| 253 | } |
| 254 | // All other control codes (CSI non-SGR, OSC, DCS, …) are silently dropped |
| 255 | } |
| 256 | |
| 257 | return spans; |
| 258 | } |
| 259 | |
| 260 | // --------------------------------------------------------------------------- |
| 261 | // Public API |
| 262 | // --------------------------------------------------------------------------- |
| 263 | |
| 264 | /** Stateless single-shot ANSI parse (no cross-chunk state). */ |
| 265 | export function parseAnsi(input: string): AnsiSpan[] { |
| 266 | if (!input) return []; |
| 267 | if (!input.includes("\x1b")) return [spanFromStyle(input, DEFAULT_STYLE)]; |
| 268 | |
| 269 | const codes = parse(input); |
| 270 | const style: StyleState = { ...DEFAULT_STYLE }; |
| 271 | return processCodeArray(codes, style); |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Create a stateful ANSI processor that carries SGR style and |
| 276 | * incomplete-escape buffers across successive `feed()` calls. |
| 277 | */ |
| 278 | export function createAnsiProcessor(): AnsiProcessor { |
| 279 | let pending = ""; |
| 280 | const style: StyleState = { ...DEFAULT_STYLE }; |
| 281 | |
| 282 | return { |
| 283 | feed(chunk: string): AnsiSpan[] { |
| 284 | const input = pending + chunk; |
| 285 | pending = ""; |
| 286 | |
| 287 | const { complete, remainder } = splitTrailingEscape(input); |
| 288 | pending = remainder; |
| 289 | |
| 290 | if (!complete) return []; |
| 291 | if (!complete.includes("\x1b")) return [spanFromStyle(complete, style)]; |
| 292 | |
| 293 | const codes = parse(complete); |
| 294 | return processCodeArray(codes, style); |
| 295 | }, |
| 296 | }; |
| 297 | } |