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

@ -443,6 +443,14 @@ interface MyTheme {
}
```
## Debug logging
Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
```bash
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts
```
## Performance
Cache rendered output when possible:

View file

@ -2,8 +2,14 @@
## [Unreleased]
### Added
- Added `fullRedraws` readonly property to TUI class for tracking full screen redraws
- Added `PI_TUI_WRITE_LOG` environment variable to capture raw ANSI output for debugging
### Fixed
- Fixed appended lines not being committed to scrollback, causing earlier content to be overwritten when viewport fills ([#954](https://github.com/badlogic/pi-mono/issues/954))
- Slash command menu now only triggers when the editor input is otherwise empty ([#904](https://github.com/badlogic/pi-mono/issues/904))
- Center-anchored overlays now stay vertically centered when resizing the terminal taller after a shrink ([#950](https://github.com/badlogic/pi-mono/pull/950) by [@nicobailon](https://github.com/nicobailon))
- Fixed editor multi-line insertion handling and lastAction tracking ([#945](https://github.com/badlogic/pi-mono/pull/945) by [@Perlence](https://github.com/Perlence))

View file

@ -746,3 +746,11 @@ npm run check
# Run the demo
npx tsx test/chat-simple.ts
```
### Debug logging
Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
```bash
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts
```

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)}`,

View file

@ -0,0 +1,106 @@
/**
* TUI viewport overwrite repro
*
* Place this file at: packages/tui/test/viewport-overwrite-repro.ts
* Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts
*
* For reliable repro, run in a small terminal (8-12 rows) or a tmux session:
* tmux new-session -d -s tui-bug -x 80 -y 12
* tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter
* tmux attach -t tui-bug
*
* Expected behavior:
* - PRE-TOOL lines remain visible above tool output.
* - POST-TOOL lines append after tool output without overwriting earlier content.
*
* Actual behavior (bug):
* - When content exceeds the viewport and new lines arrive after a tool-call pause,
* some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines.
*/
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
class Lines implements Component {
private lines: string[] = [];
set(lines: string[]): void {
this.lines = lines;
}
append(lines: string[]): void {
this.lines.push(...lines);
}
render(width: number): string[] {
return this.lines.map((line) => {
if (line.length > width) return line.slice(0, width);
return line.padEnd(width, " ");
});
}
invalidate(): void {}
}
async function streamLines(buffer: Lines, label: string, count: number, delayMs: number, ui: TUI): Promise<void> {
for (let i = 1; i <= count; i += 1) {
buffer.append([`${label} ${String(i).padStart(2, "0")}`]);
ui.requestRender();
await sleep(delayMs);
}
}
async function main(): Promise<void> {
const ui = new TUI(new ProcessTerminal());
const buffer = new Lines();
ui.addChild(buffer);
ui.start();
const height = ui.terminal.rows;
const preCount = height + 8; // Ensure content exceeds viewport
const toolCount = height + 12; // Tool output pushes further into scrollback
const postCount = 6;
buffer.set([
"TUI viewport overwrite repro",
`Viewport rows detected: ${height}`,
"(Resize to ~8-12 rows for best repro)",
"",
"=== PRE-TOOL STREAM ===",
]);
ui.requestRender();
await sleep(300);
// Phase 1: Stream pre-tool text until viewport is exceeded.
await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui);
// Phase 2: Simulate tool call pause and tool output.
buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]);
ui.requestRender();
await sleep(700);
await streamLines(buffer, "TOOL OUT", toolCount, 20, ui);
// Phase 3: Post-tool streaming. This is where overwrite often appears.
buffer.append(["", "=== POST-TOOL STREAM ==="]);
ui.requestRender();
await sleep(300);
await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui);
// Leave the output visible briefly, then restore terminal state.
await sleep(1500);
ui.stop();
}
main().catch((error) => {
// Ensure terminal is restored if something goes wrong.
try {
const ui = new TUI(new ProcessTerminal());
ui.stop();
} catch {
// Ignore restore errors.
}
process.stderr.write(`${String(error)}\n`);
process.exitCode = 1;
});