From 3fa7c203f437e06819abba9cf8b9df93f79266df Mon Sep 17 00:00:00 2001 From: mjapple8 <351535293@qq.com> Date: Thu, 6 Aug 2026 22:19:24 +0800 Subject: [PATCH 1/2] fix(tui): preserve scrollback on content-driven full redraws When a line above the previous viewport changes (e.g. streaming markdown re-flowing while the message has grown past the visible area), the renderer fell back to fullRender(true), which unconditionally emitted ESC[2J ESC[H ESC[3J and wiped the terminal's scrollback buffer. In normal-screen mode pi-tui relies on terminal scrollback to keep conversation history scrollable, so users reading earlier output lost it all at once. Split fullRender's clear flag into three modes: - false: first render, writes everything - true: clear viewport but PRESERVE scrollback, write only the last height lines so earlier content is not pushed into scrollback a second time - "scrollback": clear viewport AND scrollback, write everything (reserved for width changes where prior wrapping is invalid) --- packages/tui/src/tui-main-screen.ts | 37 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index bb2d6785d0d..d0c8bb0277b 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -206,16 +206,34 @@ export class TuiMainScreen extends TuiBase implements TUI { 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) { + if (clear === "scrollback") { buffer += this.deleteKittyImages(this.previousKittyImageIds); buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback + } else if (clear === true) { + buffer += this.deleteKittyImages(this.previousKittyImageIds); + buffer += "\x1b[2J\x1b[H"; // Clear screen + home, preserve scrollback } - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; + // 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"; const line = newLines[i]; const isImage = isImageLine(line); const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; @@ -235,8 +253,8 @@ export class TuiMainScreen extends TuiBase implements TUI { 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); @@ -266,10 +284,11 @@ export class TuiMainScreen extends TuiBase implements TUI { 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; } From 1d7c50f70d1b379dd66982de1dfac76040724457 Mon Sep 17 00:00:00 2001 From: mjapple8 <351535293@qq.com> Date: Thu, 6 Aug 2026 22:19:33 +0800 Subject: [PATCH 2/2] test(tui): add regression tests for scrollback preservation on full redraws Covers the content-driven full redraw path (line above the viewport changes): pre-TUI shell history must survive, earlier content must not be duplicated into scrollback, and width changes still clear stale scrollback. --- .../tui/test/tui-scrollback-preserve.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/tui/test/tui-scrollback-preserve.test.ts diff --git a/packages/tui/test/tui-scrollback-preserve.test.ts b/packages/tui/test/tui-scrollback-preserve.test.ts new file mode 100644 index 00000000000..663f52948a3 --- /dev/null +++ b/packages/tui/test/tui-scrollback-preserve.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class Lines implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} + + setLines(lines: string[]): void { + this.lines = lines; + } +} + +describe("TUI scrollback preservation", () => { + // Regression test for conversation history disappearing from terminal scrollback. + // + // When a line 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 a full redraw. Previously this issued \x1b[3J unconditionally, + // wiping the terminal's scrollback buffer and discarding any content the host + // shell or earlier messages had placed there. Content-driven redraws must + // 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. + for (let i = 0; i < 25; i++) { + terminal.write(`shell-history-${i}\r\n`); + } + await terminal.waitForRender(); + + const tui: TUI = new TuiMainScreen(terminal); + const content = new Lines(Array.from({ length: 30 }, (_, i) => `Line ${i}`)); + tui.addChild(content); + 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. + content.setLines(Array.from({ length: 30 }, (_, i) => (i === 5 ? "Line 5 CHANGED" : `Line ${i}`))); + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > initialRedraws, "above-viewport change should trigger a full redraw"); + + const scrollback = terminal.getScrollBuffer(); + const survivedLines = scrollback.filter((row) => row.includes("shell-history")); + assert.ok( + survivedLines.length > 0, + `pre-TUI shell history should survive a content-driven redraw; none of the shell-history lines remained in scrollback`, + ); + tui.stop(); + }); + + // A viewport-only redraw writes only the last `height` lines of the document + // so earlier content is not pushed into scrollback a second time. + it("does not duplicate content into scrollback on a content-driven full redraw", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui: TUI = new TuiMainScreen(terminal); + const content = new Lines(Array.from({ length: 30 }, (_, i) => `Line ${i}`)); + tui.addChild(content); + tui.start(); + await terminal.waitForRender(); + + const scrollbackBefore = terminal.getScrollBuffer(); + const topOfContentBefore = scrollbackBefore.filter((row) => row.includes("Line 0")).length; + + content.setLines(Array.from({ length: 30 }, (_, i) => (i === 5 ? "Line 5 CHANGED" : `Line ${i}`))); + tui.requestRender(); + await terminal.waitForRender(); + + const scrollbackAfter = terminal.getScrollBuffer(); + const topOfContentAfter = scrollbackAfter.filter((row) => row.includes("Line 0")).length; + assert.ok( + topOfContentAfter <= topOfContentBefore, + "a viewport-only full redraw must not push duplicate copies of earlier content into scrollback", + ); + tui.stop(); + }); + + 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: TUI = new TuiMainScreen(terminal); + const content = new Lines(Array.from({ length: 15 }, (_, i) => `Line ${i}`)); + tui.addChild(content); + tui.start(); + await terminal.waitForRender(); + + terminal.resize(60, 10); + await terminal.waitForRender(); + + const scrollback = terminal.getScrollBuffer(); + const survivedLines = scrollback.filter((row) => row.includes("shell-")); + assert.ok( + survivedLines.length === 0, + "width change should clear stale scrollback since wrapping has changed", + ); + tui.stop(); + }); +});