feat: add the TUI's SGR-aware preview helpers

The preview pane shows a session's raw terminal stream, so it needs the tail
reconstructed rather than emulated: SGR survives, cursor steering and OSC do
not, and a carriage return returns to column 0 so a spinner that repaints its
line 200 times contributes one line instead of 200.

Widths count East Asian Wide characters as two columns, which the clip and pad
helpers rely on to never cut a wide character, a code point or an escape
sequence in half.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:57 +02:00
parent 596c08d20c
commit 64cf8384f2
2 changed files with 647 additions and 0 deletions
+473
View File
@@ -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<readonly [number, number]> = [
[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<readonly [number, number]> = [
[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<readonly [number, number]>): 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)));
}
+174
View File
@@ -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}中');
});
});