From a6f9c3cf0dd1a7546ae24e76900bd76cf775e702 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 26 Jan 2026 16:51:28 +0100 Subject: [PATCH] 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 --- packages/coding-agent/docs/tui.md | 8 ++ packages/tui/CHANGELOG.md | 6 + packages/tui/README.md | 8 ++ packages/tui/src/terminal.ts | 9 ++ packages/tui/src/tui.ts | 34 +++++- packages/tui/test/viewport-overwrite-repro.ts | 106 ++++++++++++++++++ 6 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 packages/tui/test/viewport-overwrite-repro.ts diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 711686e0..6c086e41 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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: diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 863db668..653d0f53 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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)) diff --git a/packages/tui/README.md b/packages/tui/README.md index e3b0e89e..accde46a 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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 +``` diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 34e22bca..cc5d925a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -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 { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index b4823e11..686bfc7a 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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)}`, diff --git a/packages/tui/test/viewport-overwrite-repro.ts b/packages/tui/test/viewport-overwrite-repro.ts new file mode 100644 index 00000000..5a0ac4f9 --- /dev/null +++ b/packages/tui/test/viewport-overwrite-repro.ts @@ -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 => 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 { + 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 { + 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; +});