返回 DeepSeek-Reasonix
ansi.ts
1 // Ported from DeepSeek Harness c291e7961a (MIT).
2 // Strip control sequences that anser does not consume before resolving SGR
3 // runs, so they cannot reach the DOM as literal characters.
4
5 import Anser from 'anser'
6 import type { CSSProperties } from 'react'
7
8 /**
9 * The subset of one anser JSON chunk this module reads. anser's own types
10 * declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run
11 * that sets no color, so the null is spelled out here.
12 */
13 interface AnsiChunk {
14 /** Run text with its SGR codes already removed. */
15 content: string
16 /** Foreground as an `r, g, b` triple, or null when the run sets none. */
17 fg: string | null
18 /** Background as an `r, g, b` triple, or null when the run sets none. */
19 bg: string | null
20 /** SGR attributes in effect for the run, in the order they were declared. */
21 decorations: readonly string[]
22 }
23
24 /** One run of terminal text; `style` is undefined for text that carries no SGR state. */
25 export interface AnsiSpan {
26 /** The run's plain text, free of escape sequences and newlines. */
27 text: string
28 /** Resolved inline style, or undefined when the run needs no wrapper. */
29 style: CSSProperties | undefined
30 }
31
32 /** The spans of one output line, in order. */
33 export type AnsiLine = readonly AnsiSpan[]
34
35 /**
36 * The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple
37 * anser emits for them, mapped onto the theme tokens that carry the same
38 * semantic. Black and white both resolve to the primary label color so text
39 * stays legible under either theme instead of matching the surface it sits
40 * on; bright black takes the tertiary label color (the muted-gray role).
41 * Magenta and cyan have no token equivalent in this design system and fall
42 * through to anser's literal rgb, as do all 256-palette and truecolor values.
43 */
44 const TOKEN_BY_BASIC_RGB: Record<string, string> = {
45 '0,0,0': 'var(--dsw-alias-label-primary)',
46 '255,255,255': 'var(--dsw-alias-label-primary)',
47 '85,85,85': 'var(--dsw-alias-label-tertiary)',
48 '187,0,0': 'var(--dsw-alias-state-error-primary)',
49 '255,85,85': 'var(--dsw-alias-state-error-secondary)',
50 '0,187,0': 'var(--dsw-alias-state-success-primary)',
51 '0,255,0': 'var(--dsw-alias-state-success-secondary)',
52 '187,187,0': 'var(--dsw-alias-state-warn-primary)',
53 '255,255,85': 'var(--dsw-alias-state-warn-secondary)',
54 '0,0,187': 'var(--dsw-alias-state-business-primary)',
55 '85,85,255': 'var(--dsw-static-blue-400)',
56 }
57
58 /**
59 * CSS for each SGR attribute anser reports. `blink` is deliberately absent —
60 * animated text is not reproduced. `reverse` never arrives here: anser
61 * consumes it by swapping the run's foreground and background. Underline and
62 * strikethrough share `textDecoration`, so in a run declaring both, the
63 * later declaration wins.
64 */
65 const STYLE_BY_DECORATION: Record<string, CSSProperties | undefined> = {
66 bold: { fontWeight: 700 },
67 dim: { opacity: 0.7 },
68 italic: { fontStyle: 'italic' },
69 underline: { textDecoration: 'underline' },
70 strikethrough: { textDecoration: 'line-through' },
71 hidden: { visibility: 'hidden' },
72 }
73
74 /** OSC strings (window title, hyperlinks), with or without their terminator. */
75 const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
76
77 /** Escape sequences other than CSI: charset selection, single-shift, reset. */
78 const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
79
80 /**
81 * C0 controls with no display meaning here. Tab, newline, backspace and ESC
82 * survive: the first two for layout, backspace for the cursor replay, ESC
83 * for anser's CSI split.
84 */
85 const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
86
87 /**
88 * Lines whose cursor movements have to be replayed: a carriage return, a
89 * backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
90 * `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
91 * form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
92 */
93 const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
94
95 /** SGR sequences alone, for folding state through a line that needs no replay. */
96 const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
97
98 /** Terminal tab stop width; a tab advances to the next multiple of this. */
99 const TAB_WIDTH = 8
100
101 /**
102 * Combining marks and other zero-width code points: a terminal advances no
103 * column for them, so `e` + U+0301 occupies one cell and a two-column redraw
104 * covers both code points.
105 */
106 const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
107
108 /**
109 * Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
110 * CJK punctuation, and characters with emoji presentation. Text-presentation
111 * symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
112 * must stay out of this set.
113 */
114 const WIDE_CHAR = new RegExp(
115 '\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
116 // Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
117 // width — `\u2713` (the check every progress line writes, this fixture
118 // included) advances one column, verified against a real terminal, so taking
119 // the whole block as wide misaligned exactly the output this card exists for.
120 + '|\\p{Emoji_Presentation}'
121 + '|[\\uff01-\\uff60\\u3000-\\u303e]',
122 'u',
123 )
124
125 /**
126 * Whether a character occupies two terminal columns (CJK, fullwidth forms,
127 * emoji). Covers the ranges a command's output realistically carries; a
128 * narrower guess would misalign the columns this card exists to preserve.
129 * @param char - one character from the output.
130 * @returns true when the terminal advances two columns for it.
131 */
132 function isWide(char: string): boolean {
133 const code = char.codePointAt(0)
134 if (code === undefined || code < 0x1100) return false
135 return WIDE_CHAR.test(char)
136 }
137
138 /**
139 * A cell's graphic state, normalized. Held as fields rather than as the raw
140 * sequence history because a terminal tracks CURRENT state, not a transcript:
141 * accumulating sequences made each state boundary re-emit the whole chain, so
142 * output that switches color without a full reset emitted O(n^2) characters
143 * (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
144 * the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
145 * `24`, `27`, `29` — actually close their attribute instead of appending to it.
146 */
147 interface SgrState {
148 fg: string
149 bg: string
150 /** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
151 attrs: readonly string[]
152 }
153
154 /** The default state: no color, no attributes. */
155 const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
156
157 /** Attribute closers, mapped to the opener parameters each one turns off. */
158 const ATTR_CLOSERS: Record<string, readonly string[]> = {
159 22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
160 }
161
162 /**
163 * Fold one SGR sequence's parameters into the state it produces.
164 * @param state - state in force before the sequence.
165 * @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
166 * @returns the state the sequence leaves in force.
167 */
168 function foldSgr(state: SgrState, params: string): SgrState {
169 const codes = params === '' ? ['0'] : params.split(';')
170 let next = state
171 for (let index = 0; index < codes.length; index++) {
172 const code = String(codes[index])
173 if (code === '' || code === '0') { next = SGR_NONE; continue }
174 // Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
175 // consume their own arguments, so they are taken whole.
176 if (code === '38' || code === '48') {
177 const kind = codes[index + 1] ?? ''
178 const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
179 const value = codes.slice(index, index + span + 1).join(';')
180 next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
181 index += span
182 continue
183 }
184 const closes = ATTR_CLOSERS[code]
185 if (closes !== undefined) {
186 next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
187 continue
188 }
189 const numeric = Number(code)
190 if (code === '39') { next = { ...next, fg: '' }; continue }
191 if (code === '49') { next = { ...next, bg: '' }; continue }
192 if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
193 if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
194 if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
195 }
196 return next
197 }
198
199 /**
200 * Render a state as the one canonical sequence that establishes it from the
201 * default, so a boundary emits a bounded string no matter how the state was
202 * reached.
203 * @param state - the state to open.
204 * @returns the SGR sequence, or the empty string for the default state.
205 */
206 function openSgr(state: SgrState): string {
207 const codes = [...state.attrs]
208 if (state.fg !== '') codes.push(state.fg)
209 if (state.bg !== '') codes.push(state.bg)
210 return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
211 }
212
213 /** Whether two states are the same, so a boundary is only emitted on a change. */
214 function sameSgr(a: SgrState, b: SgrState): boolean {
215 return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
216 && a.attrs.every((attr, index) => attr === b.attrs[index])
217 }
218
219 /**
220 * Replay one line's cursor movements the way a terminal paints it, into a
221 * column buffer. Carriage return and backspace only MOVE the cursor — neither
222 * erases anything — so what a reader sees is whatever each column last had
223 * written to it. That distinction is the whole point of doing this as a buffer
224 * rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
225 * shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
226 * because nothing ever overwrote the `c`.
227 *
228 * A CSI sequence occupies no column; it changes the state that the NEXT writes
229 * are stamped with, which is how a terminal stores color per cell. `red bad`
230 * then three backspaces then `ok` therefore shows `okd` with the `d` still red:
231 * `ok` overwrote two cells and the third kept the state it was written with.
232 * The columns are re-emitted as runs, so anser sees that same styling.
233 * @param line - one output line, still carrying its CSI sequences.
234 * @param entrySgr - SGR state in force when the line begins, since a newline
235 * does not reset it.
236 * @returns the line as the terminal would have it after every movement, plus the
237 * SGR state at its end for the next line to enter with.
238 */
239 function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
240 // Same shape anser splits on, so a sequence is one unit here as well.
241 const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
242 /** Per column: the state in force when it was written, and its character. */
243 const columns: (Cell | undefined)[] = []
244 let cursor = 0
245 // State is tracked exactly as a terminal tracks it: each cell is stamped with
246 // whatever was in force at the moment of the write, so a later redraw cannot
247 // restyle the cells it does not reach. It enters carrying the previous line's
248 // state, since a newline does not reset it.
249 let sgr = entrySgr
250 let at = 0
251
252 /** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
253 const clear = (index: number, fill: string): void => {
254 const cell = columns[index]
255 if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
256 else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
257 columns[index + 1] = { sgr, char: fill }
258 }
259 columns[index] = { sgr, char: fill }
260 }
261
262 const consume = (chunk: string): void => {
263 for (const char of chunk) {
264 if (char === '\r') { cursor = 0; continue }
265 if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
266 if (char === '\t') {
267 // A tab advances to the next 8-column stop, leaving the cells it skips
268 // as they were — which is how a redraw can leave a tabbed column
269 // standing. Column alignment is the whole point of this card.
270 const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
271 for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
272 continue
273 }
274 if (ZERO_WIDTH.test(char)) {
275 // No column of its own: it attaches to the cell already written, so a
276 // redraw that covers that cell covers the mark with it. With no cell to
277 // attach to (line start, or straight after a redraw to column 0) a
278 // terminal shows nothing rather than a lone accent.
279 const base = cursor > 0 ? columns[cursor - 1] : undefined
280 if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
281 continue
282 }
283 // Writing over either half of a wide pair blanks the other half, since a
284 // terminal cannot leave one cell of a two-cell glyph standing.
285 clear(cursor, ' ')
286 columns[cursor] = { sgr, char }
287 cursor++
288 // A wide character occupies two columns; the trailing one is a spacer,
289 // marked so that overwriting the lead cell leaves a blank behind instead
290 // of closing the gap and shifting everything after it left.
291 if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
292 }
293 }
294
295 for (const match of line.matchAll(csi)) {
296 consume(line.slice(at, match.index))
297 at = match.index + match[0].length
298 // Both groups are mandatory in the pattern, so destructuring types them as
299 // strings without a fallback that could never run.
300 const params = String(match[1])
301 const final = String(match[2])
302 if (final === 'K') {
303 // Erase in line: the fixed companion of `\r` in every spinner and progress
304 // bar. Without it a shorter redraw leaves the previous frame's tail
305 // standing, which is text the terminal never showed. `1` blanks from the
306 // line start THROUGH the cursor column (inclusive, per the CSI spec)
307 // rather than dropping those cells, since the cursor does not move and a
308 // later write can still land past them. Only the FIRST parameter selects
309 // the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
310 const mode = String(params.split(';')[0])
311 if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
312 else columns.length = mode === '2' ? 0 : cursor
313 continue
314 }
315 // Only SGR carries graphic state; every other final byte is a cursor or
316 // erase action that must not affect a cell's style.
317 if (final !== 'm') continue
318 sgr = foldSgr(sgr, params)
319 }
320 consume(line.slice(at))
321
322 // Re-emit the columns, opening a run only where its state changes, so anser
323 // sees the same styling a terminal shows. Each boundary emits ONE canonical
324 // sequence for the state it opens, which is what keeps the output linear in
325 // the number of cells however the state was reached.
326 let out = ''
327 let active = entrySgr
328 for (let index = 0; index < columns.length; index++) {
329 const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
330 if (!sameSgr(column.sgr, active)) {
331 if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
332 out += openSgr(column.sgr)
333 active = column.sgr
334 }
335 // A spacer still holds its column. While its lead cell survives, the wide
336 // glyph spans both and the spacer emits nothing; once a later write replaced
337 // that lead, the terminal blanks the spacer instead of closing the gap, so
338 // emitting nothing would shift everything after it one column left.
339 const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
340 out += column.spacer === true && !leadIntact ? ' ' : column.char
341 }
342 // Converge to the state the SCAN ended in, not the last written cell's: a
343 // sequence after the final write (the `\x1b[0m` closing a colored line) changes
344 // no cell yet still ends the run, and it has to reach both the DOM and the
345 // next line. Without this a line ending in a reset leaked its color onward.
346 if (!sameSgr(active, sgr)) {
347 if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
348 out += openSgr(sgr)
349 }
350 return { text: out, sgr }
351 }
352
353 /** One replayed column: the state it was written with, and its character. */
354 interface Cell {
355 sgr: SgrState
356 char: string
357 /** The trailing half of a wide character's two-column pair. */
358 spacer?: boolean
359 }
360
361 /**
362 * Replay every line's cursor movements. A `\r` that only terminates a CRLF line
363 * is dropped first, so those lines keep their text instead of being redrawn onto
364 * themselves. SGR state threads across lines: a newline does not reset it, so a
365 * run opened before a redraw still colors the lines after it.
366 * @param text - output text, already free of OSC and non-CSI escapes.
367 * @returns the text with each line painted as the terminal would.
368 */
369 function applyCursorMovements(text: string): string {
370 const replayed: string[] = []
371 let sgr = SGR_NONE
372 for (const raw of text.split('\n')) {
373 const line = raw.replace(/\r+$/, '')
374 if (NEEDS_REPLAY.test(line)) {
375 const result = replayLine(line, sgr)
376 replayed.push(result.text)
377 sgr = result.sgr
378 continue
379 }
380 // No cursor movement: the line needs no column buffer, and painting one
381 // would allocate a cell per character of output this card never redraws —
382 // an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
383 // line that DOES replay enters with the right state.
384 replayed.push(line)
385 for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
386 }
387 return replayed.join('\n')
388 }
389
390 /**
391 * Remove every escape sequence and control character that carries no color,
392 * leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
393 * (carriage return, backspace) replay first, since their effect on the visible
394 * text must land before the characters that expressed them are dropped.
395 * @param text - raw command output.
396 * @returns text whose only remaining escapes are CSI sequences.
397 */
398 function sanitize(text: string): string {
399 const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
400 return applyCursorMovements(escaped).replace(INERT_CONTROL, '')
401 }
402
403 /**
404 * Resolve one run's colors and decorations.
405 * @param chunk - the anser chunk to style.
406 * @returns the run's inline style, or undefined when it carries no SGR state.
407 */
408 function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined {
409 const style: CSSProperties = {}
410 const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})`
411 if (background !== undefined) style.backgroundColor = background
412 if (chunk.fg !== null) {
413 const literal = `rgb(${chunk.fg})`
414 // A run that paints its own background keeps anser's literal pair so the
415 // authored foreground/background contrast survives; a foreground-only run
416 // maps onto a theme token, which adapts to light and dark surfaces.
417 style.color = background === undefined
418 ? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal
419 : literal
420 }
421 for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration])
422 return Object.keys(style).length === 0 ? undefined : style
423 }
424
425 /**
426 * Parse command output into styled spans grouped by line.
427 * @param text - raw output text, which may contain ANSI escape sequences.
428 * @returns one entry per output line (always at least one, possibly empty).
429 */
430 export function parseAnsiLines(text: string): AnsiLine[] {
431 let current: AnsiSpan[] = []
432 const lines: AnsiSpan[][] = [current]
433 for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) {
434 const style = resolveStyle(chunk)
435 for (const [index, part] of chunk.content.split('\n').entries()) {
436 if (index > 0) {
437 current = []
438 lines.push(current)
439 }
440 if (part !== '') current.push({ text: part, style })
441 }
442 }
443 return lines
444 }
445
445 lines TYPESCRIPT