fix(tui): deferred native scrollback rebuilds
This commit is contained in:
@@ -839,6 +839,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#pendingSubmissionDispose = undefined;
|
||||
}
|
||||
this.editor.setText("");
|
||||
this.ui.refreshNativeScrollbackIfDirty();
|
||||
this.ensureLoadingAnimation();
|
||||
this.ui.requestRender();
|
||||
return submission;
|
||||
|
||||
+32
-22
@@ -236,8 +236,6 @@ export class Container implements Component {
|
||||
* - `initial`: first paint after `start()` — clear viewport, emit transcript.
|
||||
* - `sessionReplace`: caller asked for `{ clearScrollback: true }` on a forced
|
||||
* render — clear viewport, clear scrollback (outside multiplexers).
|
||||
* - `historyRebuild`: width changed and offscreen rows changed — clear viewport
|
||||
* and scrollback so terminal history rewraps at the new width.
|
||||
* - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
|
||||
* is set, emit those tail rows as scrollback growth first so streaming
|
||||
* output reaches terminal history before the corrected viewport is drawn.
|
||||
@@ -248,7 +246,6 @@ type RenderIntent =
|
||||
| { kind: "noop" }
|
||||
| { kind: "initial" }
|
||||
| { kind: "sessionReplace" }
|
||||
| { kind: "historyRebuild" }
|
||||
| { kind: "viewportRepaint"; appendFrom?: number }
|
||||
| { kind: "shrink" }
|
||||
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
|
||||
@@ -290,6 +287,7 @@ export class TUI extends Container {
|
||||
// Set after a clear+full replay so the next insert-above-suffix frame does
|
||||
// not scroll replayed live chrome (status/editor) into fresh history.
|
||||
#suppressNextSuffixScroll = false;
|
||||
#nativeScrollbackDirty = false;
|
||||
#fullRedrawCount = 0;
|
||||
#clearScrollbackOnNextRender = false;
|
||||
#hasEverRendered = false;
|
||||
@@ -627,6 +625,17 @@ export class TUI extends Container {
|
||||
this.terminal.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild native terminal scrollback if live rendering deferred a history rewrite.
|
||||
* Callers should only invoke this at checkpoints where the user is expected to be
|
||||
* at the terminal bottom, such as after submitting a new prompt.
|
||||
*/
|
||||
refreshNativeScrollbackIfDirty(): boolean {
|
||||
if (!this.#nativeScrollbackDirty) return false;
|
||||
this.requestRender(true, { clearScrollback: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
requestRender(force = false, options?: RenderRequestOptions): void {
|
||||
if (force) {
|
||||
this.#clearScrollbackOnNextRender ||= options?.clearScrollback === true;
|
||||
@@ -1111,12 +1120,7 @@ export class TUI extends Container {
|
||||
return;
|
||||
case "sessionReplace":
|
||||
this.#clearScrollbackOnNextRender = false;
|
||||
this.#emitFullPaint(lines, width, height, cursorPos, {
|
||||
clearViewport: true,
|
||||
clearScrollback: !isMultiplexerSession(),
|
||||
});
|
||||
return;
|
||||
case "historyRebuild":
|
||||
this.#nativeScrollbackDirty = false;
|
||||
this.#emitFullPaint(lines, width, height, cursorPos, {
|
||||
clearViewport: true,
|
||||
clearScrollback: !isMultiplexerSession(),
|
||||
@@ -1149,9 +1153,10 @@ export class TUI extends Container {
|
||||
|
||||
/**
|
||||
* Map the current frame onto a single render intent. Order matters: forced
|
||||
* resets and session replacement short-circuit before any diff work, and
|
||||
* width-changed-with-offscreen edits route to {@link "historyRebuild"} so
|
||||
* terminal scrollback receives the new geometry.
|
||||
* resets and session replacement short-circuit before any diff work. Frames
|
||||
* that would require rewriting native scrollback mark it dirty and repaint
|
||||
* the viewport instead; the destructive clear+replay is deferred to an
|
||||
* explicit checkpoint.
|
||||
*/
|
||||
#planRender(
|
||||
newLines: string[],
|
||||
@@ -1177,9 +1182,9 @@ export class TUI extends Container {
|
||||
|
||||
// Shrink-across-viewport-boundary: if a shrink would place the new
|
||||
// viewport above rows already committed to terminal scrollback, those
|
||||
// rows would appear twice when the user scrolls back. A clear+replay
|
||||
// keeps the current OMP transcript scrollable while dropping stale
|
||||
// terminal history.
|
||||
// rows can become stale or duplicated in native history. Preserve native
|
||||
// scrollback for users reading it now, and defer the destructive
|
||||
// clear+replay to the next checkpoint.
|
||||
const naturalViewportTop = Math.max(0, newLines.length - height);
|
||||
if (
|
||||
diff.firstChanged !== -1 &&
|
||||
@@ -1187,7 +1192,8 @@ export class TUI extends Container {
|
||||
naturalViewportTop < this.#scrollbackHighWater &&
|
||||
!isMultiplexerSession()
|
||||
) {
|
||||
return { kind: "historyRebuild" };
|
||||
this.#nativeScrollbackDirty = true;
|
||||
return { kind: "viewportRepaint" };
|
||||
}
|
||||
|
||||
const suppressSuffixScroll = this.#suppressNextSuffixScroll;
|
||||
@@ -1210,12 +1216,16 @@ export class TUI extends Container {
|
||||
return { kind: "noop" };
|
||||
}
|
||||
|
||||
// Width changes alter wrapping for the whole transcript. Offscreen
|
||||
// edits need a history rebuild so terminal scrollback receives the
|
||||
// new geometry; pure appends fall through to the diff path so the
|
||||
// append handler scrolls them into scrollback correctly.
|
||||
// Width changes alter wrapping for the whole transcript. Offscreen edits
|
||||
// make native history stale at the old geometry; mark it dirty and repaint
|
||||
// only the viewport so users scrolled into history are not yanked mid-stream.
|
||||
// Pure appends fall through to the diff path so the append handler scrolls
|
||||
// them into history correctly.
|
||||
if (widthChanged) {
|
||||
if (diff.firstChanged < prevViewportTop) return { kind: "historyRebuild" };
|
||||
if (diff.firstChanged < prevViewportTop) {
|
||||
this.#nativeScrollbackDirty = true;
|
||||
return { kind: "viewportRepaint" };
|
||||
}
|
||||
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
||||
if (!pureAppend) return { kind: "viewportRepaint" };
|
||||
}
|
||||
@@ -1340,7 +1350,7 @@ export class TUI extends Container {
|
||||
|
||||
/**
|
||||
* Clear the viewport (optionally scrollback) and emit the full transcript.
|
||||
* Backs `initial`, `sessionReplace`, and `historyRebuild` intents.
|
||||
* Backs `initial` and `sessionReplace` intents.
|
||||
*/
|
||||
#emitFullPaint(
|
||||
lines: string[],
|
||||
|
||||
@@ -545,7 +545,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
it("forced renders during resize storm stay stable under cursor relocation", async () => {
|
||||
const term = new VirtualTerminal(80, 18);
|
||||
const tui = new TUI(term);
|
||||
@@ -731,7 +731,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("keeps viewport aligned when offscreen header changes during overflow growth", async () => {
|
||||
const term = new VirtualTerminal(32, 6);
|
||||
@@ -849,12 +849,66 @@ describe("TUI terminal-state regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("tail-cell mutation after the transcript overflowed does not re-deposit header rows", async () => {
|
||||
// Repro for the reported scrollback-duplication bug: once a header
|
||||
it("defers stale-history rebuild while native scrollback is scrolled", async () => {
|
||||
const term = new VirtualTerminal(32, 5);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 12));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
term.scrollLines(-2);
|
||||
const before = term.getBufferPosition();
|
||||
expect(before.viewportY).toBeGreaterThan(0);
|
||||
|
||||
component.setLines(rows("line-", 8));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
const after = term.getBufferPosition();
|
||||
expect(after.viewportY).toBe(before.viewportY);
|
||||
expect(tui.refreshNativeScrollbackIfDirty()).toBe(true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes deferred native scrollback at an explicit bottom checkpoint", async () => {
|
||||
const term = new VirtualTerminal(32, 5);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 12));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
term.scrollLines(-2);
|
||||
|
||||
component.setLines(rows("line-", 8));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
term.scrollLines(999);
|
||||
expect(tui.refreshNativeScrollbackIfDirty()).toBe(true);
|
||||
await settle(term);
|
||||
|
||||
const position = term.getBufferPosition();
|
||||
expect(position.viewportY).toBe(position.baseY);
|
||||
expect(visible(term).map(line => line.trim())).toEqual(["line-3", "line-4", "line-5", "line-6", "line-7"]);
|
||||
expect(tui.refreshNativeScrollbackIfDirty()).toBe(false);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("tail-cell mutation is cleaned up at the next native scrollback checkpoint", async () => {
|
||||
// Repro for the old scrollback-duplication bug: once a header
|
||||
// (e.g. the welcome screen) has scrolled into terminal history, the
|
||||
// last tool cell mutating (grow/shrink cycles, completion collapse)
|
||||
// must not re-emit those header rows in a way that visibly
|
||||
// duplicates them when the user scrolls back.
|
||||
// makes native scrollback stale. Live frames now defer the destructive
|
||||
// clear+replay until a user-run checkpoint rather than yanking users who
|
||||
// are reading scrollback mid-stream.
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(term);
|
||||
const header = new MutableLinesComponent(["HEADER-0", "HEADER-1", "HEADER-2", "HEADER-3", "HEADER-4"]);
|
||||
@@ -892,11 +946,14 @@ describe("TUI terminal-state regressions", () => {
|
||||
|
||||
// Final completion-style collapse: full transcript fits in the
|
||||
// viewport again, even though scrollback already holds an
|
||||
// earlier copy of HEADER.
|
||||
// earlier copy of HEADER. Rebuild at the next checkpoint to clean the
|
||||
// stale native history.
|
||||
tail.setLines(["[completed: many lines]", "[footer]"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
term.scrollLines(999);
|
||||
expect(tui.refreshNativeScrollbackIfDirty()).toBe(true);
|
||||
await settle(term);
|
||||
const scrollback = term.getScrollBuffer();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const pattern = new RegExp(`\\bHEADER-${i}\\b`);
|
||||
|
||||
@@ -130,6 +130,19 @@ export class VirtualTerminal implements Terminal {
|
||||
this.inputHandler(data);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Simulate the user scrolling through native terminal scrollback.
|
||||
* Negative values scroll up; positive values scroll down.
|
||||
*/
|
||||
scrollLines(lines: number): void {
|
||||
this.xterm.scrollLines(lines);
|
||||
}
|
||||
|
||||
/** Get the terminal buffer's scrollback and viewport offsets. */
|
||||
getBufferPosition(): { baseY: number; viewportY: number } {
|
||||
const buffer = this.xterm.buffer.active;
|
||||
return { baseY: buffer.baseY, viewportY: buffer.viewportY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the terminal
|
||||
|
||||
Reference in New Issue
Block a user