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 ## Performance
Cache rendered output when possible: Cache rendered output when possible:

View file

@ -2,8 +2,14 @@
## [Unreleased] ## [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
- 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)) - 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)) - 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)) - 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 # Run the demo
npx tsx test/chat-simple.ts 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 { setKittyProtocolActive } from "./keys.js";
import { StdinBuffer } from "./stdin-buffer.js"; import { StdinBuffer } from "./stdin-buffer.js";
@ -47,6 +48,7 @@ export class ProcessTerminal implements Terminal {
private _kittyProtocolActive = false; private _kittyProtocolActive = false;
private stdinBuffer?: StdinBuffer; private stdinBuffer?: StdinBuffer;
private stdinDataHandler?: (data: string) => void; private stdinDataHandler?: (data: string) => void;
private writeLogPath = process.env.PI_TUI_WRITE_LOG || "";
get kittyProtocolActive(): boolean { get kittyProtocolActive(): boolean {
return this._kittyProtocolActive; return this._kittyProtocolActive;
@ -184,6 +186,13 @@ export class ProcessTerminal implements Terminal {
write(data: string): void { write(data: string): void {
process.stdout.write(data); process.stdout.write(data);
if (this.writeLogPath) {
try {
fs.appendFileSync(this.writeLogPath, data, { encoding: "utf8" });
} catch {
// Ignore logging errors
}
}
} }
get columns(): number { get columns(): number {

View file

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