co-mono/packages/tui/test/viewport-overwrite-repro.ts
Mario Zechner a6f9c3cf0d 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
2026-01-26 16:51:28 +01:00

106 lines
3.1 KiB
TypeScript

/**
* 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;
});