Skip to content
File

Blob: src/client/lib/ansi.ts

typescript298 lines
1import { parse, type CODE } from "@ansi-tools/parser";
2 
3// ---------------------------------------------------------------------------
4// Types
5// ---------------------------------------------------------------------------
6 
7export 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 
18export interface AnsiProcessor {
19 feed(chunk: string): AnsiSpan[];
20}
21 
22// ---------------------------------------------------------------------------
23// 16-color palette – tuned for dark bg (zinc-950)
24// ---------------------------------------------------------------------------
25 
26const 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 
45function 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 
61function 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 
70interface 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 
80const 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 
90function 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 
103function 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 */
124function 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 
147function 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 */
214function 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 
237function 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). */
265export 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 */
278export 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}