| 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 |