/** * @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); }); });