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.
This commit is contained in:
can1357
2026-05-31 09:57:39 +02:00
parent 21259aac5e
commit d7a5fe1ce5
3 changed files with 40 additions and 8 deletions
+1
View File
@@ -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
+8 -8
View File
@@ -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" };
}
@@ -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", () => {