fix(tui): preserve scrollback on content-driven full redraws - #4204
fix(tui): preserve scrollback on content-driven full redraws#4204joemcurry wants to merge 1 commit into
Conversation
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>
|
This PR was auto-closed. Only contributors approved with 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 See CONTRIBUTING.md. |
There was a problem hiding this comment.
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.
| // 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); |
| 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. | ||
| }); |
Summary
Stops
pi-tuifrom 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. Becausepi-tuideliberately renders in normal-screen mode (not alt-screen) so that conversation history stays scrollable, that scrollback wipe was actively destructive.Why
firstChanged < prevViewportToptriggersfullRender(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 putfirstChangedaboveprevViewportTop. Each time that fired, the user lost:pi-tuistarted.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
clearparameter on the localfullRenderhelper:Call sites:
widthChangedfullRender(true)fullRender("scrollback")heightChanged(non-Termux)fullRender(true)fullRender(true)(preserves scrollback)clearOnShrinkfullRender(true)fullRender(true)(preserves scrollback)targetRow < prevViewportTopfullRender(true)fullRender(true)(preserves scrollback)extraLines > heightfullRender(true)fullRender(true)(preserves scrollback)firstChanged < prevViewportTopfullRender(true)fullRender(true)(preserves scrollback)Only
widthChangedkeeps the scrollback wipe.Edge case considered
Writing all of
newLinesafter\x1b[2J\x1b[H(without\x1b[3J) would pushnewLines.length - heightlines into scrollback via the natural-scroll path, duplicating content that earlier differential renders already pushed there. The patch addresses this by writing only the bottomheightlines onclear === 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: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 ingetScrollBuffer(). Fails onmain(not ok), passes with this patch.clears scrollback on width change (wrapping invalidates prior render)— sanity check that thewidthChangedbranch still triggers a full redraw.Existing 554 tests continue to pass (
npm testfrompackages/tui).Reported downstream
Filed as openclaw/openclaw#78017 — long messages "vanish" in the OpenClaw TUI after streaming. The OpenClaw TUI consumes
pi-tuidirectly, 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.