diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts new file mode 100644 index 00000000..80118c36 --- /dev/null +++ b/src/tui/tui-ansi.ts @@ -0,0 +1,473 @@ +/** + * @fileoverview Pure ANSI helpers for the TUI preview pane. + * + * The preview shows the tail of a session's raw terminal stream, which is + * xterm-bound bytes: SGR colors, cursor jumps, OSC titles, DECSET modes and + * carriage-return repaints. This is NOT a terminal emulator. It reconstructs a + * readable, color-preserving tail: SGR survives, everything else that steers a + * cursor is dropped, and a `\r` is honored as "back to column 0" so a spinner + * that repaints its line 200 times contributes one line instead of 200. + * + * Two approximations are deliberate, because the alternative is an emulator: + * a carriage-return overwrite counts CODE POINTS, not display columns (so a + * repaint over CJK text can land one cell off), and tab stops are counted the + * same way. Neither can corrupt output, they only shift a repaint's alignment. + * + * @module tui/tui-ansi + */ + +const ESC = 0x1b; +const BEL = 0x07; +const ST_C1 = 0x9c; +const DEL = 0x7f; + +/** SGR reset, appended by `clipStyledLine` so a clipped line cannot bleed. */ +export const SGR_RESET = '\x1b[0m'; + +const TAB_WIDTH = 8; +/** Cap on remembered SGR sequences per cell, so a pathological stream cannot grow one unboundedly. */ +const MAX_ACTIVE_SGR = 32; + +// ───────────────────────────────────────────────────────────────────────────── +// Escape-sequence scanning +// ───────────────────────────────────────────────────────────────────────────── + +interface EscapeScan { + /** Index just past the sequence; `text.length` for a truncated one. */ + next: number; + /** The sequence itself, only when it is SGR (`CSI ... m`) and therefore kept. */ + sgr?: string; +} + +/** Scan a CSI body starting at `from` (params, then intermediates, then a final byte). */ +function readCsi(text: string, start: number, from: number, keepSgr: boolean): EscapeScan { + let j = from; + while (j < text.length && text.charCodeAt(j) >= 0x30 && text.charCodeAt(j) <= 0x3f) j++; + while (j < text.length && text.charCodeAt(j) >= 0x20 && text.charCodeAt(j) <= 0x2f) j++; + if (j >= text.length) return { next: text.length }; + const next = j + 1; + if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) }; + return { next }; +} + +/** Scan an OSC/DCS/PM/APC body: everything up to BEL, C1 ST or `ESC \`. */ +function readStringSequence(text: string, from: number): number { + let j = from; + while (j < text.length) { + const code = text.charCodeAt(j); + if (code === BEL || code === ST_C1) return j + 1; + if (code === ESC && text[j + 1] === '\\') return j + 2; + j++; + } + return text.length; +} + +/** Scan the escape sequence starting at `i` (which must be an ESC). */ +function readEscape(text: string, i: number): EscapeScan { + const second = text[i + 1]; + if (second === undefined) return { next: text.length }; + if (second === '[') return readCsi(text, i, i + 2, true); + if (second === ']' || second === 'P' || second === 'X' || second === '^' || second === '_') { + return { next: readStringSequence(text, i + 2) }; + } + // Charset / character-set selection: one more byte belongs to the sequence. + if (second === '(' || second === ')' || second === '*' || second === '+' || second === '#' || second === '%') { + return { next: Math.min(text.length, i + 3) }; + } + return { next: i + 2 }; +} + +/** Scan a single-byte C1 control at `i` (0x80-0x9f). */ +function readC1(text: string, i: number): number { + const code = text.charCodeAt(i); + if (code === 0x9b) return readCsi(text, i, i + 1, false).next; + if (code === 0x90 || code === 0x9d || code === 0x9e || code === 0x9f) return readStringSequence(text, i + 1); + return i + 1; +} + +function isC1(code: number): boolean { + return code >= 0x80 && code <= 0x9f; +} + +/** `CSI 0 m`, `CSI m` and `CSI 0;0 m` all mean "back to plain". */ +function isSgrReset(seq: string): boolean { + const params = seq.slice(2, -1); + return params === '' || /^0(?:;0)*$/.test(params); +} + +/** + * Fold one SGR sequence into the active set. Sequences accumulate in arrival + * order (a later color simply wins when replayed), a reset clears them, and a + * repeat moves rather than duplicates. + */ +function applySgr(active: string[], seq: string): string[] { + if (isSgrReset(seq)) return []; + const next = active.filter((s) => s !== seq); + next.push(seq); + return next.length > MAX_ACTIVE_SGR ? next.slice(-MAX_ACTIVE_SGR) : next; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Display width +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Combining marks, variation selectors and other zero-advance code points. + * Pragmatic, not exhaustive: enough that accents and emoji modifiers do not + * inflate a measured width. + */ +const ZERO_WIDTH_RANGES: ReadonlyArray = [ + [0x0300, 0x036f], + [0x0483, 0x0489], + [0x0591, 0x05bd], + [0x05bf, 0x05bf], + [0x0610, 0x061a], + [0x064b, 0x065f], + [0x0670, 0x0670], + [0x06d6, 0x06dc], + [0x0e31, 0x0e31], + [0x0e34, 0x0e3a], + [0x0e47, 0x0e4e], + [0x200b, 0x200f], + [0x2028, 0x202e], + [0x2060, 0x2064], + [0x20d0, 0x20f0], + [0xfe00, 0xfe0f], + [0xfe20, 0xfe2f], + [0xfeff, 0xfeff], +]; + +/** + * East Asian Wide + Fullwidth, plus the standalone code points UAX #11 marks + * Wide because they are emoji-presentation by default. This repo ships a zh-CN + * locale, so CJK correctness is the point; exhaustive Unicode is not required, + * but the scattered BMP entries below are not optional either: `✋` (U+270B) is + * one of them and it is a glyph this TUI draws in every waiting row, so getting + * it wrong mis-pads a column on every frame. + */ +const WIDE_RANGES: ReadonlyArray = [ + [0x1100, 0x115f], + [0x231a, 0x231b], + [0x23e9, 0x23ec], + [0x23f0, 0x23f0], + [0x23f3, 0x23f3], + [0x25fd, 0x25fe], + [0x2614, 0x2615], + [0x2648, 0x2653], + [0x267f, 0x267f], + [0x2693, 0x2693], + [0x26a1, 0x26a1], + [0x26aa, 0x26ab], + [0x26bd, 0x26be], + [0x26c4, 0x26c5], + [0x26ce, 0x26ce], + [0x26d4, 0x26d4], + [0x26ea, 0x26ea], + [0x26f2, 0x26f3], + [0x26f5, 0x26f5], + [0x26fa, 0x26fa], + [0x26fd, 0x26fd], + [0x2705, 0x2705], + [0x270a, 0x270b], + [0x2728, 0x2728], + [0x274c, 0x274c], + [0x274e, 0x274e], + [0x2753, 0x2755], + [0x2757, 0x2757], + [0x2795, 0x2797], + [0x27b0, 0x27b0], + [0x27bf, 0x27bf], + [0x2b1b, 0x2b1c], + [0x2b50, 0x2b50], + [0x2b55, 0x2b55], + [0x2e80, 0x303e], + [0x3041, 0x33ff], + [0x3400, 0x4dbf], + [0x4e00, 0x9fff], + [0xa000, 0xa4cf], + [0xa960, 0xa97f], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe10, 0xfe19], + [0xfe30, 0xfe6f], + [0xff00, 0xff60], + [0xffe0, 0xffe6], + [0x1f004, 0x1f004], + [0x1f0cf, 0x1f0cf], + [0x1f18e, 0x1f18e], + [0x1f191, 0x1f19a], + [0x1f200, 0x1f320], + [0x1f32d, 0x1f335], + [0x1f337, 0x1f37c], + [0x1f37e, 0x1f393], + [0x1f3a0, 0x1f3ca], + [0x1f3cf, 0x1f3d3], + [0x1f3e0, 0x1f3f0], + [0x1f3f4, 0x1f3f4], + [0x1f3f8, 0x1f43e], + [0x1f440, 0x1f440], + [0x1f442, 0x1f4fc], + [0x1f4ff, 0x1f53d], + [0x1f54b, 0x1f54e], + [0x1f550, 0x1f567], + [0x1f57a, 0x1f57a], + [0x1f595, 0x1f596], + [0x1f5a4, 0x1f5a4], + [0x1f5fb, 0x1f64f], + [0x1f680, 0x1f6c5], + [0x1f6cc, 0x1f6cc], + [0x1f6d0, 0x1f6d2], + [0x1f6eb, 0x1f6ec], + [0x1f6f4, 0x1f6fc], + [0x1f7e0, 0x1f7eb], + [0x1f90c, 0x1f93a], + [0x1f93c, 0x1f945], + [0x1f947, 0x1f9ff], + [0x1fa70, 0x1faff], + [0x20000, 0x2fffd], + [0x30000, 0x3fffd], +]; + +function inRanges(cp: number, ranges: ReadonlyArray): boolean { + for (const [lo, hi] of ranges) { + if (cp < lo) return false; + if (cp <= hi) return true; + } + return false; +} + +/** Columns one code point advances the cursor by: 0, 1 or 2. */ +export function charWidth(codePoint: number): number { + if (codePoint < 0x20 || (codePoint >= DEL && codePoint <= 0x9f)) return 0; + if (inRanges(codePoint, ZERO_WIDTH_RANGES)) return 0; + if (inRanges(codePoint, WIDE_RANGES)) return 2; + return 1; +} + +/** Display width of a string: escape sequences take no columns, CJK takes two. */ +export function visibleWidth(text: string): number { + let width = 0; + let i = 0; + while (i < text.length) { + const code = text.charCodeAt(i); + if (code === ESC) { + i = readEscape(text, i).next; + continue; + } + if (isC1(code)) { + i = readC1(text, i); + continue; + } + if (code < 0x20 || code === DEL) { + i++; + continue; + } + const cp = text.codePointAt(i) as number; + i += cp > 0xffff ? 2 : 1; + width += charWidth(cp); + } + return width; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Raw stream to display lines +// ───────────────────────────────────────────────────────────────────────────── + +/** One printed code point (plus any combining marks) and the SGR state under it. */ +interface Cell { + text: string; + sgr: string; +} + +/** + * Replay cells into a string, emitting an SGR change only where the state + * actually changes and closing the line so it is self-contained. + */ +function renderCells(cells: Cell[]): string { + let out = ''; + let active = ''; + for (const cell of cells) { + if (cell.sgr !== active) { + if (active !== '') out += SGR_RESET; + out += cell.sgr; + active = cell.sgr; + } + out += cell.text; + } + if (active !== '') out += SGR_RESET; + return out; +} + +/** + * Turn a raw terminal stream into display lines: SGR preserved, every other + * escape sequence dropped, `\r` treated as a return to column 0 (the following + * text overwrites what is there), tabs expanded, other control characters + * dropped. + * + * Splitting matches `String.split('\n')`, so `''` yields `['']` and a trailing + * newline yields a trailing empty line. + */ +export function toDisplayLines(raw: string): string[] { + const lines: string[] = []; + let cells: Cell[] = []; + let col = 0; + let active: string[] = []; + let sgr = ''; + + const endLine = (): void => { + lines.push(renderCells(cells)); + cells = []; + col = 0; + }; + + const write = (text: string, width: number): void => { + if (width === 0) { + // A combining mark belongs to the character it follows, never to a cell + // of its own: keeping them together is what stops a clip from severing + // an accent from its base letter. + if (col > 0) cells[col - 1].text += text; + return; + } + cells[col] = { text, sgr }; + col++; + }; + + let i = 0; + while (i < raw.length) { + const code = raw.charCodeAt(i); + if (code === ESC) { + const scan = readEscape(raw, i); + if (scan.sgr !== undefined) { + active = applySgr(active, scan.sgr); + sgr = active.join(''); + } + i = scan.next; + continue; + } + if (isC1(code)) { + i = readC1(raw, i); + continue; + } + if (code === 0x0a) { + endLine(); + i++; + continue; + } + if (code === 0x0d) { + col = 0; + i++; + continue; + } + if (code === 0x09) { + const stop = TAB_WIDTH - (col % TAB_WIDTH); + for (let n = 0; n < stop; n++) write(' ', 1); + i++; + continue; + } + if (code < 0x20 || code === DEL) { + i++; + continue; + } + const cp = raw.codePointAt(i) as number; + const text = String.fromCodePoint(cp); + i += text.length; + write(text, charWidth(cp)); + } + endLine(); + return lines; +} + +/** + * Drop every escape sequence, keeping the visible text. Needed because the + * preview carries the session's OWN colors: under NO_COLOR the frame must not + * smuggle them back in. + */ +export function stripStyles(text: string): string { + let out = ''; + let i = 0; + while (i < text.length) { + const code = text.charCodeAt(i); + if (code === ESC) { + i = readEscape(text, i).next; + continue; + } + if (isC1(code)) { + i = readC1(text, i); + continue; + } + if (code < 0x20 || code === DEL) { + i++; + continue; + } + const cp = text.codePointAt(i) as number; + const size = cp > 0xffff ? 2 : 1; + out += text.slice(i, i + size); + i += size; + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Clipping and padding +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Clip a line that carries SGR to `width` display columns, keeping the styling + * that is active up to the clip point and closing it with a reset. Never splits + * a code point, a combining sequence or an escape sequence, and never emits + * half of a double-width character (the cell is dropped instead). + */ +export function clipStyledLine(line: string, width: number): string { + if (width <= 0) return ''; + let out = ''; + let used = 0; + let active: string[] = []; + // Styles are emitted lazily, right before the character that wears them, so a + // sequence sitting exactly on the clip boundary is not carried into a line it + // no longer styles. + let emitted = ''; + let i = 0; + while (i < line.length) { + const code = line.charCodeAt(i); + if (code === ESC) { + const scan = readEscape(line, i); + if (scan.sgr !== undefined) active = applySgr(active, scan.sgr); + i = scan.next; + continue; + } + if (isC1(code)) { + i = readC1(line, i); + continue; + } + if (code < 0x20 || code === DEL) { + i++; + continue; + } + const cp = line.codePointAt(i) as number; + const w = charWidth(cp); + if (used + w > width) break; + const style = active.join(''); + if (style !== emitted) { + if (emitted !== '') out += SGR_RESET; + out += style; + emitted = style; + } + out += String.fromCodePoint(cp); + used += w; + i += cp > 0xffff ? 2 : 1; + } + return emitted !== '' ? out + SGR_RESET : out; +} + +/** + * Pad or clip to exactly `width` display columns. A clip that lands on a + * double-width boundary leaves one column short, so the pad runs after it. + */ +export function padDisplay(text: string, width: number): string { + if (width <= 0) return ''; + const w = visibleWidth(text); + if (w === width) return text; + if (w < width) return text + ' '.repeat(width - w); + const clipped = clipStyledLine(text, width); + return clipped + ' '.repeat(Math.max(0, width - visibleWidth(clipped))); +} diff --git a/test/tui/tui-ansi.test.ts b/test/tui/tui-ansi.test.ts new file mode 100644 index 00000000..0dc3b83c --- /dev/null +++ b/test/tui/tui-ansi.test.ts @@ -0,0 +1,174 @@ +/** + * @fileoverview Unit tests for the TUI's SGR-aware preview helpers + * (toDisplayLines / clipStyledLine / visibleWidth / padDisplay / stripStyles). + * + * The invariants under test are the ones a preview pane fails visibly on: color + * survives, cursor steering does not, a carriage-return repaint collapses to + * one line, and no clip ever cuts a code point, a wide character or an escape + * sequence in half. + */ +import { describe, it, expect } from 'vitest'; +import { + clipStyledLine, + padDisplay, + stripStyles, + toDisplayLines, + visibleWidth, + charWidth, +} from '../../src/tui/tui-ansi.js'; + +const RED = '\x1b[31m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +describe('toDisplayLines', () => { + it('splits like String.split, trailing newline included', () => { + expect(toDisplayLines('a\nb')).toEqual(['a', 'b']); + expect(toDisplayLines('a\n')).toEqual(['a', '']); + expect(toDisplayLines('')).toEqual(['']); + }); + + it('preserves SGR and closes an open style at end of line', () => { + expect(toDisplayLines(`${RED}red${RESET} done`)).toEqual([`${RED}red${RESET} done`]); + expect(toDisplayLines(`${BOLD}bold`)).toEqual([`${BOLD}bold${RESET}`]); + }); + + it('accumulates SGR state across a line', () => { + expect(toDisplayLines(`${BOLD}a${RED}b`)).toEqual([`${BOLD}a${RESET}${BOLD}${RED}b${RESET}`]); + }); + + it('strips OSC sequences (BEL and ST terminated)', () => { + expect(toDisplayLines('\x1b]0;window title\x07text')).toEqual(['text']); + expect(toDisplayLines('\x1b]0;window title\x1b\\text')).toEqual(['text']); + }); + + it('strips DECSET/DECRST, cursor movement and charset selection', () => { + expect(toDisplayLines('\x1b[?25lvisible\x1b[?25h')).toEqual(['visible']); + expect(toDisplayLines('a\x1b[5Cb')).toEqual(['ab']); + expect(toDisplayLines('\x1b[2J\x1b[H\x1b[1;1Hhome')).toEqual(['home']); + expect(toDisplayLines('\x1b(0lqk\x1b(B')).toEqual(['lqk']); + expect(toDisplayLines('\x1b=app\x1b>')).toEqual(['app']); + }); + + it('strips C1 controls and their sequences', () => { + expect(toDisplayLines('a\x9b31mb')).toEqual(['ab']); + expect(toDisplayLines('a\x9d0;title\x9cb')).toEqual(['ab']); + }); + + it('drops control characters but keeps tabs as spaces', () => { + expect(toDisplayLines('a\x07b\x00c')).toEqual(['abc']); + expect(toDisplayLines('a\tb')).toEqual(['a b']); + expect(toDisplayLines('\tx')).toEqual([' x']); + }); + + it('treats a bare \\r as a return to column zero (spinner repaint)', () => { + expect(toDisplayLines('abcdef\rXY')).toEqual(['XYcdef']); + expect(toDisplayLines('long line here\rshort')).toEqual(['shortline here']); + expect(toDisplayLines('\rWorking 1%\rWorking 99%')).toEqual(['Working 99%']); + }); + + it('keeps \\r\\n as a plain newline', () => { + expect(toDisplayLines('a\r\nb')).toEqual(['a', 'b']); + }); + + it('carries the overwriting text style, not the overwritten one', () => { + expect(toDisplayLines(`${RED}aaa\r${RESET}b`)).toEqual([`b${RED}aa${RESET}`]); + }); + + it('keeps whole code points and attaches combining marks to their base', () => { + expect(toDisplayLines('a\u{1f600}b')).toEqual(['a\u{1f600}b']); + expect(toDisplayLines('éx')).toEqual(['éx']); + }); + + it('does not throw on truncated or malformed escapes', () => { + expect(toDisplayLines('abc\x1b')).toEqual(['abc']); + expect(toDisplayLines('abc\x1b[')).toEqual(['abc']); + expect(toDisplayLines('abc\x1b[31')).toEqual(['abc']); + expect(toDisplayLines('\x1b]0;no terminator')).toEqual(['']); + expect(() => toDisplayLines('\x1b\x1b\x1b[[[m')).not.toThrow(); + }); +}); + +describe('visibleWidth', () => { + it('ignores escape sequences', () => { + expect(visibleWidth(`${RED}abc${RESET}`)).toBe(3); + expect(visibleWidth('\x1b]0;title\x07abc')).toBe(3); + }); + + it('counts East Asian wide characters as two columns', () => { + expect(visibleWidth('中文')).toBe(4); + expect(visibleWidth('a中b')).toBe(4); + expect(visibleWidth('full')).toBe(8); + expect(visibleWidth('\u{1f600}')).toBe(2); + }); + + it('counts combining marks and zero-width joiners as nothing', () => { + expect(visibleWidth('é')).toBe(1); + expect(visibleWidth('a‍b')).toBe(2); + expect(visibleWidth('')).toBe(0); + }); + + it('agrees with charWidth on the boundaries', () => { + expect(charWidth(0x41)).toBe(1); + expect(charWidth(0x4e00)).toBe(2); + expect(charWidth(0x0301)).toBe(0); + expect(charWidth(0x07)).toBe(0); + }); +}); + +describe('clipStyledLine', () => { + it('clips plain text by display width', () => { + expect(clipStyledLine('abcdef', 3)).toBe('abc'); + expect(clipStyledLine('abc', 10)).toBe('abc'); + expect(clipStyledLine('abc', 0)).toBe(''); + expect(clipStyledLine('abc', -4)).toBe(''); + }); + + it('keeps the SGR state active at the clip point and closes it', () => { + expect(clipStyledLine(`${RED}abcdef${RESET}`, 3)).toBe(`${RED}abc${RESET}`); + expect(clipStyledLine(`${BOLD}${RED}abcdef`, 2)).toBe(`${BOLD}${RED}ab${RESET}`); + }); + + it('adds no reset when the kept part already reset', () => { + expect(clipStyledLine(`${RED}ab${RESET}cdef`, 4)).toBe(`${RED}ab${RESET}cd`); + }); + + it('never emits half of a double-width character', () => { + expect(clipStyledLine('中文abc', 3)).toBe('中'); + expect(clipStyledLine('中文', 4)).toBe('中文'); + expect(visibleWidth(clipStyledLine('中文abc', 3))).toBe(2); + }); + + it('never splits a surrogate pair or a combining sequence', () => { + expect(clipStyledLine('\u{1f600}x', 2)).toBe('\u{1f600}'); + expect(clipStyledLine('\u{1f600}x', 1)).toBe(''); + expect(clipStyledLine('éx', 1)).toBe('é'); + }); + + it('drops escape sequences that sit past the clip point', () => { + expect(clipStyledLine(`ab${RED}cd`, 2)).toBe('ab'); + }); +}); + +describe('padDisplay', () => { + it('pads short text and clips long text', () => { + expect(padDisplay('ab', 5)).toBe('ab '); + expect(padDisplay('abcdef', 3)).toBe('abc'); + expect(padDisplay('abc', 3)).toBe('abc'); + expect(padDisplay('abc', 0)).toBe(''); + }); + + it('pads to the exact display width around a wide-character boundary', () => { + expect(visibleWidth(padDisplay('中文', 3))).toBe(3); + expect(padDisplay('中文', 3)).toBe('中 '); + expect(visibleWidth(padDisplay(`${RED}中${RESET}x`, 6))).toBe(6); + }); +}); + +describe('stripStyles', () => { + it('removes every escape sequence and control character', () => { + expect(stripStyles(`${RED}red${RESET}`)).toBe('red'); + expect(stripStyles('\x1b]0;t\x07a\x1b[?25lb')).toBe('ab'); + expect(stripStyles('a\u{1f600}中')).toBe('a\u{1f600}中'); + }); +});