Files
Codeman/test/tui/tui-ansi.test.ts
T
Codeman maintainer bb24d2c256 fix(tui): stop the preview stacking every repaint of a session
The overview showed the same session twice, one frame above another, after
switching sessions (reported from the beta with a screenshot).

Claude repaints by ABSOLUTE CURSOR POSITIONING, not by clearing: a 198KB pane
tail carries 1142 `CSI r;c H` and exactly one `CSI 2J`. The replay honoured the
COLUMN of those sequences and ignored the ROW, so a repaint could never
overwrite what came before and was appended instead. That same tail replayed as
FIFTY stacked copies of one frame. The preview shows the last N lines, so on a
short terminal you saw the newest frame by luck and on a tall one you saw the
end of the previous frame above it.

A cursor HOME now starts the buffer over. That is not a heuristic but the
line-based equivalent of what a home means: a full-screen app announcing it is
repainting from the top, with everything on screen about to be overwritten in
place. Only row 1 column 1 counts — any other address is a write position
inside the frame being painted, and resetting on those would erase live
content.

Measured on the real tail that produced the screenshot: 198599 bytes and 50
copies of the welcome frame collapse to 40 lines carrying exactly one.

The old test pinned the append behaviour, including a spurious leading empty
line that the initial CUP produced; both are gone.
2026-08-22 14:13:58 +02:00

251 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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,
dropSeveredEscape,
padDisplay,
stripStyles,
toDisplayLines,
visibleWidth,
charWidth,
foldPreviewGlyphs,
} 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, relative cursor movement and charset selection', () => {
expect(toDisplayLines('\x1b[?25lvisible\x1b[?25h')).toEqual(['visible']);
expect(toDisplayLines('a\x1b[5Cb')).toEqual(['ab']);
expect(toDisplayLines('\x1b(0lqk\x1b(B')).toEqual(['lqk']);
expect(toDisplayLines('\x1b=app\x1b>')).toEqual(['app']);
});
it('splits a row-addressed repaint into lines, which is how an Ink TUI paints', () => {
// Claude Code emits almost no newlines: without this the whole screen is
// one line and nothing in the preview is readable.
expect(toDisplayLines('\x1b[1;1Hfirst\x1b[2;1Hsecond\x1b[3;1Hthird')).toEqual(['first', 'second', 'third']);
// A jump inside a row is a write position, not a new line.
expect(toDisplayLines('\x1b[1;1Hab\x1b[1;5Hcd')).toEqual(['ab cd']);
expect(toDisplayLines('\x1b[1;1Habcdef\x1b[1;2HXY')).toEqual(['aXYdef']);
expect(toDisplayLines('a\x1b[3;1fb')).toEqual(['a', 'b']);
});
it('starts a new frame at a cursor HOME, instead of stacking repaints', () => {
// ⚠️ A home is a full-screen app announcing a repaint from the top, and
// everything on screen is about to be overwritten in place. This replay is
// line-based and cannot overwrite, so starting over is the faithful
// equivalent. Without it every repaint was APPENDED: a real claude pane's
// 198KB tail replayed as FIFTY stacked copies of the same frame, and the
// preview showed the last N lines, which on a tall terminal spanned two of
// them — the overview appeared to show the session twice.
expect(toDisplayLines('old frame\x1b[1;1Hnew frame')).toEqual(['new frame']);
// Both parameters default to 1, so a bare CUP is a home too.
expect(toDisplayLines('a\x1b[Hb')).toEqual(['b']);
// Only row 1 column 1. Any other address is a write position within the
// frame being painted, and resetting on those would erase live content.
expect(toDisplayLines('keep\x1b[2;1Hnext')).toEqual(['keep', 'next']);
expect(toDisplayLines('keep\x1b[1;3Hxx')).toEqual(['kexx']);
});
it('refuses to allocate a line for a column no terminal has', () => {
const lines = toDisplayLines('\x1b[1;99999Hx');
expect(lines).toHaveLength(1);
expect(visibleWidth(lines[0])).toBeLessThanOrEqual(1001);
});
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('dropSeveredEscape', () => {
it('drops the remains of a sequence a byte-sliced tail starts inside', () => {
expect(dropSeveredEscape(';1Hstill here')).toBe('still here');
expect(dropSeveredEscape('12;3Htext')).toBe('text');
expect(dropSeveredEscape('31mred')).toBe('red');
});
it('leaves ordinary text alone', () => {
expect(dropSeveredEscape('hello world')).toBe('hello world');
expect(dropSeveredEscape('\x1b[31mred')).toBe('\x1b[31mred');
expect(dropSeveredEscape('')).toBe('');
});
});
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}中');
});
});
describe('foldPreviewGlyphs', () => {
it("turns claude's prompt and mode markers into the arrows they look like", () => {
// Exactly what a beta tester photographed as empty boxes.
expect(foldPreviewGlyphs('\u276F Try "how does report_agent.py work?"')).toBe(
'> Try "how does report_agent.py work?"'
);
expect(foldPreviewGlyphs(' \u23F5\u23F5 bypass permissions on')).toBe(' >> bypass permissions on');
});
it('leaves the glyphs that plain fonts DO render', () => {
// The same terminal drew all of these correctly, so folding them would be a
// downgrade for everyone to fix a problem nobody has.
const kept = '\u00B7 \u2500 \u2502 \u25B6 \u25CB \u2714 \u2192';
expect(foldPreviewGlyphs(kept)).toBe(kept);
});
it('leaves ordinary text and box drawing exactly alone', () => {
const line = ' 1 tui-demo-shell shell \u25CB 4m\u2502';
expect(foldPreviewGlyphs(line)).toBe(line);
expect(foldPreviewGlyphs('')).toBe('');
});
it('preserves length, so preview column arithmetic is unaffected', () => {
const line = '\u276F hello \u23F5\u23F5 world';
expect(foldPreviewGlyphs(line)).toHaveLength([...line].length);
});
});