fix(tui): fix scrollback overwrite when appending lines past viewport

Appended lines were not committed to terminal scrollback because the
renderer used cursor movement (CSI B) and carriage return without
linefeed. This caused earlier content to be overwritten when the
viewport filled up.

Changes:
- For appended lines, emit \r\n to create real scrollback lines
- When target row is below viewport, scroll with \r\n before positioning
- Add PI_TUI_WRITE_LOG env var for debugging raw ANSI output
- Add fullRedraws readonly property to TUI class
- Add viewport-overwrite-repro.ts test script

fixes #954
This commit is contained in:
Mario Zechner 2026-01-26 16:51:28 +01:00
parent fa8b26a184
commit a6f9c3cf0d
6 changed files with 165 additions and 6 deletions

View file

@ -1,3 +1,4 @@
import * as fs from "node:fs";
import { setKittyProtocolActive } from "./keys.js";
import { StdinBuffer } from "./stdin-buffer.js";
@ -47,6 +48,7 @@ export class ProcessTerminal implements Terminal {
private _kittyProtocolActive = false;
private stdinBuffer?: StdinBuffer;
private stdinDataHandler?: (data: string) => void;
private writeLogPath = process.env.PI_TUI_WRITE_LOG || "";
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
@ -184,6 +186,13 @@ export class ProcessTerminal implements Terminal {
write(data: string): void {
process.stdout.write(data);
if (this.writeLogPath) {
try {
fs.appendFileSync(this.writeLogPath, data, { encoding: "utf8" });
} catch {
// Ignore logging errors
}
}
}
get columns(): number {

View file

@ -211,6 +211,7 @@ export class TUI extends Container {
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
private fullRedrawCount = 0;
// Overlay stack for modal components rendered on top of base content
private overlayStack: {
@ -228,6 +229,10 @@ export class TUI extends Container {
}
}
get fullRedraws(): number {
return this.fullRedrawCount;
}
getShowHardwareCursor(): boolean {
return this.showHardwareCursor;
}
@ -787,10 +792,11 @@ export class TUI extends Container {
private doRender(): void {
const width = this.terminal.columns;
const height = this.terminal.rows;
const viewportTop = Math.max(0, this.maxLinesRendered - height);
const prevViewportTop = this.previousViewportTop;
let viewportTop = Math.max(0, this.maxLinesRendered - height);
let prevViewportTop = this.previousViewportTop;
let hardwareCursorRow = this.hardwareCursorRow;
const computeLineDiff = (targetRow: number): number => {
const currentScreenRow = this.hardwareCursorRow - prevViewportTop;
const currentScreenRow = hardwareCursorRow - prevViewportTop;
const targetScreenRow = targetRow - viewportTop;
return targetScreenRow - currentScreenRow;
};
@ -813,6 +819,7 @@ export class TUI extends Container {
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
for (let i = 0; i < newLines.length; i++) {
@ -869,6 +876,7 @@ export class TUI extends Container {
}
lastChanged = newLines.length - 1;
}
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
@ -926,16 +934,30 @@ export class TUI extends Container {
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
const moveToBottom = height - 1 - currentScreenRow;
if (moveToBottom > 0) {
buffer += `\x1b[${moveToBottom}B`;
}
const scroll = moveTargetRow - prevViewportBottom;
buffer += "\r\n".repeat(scroll);
prevViewportTop += scroll;
viewportTop += scroll;
hardwareCursorRow = moveTargetRow;
}
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = computeLineDiff(firstChanged);
const lineDiff = computeLineDiff(moveTargetRow);
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
buffer += `\x1b[${-lineDiff}A`; // Move up
}
buffer += "\r"; // Move to column 0
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
// Only render changed lines (firstChanged to lastChanged), not all lines to end
// This reduces flicker when only a single line changes (e.g., spinner animation)
@ -1007,7 +1029,7 @@ export class TUI extends Container {
`cursorRow: ${this.cursorRow}`,
`height: ${height}`,
`lineDiff: ${lineDiff}`,
`hardwareCursorRow: ${this.hardwareCursorRow}`,
`hardwareCursorRow: ${hardwareCursorRow}`,
`renderEnd: ${renderEnd}`,
`finalCursorRow: ${finalCursorRow}`,
`cursorPos: ${JSON.stringify(cursorPos)}`,