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
37 changes: 28 additions & 9 deletions packages/tui/src/tui-main-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
122 changes: 122 additions & 0 deletions packages/tui/test/tui-scrollback-preserve.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});