diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 75d396b0c..222070b42 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -12,6 +12,10 @@ - Changed truncation debug logging to run only when `debugRedraw` is enabled +### Fixed + +- Fixed viewport jumping during streaming and session swap by tracking actual content height instead of high-water mark + ## [14.0.5] - 2026-04-11 ### Changed diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2348398b4..a39500c5b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -230,7 +230,7 @@ export class TUI extends Container { #sixelProbeUnsubscribe?: () => void; #showHardwareCursor = $flag("PI_HARDWARE_CURSOR"); #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off) - #maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy + #maxLinesRendered = 0; // Line count from last render, used for viewport calculation #fullRedrawCount = 0; #stopped = false; @@ -1066,11 +1066,11 @@ export class TUI extends Container { return; } - // Content shrunk below the working area and no overlays - re-render to clear empty rows + // Content shrunk below the previous render and no overlays - re-render to clear empty rows // (overlays need the padding, so only do this when no overlays are active) // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var - if (this.#clearOnShrink && newLines.length < this.#maxLinesRendered && this.overlayStack.length === 0) { - logRedraw(`clearOnShrink (maxLinesRendered=${this.#maxLinesRendered})`); + if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) { + logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`); fullRender(true); return; } @@ -1144,18 +1144,34 @@ export class TUI extends Container { this.#previousLines = newLines; this.#previousWidth = width; this.#previousHeight = height; - this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); + this.#maxLinesRendered = newLines.length; + this.#viewportTopRow = Math.max(0, newLines.length - height); return; } // Check if firstChanged is above what was previously visible - // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks const previousContentViewportTop = Math.max(0, this.#previousLines.length - height); if (firstChanged < previousContentViewportTop) { - // First change is above previous viewport - need full re-render - logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`); - fullRender(true); - return; + const newViewportTop = Math.max(0, newLines.length - height); + if (newViewportTop < previousContentViewportTop) { + // Viewport needs to shift up — can only be done with a full redraw + logRedraw(`viewport shift up (new=${newViewportTop} < prev=${previousContentViewportTop})`); + fullRender(true); + return; + } + // Viewport is stable or shifting down — skip invisible above-viewport changes + firstChanged = previousContentViewportTop; + if (lastChanged < firstChanged) { + // All changes are above the viewport — nothing visible to update + this.#cursorRow = Math.max(0, newLines.length - 1); + this.#maxLinesRendered = newLines.length; + this.#viewportTopRow = Math.max(0, newLines.length - height); + this.#positionHardwareCursor(cursorPos, newLines.length); + this.#previousLines = newLines; + this.#previousWidth = width; + this.#previousHeight = height; + return; + } } // Render from first changed line to end @@ -1272,9 +1288,9 @@ export class TUI extends Container { // hardwareCursorRow tracks actual terminal cursor position (for movement) this.#cursorRow = Math.max(0, newLines.length - 1); this.#hardwareCursorRow = finalCursorRow; - // Track terminal's working area (grows but doesn't shrink unless cleared) - this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length); - this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); + // Track content height for viewport calculation + this.#maxLinesRendered = newLines.length; + this.#viewportTopRow = Math.max(0, newLines.length - height); // Position hardware cursor for IME this.#positionHardwareCursor(cursorPos, newLines.length);