mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
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:
+22
-5
@@ -51,13 +51,18 @@ interface EscapeScan {
|
|||||||
sgr?: string;
|
sgr?: string;
|
||||||
/** 1-based column of a cursor-position sequence (`CSI r ; c H` or `f`). */
|
/** 1-based column of a cursor-position sequence (`CSI r ; c H` or `f`). */
|
||||||
column?: number;
|
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. */
|
/** The row and column a `CSI r ; c H` addresses. Both parameters default to 1. */
|
||||||
function cursorColumn(params: string): number {
|
function cursorPosition(params: string): { row: number; column: number } {
|
||||||
const parts = params.split(';');
|
const parts = params.split(';');
|
||||||
const column = Number.parseInt(parts[1] ?? '', 10);
|
const read = (index: number): number => {
|
||||||
return Number.isSafeInteger(column) && column > 0 ? column : 1;
|
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). */
|
/** 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;
|
const next = j + 1;
|
||||||
if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) };
|
if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) };
|
||||||
if (keepSgr && (text[j] === 'H' || text[j] === 'f')) {
|
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 };
|
return { next };
|
||||||
}
|
}
|
||||||
@@ -423,6 +428,18 @@ export function toDisplayLines(raw: string): string[] {
|
|||||||
if (scan.sgr !== undefined) {
|
if (scan.sgr !== undefined) {
|
||||||
active = applySgr(active, scan.sgr);
|
active = applySgr(active, scan.sgr);
|
||||||
sgr = active.join('');
|
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) {
|
} else if (scan.column !== undefined) {
|
||||||
// Column 1 is a fresh row, which is the only thing a repainting TUI
|
// Column 1 is a fresh row, which is the only thing a repainting TUI
|
||||||
// gives us to split lines on.
|
// gives us to split lines on.
|
||||||
|
|||||||
@@ -54,15 +54,30 @@ describe('toDisplayLines', () => {
|
|||||||
it('splits a row-addressed repaint into lines, which is how an Ink TUI paints', () => {
|
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
|
// Claude Code emits almost no newlines: without this the whole screen is
|
||||||
// one line and nothing in the preview is readable.
|
// 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.
|
// 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;1Hab\x1b[1;5Hcd')).toEqual(['ab cd']);
|
||||||
expect(toDisplayLines('\x1b[1;1Habcdef\x1b[1;2HXY')).toEqual(['', 'aXYdef']);
|
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('a\x1b[3;1fb')).toEqual(['a', 'b']);
|
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', () => {
|
it('refuses to allocate a line for a column no terminal has', () => {
|
||||||
const lines = toDisplayLines('\x1b[1;99999Hx');
|
const lines = toDisplayLines('\x1b[1;99999Hx');
|
||||||
expect(lines).toHaveLength(1);
|
expect(lines).toHaveLength(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user