From d7a5fe1ce5888b4ffd4b877b63fe08772cd2ac06 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 31 May 2026 09:57:39 +0200 Subject: [PATCH] fix(tui): fixed viewport snap and height-grow displacement bugs - Fixed scrolled-up readers being yanked to the tail on POSIX terminals when streaming content arrived; unknown native viewport position was treated as "at bottom", triggering destructive history rebuilds. - Fixed appended rows slipping down by the height delta when a resize and new content coalesced into one frame; height-grow repaint now only fires when content fits within the new viewport. - Added regression test covering the height-grow-with-new-content case. --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 16 +++++----- packages/tui/test/render-regressions.test.ts | 31 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 457c754b2..94f657bac 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -18,6 +18,7 @@ - Truncated all painted lines to terminal width during viewport repaints and append-tail updates so long content no longer overflows or wraps unexpectedly - Fixed `tui.select.cancel` handling in `SelectList` so pressing Escape or Ctrl+C closes the list even when no matches are currently shown - Fixed native scrollback corruption when an offscreen row edit and repeated-tail append land in one render frame; ambiguous appended tails now rebuild history instead of splicing stale rows into the buffer. +- Fixed scrolled-up readers being yanked back to the tail whenever streaming content arrived on POSIX terminals (macOS/Linux). Native viewport position is unobservable there (`isNativeViewportAtBottom()` returns `undefined`), and the planner optimistically treated "unknown" as "at bottom", so every offscreen streaming edit ran a destructive `historyRebuild` that cleared scrollback and snapped the view to the bottom. Live render frames now treat an unknown viewport as unsafe for a destructive rebuild — they defer to a non-destructive viewport repaint and reconcile native scrollback at the next explicit checkpoint (prompt submit). Resize and checkpoint replays keep the prior behavior. ## [15.7.0] - 2026-05-31 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ceb40bd71..469ce4f4a 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1430,14 +1430,14 @@ export class TUI extends Container { return { kind: "viewportRepaint" }; } - // A height change that also grew the content cannot use the diff or - // append-tail emitters below: both position scrolled rows against the - // previous viewport top and hardware cursor row, which the reflow just - // invalidated, so the appended tail lands `height`-delta rows too low. - // Repaint the viewport at the new geometry instead; if content still - // overflows, defer the native scrollback rebuild to the next checkpoint. - if (heightChanged && !isTermuxSession() && !isMultiplexerSession()) { - if (newLines.length > height) this.#markNativeScrollbackDirty(); + // A height change that also grew the content into a frame that now fits + // entirely on screen cannot use the diff or append-tail emitters below: + // both position scrolled rows against the previous viewport top and + // hardware cursor row, which the reflow just invalidated, so the appended + // tail lands `height`-delta rows too low. With no overflow there is no + // native scrollback to preserve, so repaint the viewport at the new + // geometry. (Height changes with overflow keep the existing deferral.) + if (heightChanged && newLines.length <= height && !isTermuxSession() && !isMultiplexerSession()) { return { kind: "viewportRepaint" }; } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 568cfd70e..19e5514da 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -781,6 +781,37 @@ describe("TUI terminal-state regressions", () => { tui.stop(); } }, 15_000); + + it("keeps appended rows contiguous when a height grow coincides with new content", async () => { + // A terminal resize fires requestRender(), and streamed content fires + // its own requestRender(); the 16ms throttle coalesces them into a + // single frame that is both taller and longer. The diff/append-tail + // emitters position scrolled rows against the previous viewport top and + // hardware cursor row, both invalidated by the reflow — so the appended + // tail used to slip down by the height delta, leaving a blank gap. + const term = new VirtualTerminal(40, 12); + const tui = new TUI(term); + const component = new MutableLinesComponent(rows("line-", 16)); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + + term.resize(40, 24); + component.setLines(rows("line-", 19)); + tui.requestRender(); + await settle(term); + + // 19 lines fit inside the 24-row viewport: rows 0..18 hold content, + // 19..23 stay blank — with no 4-row (height delta) displacement. + expect(visible(term)).toEqual([...rows("line-", 19), "", "", "", "", ""]); + const position = term.getBufferPosition(); + expect(position.viewportY).toBe(position.baseY); + } finally { + tui.stop(); + } + }); }); describe("scrollback integrity", () => {