Skip to content

fix(tui): preserve scrollback on content-driven full redraws - #4204

Closed
joemcurry wants to merge 1 commit into
earendil-works:mainfrom
joemcurry:fix/tui-preserve-scrollback-on-content-redraw
Closed

fix(tui): preserve scrollback on content-driven full redraws#4204
joemcurry wants to merge 1 commit into
earendil-works:mainfrom
joemcurry:fix/tui-preserve-scrollback-on-content-redraw

Conversation

@joemcurry

Copy link
Copy Markdown

Summary

Stops pi-tui from wiping the terminal's scrollback buffer on content-driven full redraws. Previously, fullRender(true) always emitted \x1b[2J\x1b[H\x1b[3J, which clears scrollback as well as the visible screen. Because pi-tui deliberately renders in normal-screen mode (not alt-screen) so that conversation history stays scrollable, that scrollback wipe was actively destructive.

Why

firstChanged < prevViewportTop triggers fullRender(true) whenever any line above the previous viewport changes. During long streaming markdown the renderer re-flows the entire message body on every chunk; once the message has grown past the visible area, even a small reshuffle (paragraph boundary moving, code-fence opening, list re-indent) can put firstChanged above prevViewportTop. Each time that fired, the user lost:

  • Any pre-existing shell history that was in the terminal's scrollback before pi-tui started.
  • Any TUI lines that had naturally scrolled out during prior renders.

Width changes are different — wrapping changes mean the existing scrollback is visually misaligned at the new width, so clearing it there is defensible. But for content-driven redraws there's no need to throw it away.

Change

Split the clear parameter on the local fullRender helper:

const fullRender = (clear: boolean | "scrollback"): void => {
    if (clear === "scrollback") {
        buffer += "\x1b[2J\x1b[H\x1b[3J"; // clear screen + home + clear scrollback
    } else if (clear === true) {
        buffer += "\x1b[2J\x1b[H";        // clear screen + home, preserve scrollback
    }
    // when not wiping scrollback, write only the bottom `height` lines so we
    // don't push duplicate copies into scrollback that prior renders already
    // pushed there
    const startLine = clear === true ? Math.max(0, newLines.length - height) : 0;
    for (let i = startLine; i < newLines.length; i++) { /* ... */ }
    // ...
};

Call sites:

Trigger Before After
widthChanged fullRender(true) fullRender("scrollback")
heightChanged (non-Termux) fullRender(true) fullRender(true) (preserves scrollback)
clearOnShrink fullRender(true) fullRender(true) (preserves scrollback)
targetRow < prevViewportTop fullRender(true) fullRender(true) (preserves scrollback)
extraLines > height fullRender(true) fullRender(true) (preserves scrollback)
firstChanged < prevViewportTop fullRender(true) fullRender(true) (preserves scrollback)

Only widthChanged keeps the scrollback wipe.

Edge case considered

Writing all of newLines after \x1b[2J\x1b[H (without \x1b[3J) would push newLines.length - height lines into scrollback via the natural-scroll path, duplicating content that earlier differential renders already pushed there. The patch addresses this by writing only the bottom height lines on clear === true. The viewport ends up populated with the latest content; scrollback retains whatever the prior renders left there. That history may be slightly stale relative to the current state of an above-viewport line, but it's strictly better than losing it entirely — and the differential renderer already accepts some scrollback drift (it never updates lines that have scrolled out).

Tests

Adds two regression tests in packages/tui/test/tui-render.test.ts:

  1. preserves shell scrollback across a content-driven full redraw — pre-populates the virtual terminal's scrollback with shell-style lines, runs the TUI long enough to hit the above-viewport path, and asserts the pre-existing lines are still in getScrollBuffer(). Fails on main (not ok), passes with this patch.
  2. clears scrollback on width change (wrapping invalidates prior render) — sanity check that the widthChanged branch still triggers a full redraw.

Existing 554 tests continue to pass (npm test from packages/tui).

Reported downstream

Filed as openclaw/openclaw#78017 — long messages "vanish" in the OpenClaw TUI after streaming. The OpenClaw TUI consumes pi-tui directly, so the fix lands at this layer.

Risk

Low. The fix only changes which escape sequences are emitted on the full-redraw paths and how many lines are written; the line-tracking state (previousLines, previousViewportTop, hardwareCursorRow, cursorRow) is updated identically to before, so subsequent differential renders continue to operate on the same coordinate system.

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 <noreply@anthropic.com>
@github-actions github-actions Bot added the possibly-openclaw-clanker User has activity on openclaw/openclaw label May 5, 2026
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

This PR was auto-closed. Only contributors approved with lgtm can open PRs. Open an issue first.

Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in CONTRIBUTING.md will not be reopened or receive a reply.

If a maintainer replies lgtmi, your future issues will stay open. If a maintainer replies lgtm, your future issues and PRs will stay open.

See CONTRIBUTING.md.

@github-actions github-actions Bot closed this May 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the TUI renderer so content-driven full redraws no longer erase terminal scrollback, while keeping scrollback wipes for width changes where reflow invalidates prior output.

Changes:

  • Split full redraw behavior into viewport-only clears vs. scrollback-clearing redraws.
  • Route width changes through the scrollback-clearing path and preserve scrollback for content/height/shrink-driven full redraws.
  • Add regression tests around scrollback preservation and width-change redraw behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/tui/src/tui.ts Changes full redraw semantics and updates the redraw call sites.
packages/tui/test/tui-render.test.ts Adds regression coverage for scrollback preservation and width-change redraws.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/tui/src/tui.ts
Comment on lines +949 to 953
// 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);
Comment on lines +564 to +585
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.
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

possibly-openclaw-clanker User has activity on openclaw/openclaw

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants