From bb24d2c25661a79331a3ba4379cc138a58806fc9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 11:16:47 +0200 Subject: [PATCH] fix(tui): stop the preview stacking every repaint of a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tui/tui-ansi.ts | 27 ++++++++++++++++++++++----- test/tui/tui-ansi.test.ts | 25 ++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts index aee0abe2..58654919 100644 --- a/src/tui/tui-ansi.ts +++ b/src/tui/tui-ansi.ts @@ -51,13 +51,18 @@ interface EscapeScan { sgr?: string; /** 1-based column of a cursor-position sequence (`CSI r ; c H` or `f`). */ column?: number; + /** 1-based row of that same sequence. Row 1 means a repaint is starting. */ + row?: number; } -/** The column a `CSI r ; c H` addresses. Both parameters default to 1. */ -function cursorColumn(params: string): number { +/** The row and column a `CSI r ; c H` addresses. Both parameters default to 1. */ +function cursorPosition(params: string): { row: number; column: number } { const parts = params.split(';'); - const column = Number.parseInt(parts[1] ?? '', 10); - return Number.isSafeInteger(column) && column > 0 ? column : 1; + const read = (index: number): number => { + const value = Number.parseInt(parts[index] ?? '', 10); + return Number.isSafeInteger(value) && value > 0 ? value : 1; + }; + return { row: read(0), column: read(1) }; } /** Scan a CSI body starting at `from` (params, then intermediates, then a final byte). */ @@ -69,7 +74,7 @@ function readCsi(text: string, start: number, from: number, keepSgr: boolean): E const next = j + 1; if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) }; if (keepSgr && (text[j] === 'H' || text[j] === 'f')) { - return { next, column: cursorColumn(text.slice(from, j)) }; + return { next, ...cursorPosition(text.slice(from, j)) }; } return { next }; } @@ -423,6 +428,18 @@ export function toDisplayLines(raw: string): string[] { if (scan.sgr !== undefined) { active = applySgr(active, scan.sgr); sgr = active.join(''); + } else if (scan.row === 1 && scan.column === 1) { + // ⚠️ A HOME is a full-screen app announcing that it is repainting from + // the top, and everything already on screen is about to be overwritten + // in place. This replay is line-based and cannot overwrite, so the + // faithful equivalent is to start over — without it every repaint was + // APPENDED, and a claude pane's tail carried fifty stacked copies of + // the same frame. The preview then showed the last N lines, which on a + // tall terminal spanned two of them (reported from the beta as the + // overview showing the session twice). + lines.length = 0; + cells = []; + col = 0; } else if (scan.column !== undefined) { // Column 1 is a fresh row, which is the only thing a repainting TUI // gives us to split lines on. diff --git a/test/tui/tui-ansi.test.ts b/test/tui/tui-ansi.test.ts index a06dfef0..92d61ac9 100644 --- a/test/tui/tui-ansi.test.ts +++ b/test/tui/tui-ansi.test.ts @@ -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);