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.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent bc1821661f
commit bb24d2c256
2 changed files with 42 additions and 10 deletions
+20 -5
View File
@@ -54,15 +54,30 @@ describe('toDisplayLines', () => {
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']);
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']);
// Both parameters default to 1, so a bare CUP is a fresh row.
expect(toDisplayLines('a\x1b[Hb')).toEqual(['a', 'b']);
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);