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:
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user