Skip to main content

ansi

import { parseAnsi, stripAnsi, parseCast } from '@react-x11/components/ansi';

A captured terminal session, reduced to a document: the parser under <TerminalOutput>, and useful on its own.

A shared module, not a component. Nothing here registers an element, renders anything, or does any work at import time; there is no React in it, no optional dependency, and no @xterm/headless. It has a subpath of its own because "turn a log into styled text" is a thing to want without a terminal anywhere near it — colouring a build log inside a <Markdown> document, say, or stripping escapes before a diff.

The PRD is the design record.

The document

const doc = parseAnsi(await readFile('build.log'));

doc.lines; // AnsiLine[] — { spans, text }
doc.title; // the OSC 0/2 title, if the capture set one
doc.needsScreen; // did these bytes want a cell grid?
doc.dropped; // { CUP: 3, 'alt-screen': 1 } — what went unhonoured
doc.truncated; // lines evicted by maxLines
doc.state; // the resume point

A AnsiLine is { spans, text }, and text is always the spans concatenated — so doc.lines.map((l) => l.text).join('\n') is the capture as plain text, which is what stripAnsi returns.

Spans keep intent, not pixels

interface AnsiSpan {
text: string;
fg?: AnsiColor;
bg?: AnsiColor;
bold?;
dim?;
italic?;
blink?;
inverse?;
conceal?;
strike?;
overline?: boolean;
underline?: 'single' | 'double' | 'curly' | 'dotted' | 'dashed';
underlineColor?: AnsiColor;
href?: string; // OSC 8
}

type AnsiColor =
| { kind: 'ansi'; index: number } // 0..255
| { kind: 'rgb'; value: number }; // 0xRRGGBB

A field that is absent is the terminal's default — there is deliberately no null, because "no background" and "the default background" are the same statement and a renderer that has to tell them apart has a bug waiting in it.

A span says { kind: 'ansi', index: 2 }, never #00cd00. Which pixels that is belongs to a palette, and keeping the two apart is what lets one parsed capture render correctly against a light theme and a dark one. It is the same split <Code> makes between codeRuns (tokens) and codeBlockLook (the palette).

Turning intent into pixels

import { ansiPalette, resolveAnsiColors } from '@react-x11/components/ansi';

const palette = ansiPalette({ foreground: '#222', background: '#fff' });
resolveAnsiColors({ fg: { kind: 'ansi', index: 1 } }, palette);
// → { fg: '#cd0000' } — no `bg`: the surface keeps its own

ansiPalette builds the 256 entries (the 16 from palette where it has one and the standard set where it does not, then the 6×6×6 cube and the 24 greys, both fixed by the protocol). resolveAnsiColors applies the transforms in the order every terminal agrees on — bright-bold, dim, inverse, conceal — which is the same order <Terminal backend="vt"> applies to a live cell, so a capture and the program that produced it look the same in one window.

bg is absent when nothing claimed it. That is what lets a log sit inside a page instead of becoming an opaque strip.

Appending

parseAnsi(chunk, { from: previous }) continues a parse rather than starting one, so a growing log costs the tail rather than the capture. A partial escape sequence — or a UTF-8 character split by a chunk boundary — is held, not mangled; that is the same rule PtyHost.onData carries for the live terminal and <Markdown> follows for an unterminated tail.

let doc = parseAnsi(first);
doc = parseAnsi(second, { from: doc });

Earlier snapshots stay valid: completed lines are never mutated.

asciinema recordings

import { parseCast, castOutput, parseAnsi } from '@react-x11/components/ansi';

const cast = parseCast(await readFile('session.cast', 'utf8'));
cast.header; // { version, width, height, title?, timestamp?, env? }
cast.events; // [{ time, kind, data }] — absolute times, v1's deltas included

parseAnsi(castOutput(cast, { until: 12.5 })); // the session at 12.5s

Both published versions are read. A malformed event line is skipped — a recording still being written has a partial last line — while a missing header throws CastFormatError, because without one there is no recording.

castOutput takes only 'o' events: input, resize and marker events have no pixels.

API

ExportWhat it is
parseAnsi(data, options?)Bytes → AnsiDocument. options: from, tabWidth, maxLines.
stripAnsi(data, options?)The capture's text, escapes resolved away.
AnsiStateThe reducer state. doc.state is one; construct one to drive it.
ansiPalette(options?)The 256-colour palette from foreground/background/palette.
resolveAnsiColors(attrs, palette)A span's two colours, after every transform that can change them.
ansiColor(i) / rgbColor(r, g, b)Colour constructors. Indexed colours are interned.
parseCssColor / cssColor / mixRgbThe colour arithmetic, exported because a caller building its own palette needs the same three.
parseCast(text) / castOutput(cast, o?)asciinema v1 and v2.
Style / PLAIN / param / ABSENTThe SGR reducer's own parts, for a caller driving the machine itself.
Utf8DecoderA decoder that survives a chunk boundary.
ANSI_16The standard sixteen, as xterm defines them.

What the model can and cannot say

Honoured exactly: SGR in full (the 16, the 256 cube, truecolor, and the : sub-parameter forms — so 4:3 curly underlines and 58 underline colours work, which is the pair @xterm/headless does not expose); \r, \n, \b, \t; \e[K/1K/2K; \e[nC/nD/nG inside a line; \e[nX/nP/n@; OSC 8 hyperlinks; OSC 0/2 titles.

Dropped and counted: cursor addressing, ED, IL/DL, scroll regions, the alternate screen, DEC private modes, device queries, OSC 52, and Sixel / Kitty / iTerm2 image envelopes. Every one of them is recognised and consumed even where it is discarded — a parser that does not understand a sequence's structure prints its payload as text, which is the classic garbage-on-screen bug.

Two model limits worth knowing: East Asian wide characters take one column rather than two, which only matters when a capture rewinds past CJK text with \r or \e[nG and never affects the text itself; and a combining mark joins the cell before it rather than taking one of its own, so an accent does not shift every column after it.