Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions packages/tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +949 to 953
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
}
Expand All @@ -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);
Expand Down
77 changes: 77 additions & 0 deletions packages/tui/test/tui-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});
Comment on lines +564 to +585
});
Loading