From fb5440c7ce34e3d9a1e10b88f7f5c2263d77cf27 Mon Sep 17 00:00:00 2001 From: Joe Curry Date: Tue, 5 May 2026 15:05:04 -0400 Subject: [PATCH] fix(tui): preserve scrollback on content-driven full redraws The differential renderer falls back to a full redraw whenever a line above the previous viewport changes (`firstChanged < prevViewportTop`), or when content shrinks past the working area, or when many lines are deleted. All of these paths used to emit `\x1b[2J\x1b[H\x1b[3J`, which clears the terminal's scrollback buffer in addition to the screen. Because `pi-tui` deliberately uses normal-screen rendering (not the alt-screen) so that conversation history remains scrollable, wiping scrollback is destructive: any prior shell history and any TUI lines that had naturally scrolled out are gone, and the user can no longer recover them by scrolling up. This is most visible during long streaming markdown, where re-flowing the body shifts an early line and trips the above-viewport path. Split `fullRender` into two modes: - `true` clears the visible viewport only (`\x1b[2J\x1b[H`) and writes only the bottom `height` lines, so the redraw doesn't duplicate content into scrollback that earlier renders already pushed there. - `"scrollback"` clears viewport AND scrollback (`\x1b[3J`) and writes everything. Reserved for width changes, where prior wrapping is no longer correct. Update call sites accordingly: only `widthChanged` uses the scrollback mode; height changes, content shrinks, deletes, and the above-viewport path use the viewport-only mode. Adds a regression test that pre-populates terminal scrollback, runs a TUI render that triggers the above-viewport path, and asserts the pre-existing scrollback survives. Also adds a sanity test that width changes still clear scrollback. Co-Authored-By: Claude Opus 4.7 --- packages/tui/src/tui.ts | 46 +++++++++++++---- packages/tui/test/tui-render.test.ts | 77 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index fda30282ec0..268a18c7904 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -914,21 +914,40 @@ export class TUI extends Container { newLines = this.applyLineResets(newLines); - // Helper to clear scrollback and viewport and render all new lines - const fullRender = (clear: boolean): void => { + // Helper to clear viewport (and optionally scrollback) and render new lines. + // + // `clear` modes: + // false - first render only; assumes a clean screen and writes everything. + // true - clear visible viewport (\x1b[2J\x1b[H) but PRESERVE scrollback. + // Only the last `height` lines of newLines are written so we don't + // duplicate content into scrollback that the natural-scroll path + // has already pushed there during prior renders. Use this for + // content-driven redraws (e.g. firstChanged above viewport). + // "scrollback" - clear viewport AND scrollback (\x1b[2J\x1b[H\x1b[3J), then + // write all of newLines so the terminal scrolls them in fresh. + // Reserved for cases where existing scrollback would be visually + // wrong (e.g. width change re-flows wrapping). + const fullRender = (clear: boolean | "scrollback"): void => { this.fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output - if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; + if (clear === "scrollback") { + buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback + } else if (clear === true) { + buffer += "\x1b[2J\x1b[H"; // Clear screen + home, preserve scrollback + } + // For viewport-only clear, write only what fits on screen so we don't push + // duplicate copies of earlier content into scrollback. + const startLine = clear === true ? Math.max(0, newLines.length - height) : 0; + for (let i = startLine; i < newLines.length; i++) { + if (i > startLine) buffer += "\r\n"; buffer += newLines[i]; } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); this.cursorRow = Math.max(0, newLines.length - 1); this.hardwareCursorRow = this.cursorRow; - // Reset max lines when clearing, otherwise track growth - if (clear) { + // Reset max lines on scrollback wipe, otherwise track growth + if (clear === "scrollback") { this.maxLinesRendered = newLines.length; } else { this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); @@ -956,16 +975,18 @@ export class TUI extends Container { return; } - // Width changes always need a full re-render because wrapping changes. + // Width changes invalidate existing scrollback because wrapping changes, + // so this is one of the few cases where wiping scrollback is justified. if (widthChanged) { logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); - fullRender(true); + fullRender("scrollback"); return; } // Height changes normally need a full re-render to keep the visible viewport aligned, // but Termux changes height when the software keyboard shows or hides. // In that environment, a full redraw causes the entire history to replay on every toggle. + // Wrapping is unchanged on a height-only resize, so scrollback stays valid. if (heightChanged && !isTermuxSession()) { logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); fullRender(true); @@ -1035,6 +1056,8 @@ export class TUI extends Container { fullRender(true); return; } + // (`fullRender(true)` for `targetRow < prevViewportTop` and `extraLines > height` above + // preserves scrollback; wrapping has not changed, so prior scrollback is still valid.) if (extraLines > 0) { buffer += "\x1b[1B"; } @@ -1060,6 +1083,11 @@ export class TUI extends Container { // Differential rendering can only touch what was actually visible. // If the first changed line is above the previous viewport, we need a full redraw. + // We clear the viewport but PRESERVE scrollback: the lines that scrolled out + // during prior renders are user-visible history. Earlier behavior wiped + // scrollback (\x1b[3J), causing long messages to vanish from the user's + // terminal entirely once any line above the viewport changed (e.g. streaming + // markdown re-flowing while the message extended past the viewport). if (firstChanged < prevViewportTop) { logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); fullRender(true); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 4357a36756a..f92ad3ee895 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -507,3 +507,80 @@ describe("TUI differential rendering", () => { tui.stop(); }); }); + +describe("TUI scrollback preservation", () => { + // Regression test for long messages disappearing from terminal scrollback. + // + // When content above the previous viewport changes (e.g. streaming markdown + // re-flowing while the message has grown past the visible area), the renderer + // falls back to fullRender. Previously this issued \x1b[3J unconditionally, + // which wiped the terminal's scrollback buffer and discarded any content the + // host shell or earlier sessions had placed there. Content-driven redraws + // should preserve scrollback; only width changes (which invalidate prior + // wrapping) should clear it. + it("preserves shell scrollback across a content-driven full redraw", async () => { + const terminal = new VirtualTerminal(40, 10); + + // Simulate pre-existing shell history: lines that the user's terminal + // had in its scrollback before the TUI started. The TUI must not + // destroy these on a content-driven redraw. + const shellLines: string[] = []; + for (let i = 0; i < 25; i++) { + shellLines.push(`shell-history-${i}`); + terminal.write(`shell-history-${i}\r\n`); + } + await terminal.waitForRender(); + + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Long enough to scroll naturally and set prevViewportTop > 0. + component.lines = Array.from({ length: 30 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + + // Trigger the `firstChanged < prevViewportTop` path by changing a line + // that has scrolled above the visible viewport. + const next = Array.from({ length: 30 }, (_, i) => (i === 5 ? "Line 5 CHANGED" : `Line ${i}`)); + component.lines = next; + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > initialRedraws, "Above-viewport change should trigger a full redraw"); + + const scrollback = terminal.getScrollBuffer(); + // Pre-existing shell history must still be retrievable via scrollback. + // With the prior \x1b[3J behavior these would all be gone. + const survivedLines = shellLines.filter((line) => scrollback.some((row) => row.includes(line))); + assert.ok( + survivedLines.length > 0, + `Pre-TUI shell history should survive content-driven redraw; none of ${shellLines.length} lines remained in scrollback`, + ); + }); + + it("clears scrollback on width change (wrapping invalidates prior render)", async () => { + const terminal = new VirtualTerminal(40, 10); + // Pre-existing shell history that becomes stale after a re-flow. + for (let i = 0; i < 20; i++) terminal.write(`shell-${i}\r\n`); + await terminal.waitForRender(); + + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 15 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + terminal.resize(60, 10); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > initialRedraws, "Width change should trigger a full redraw"); + // On width change we still emit \x1b[3J because wrapping has changed + // and prior scrollback would be visually misaligned. + }); +});