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; +});