Skip to content

Pull pushed rows back down when a height shrink/grow cycle reverses - #190

Open
ejc3 wants to merge 1 commit into
manaflow-ai:mainfrom
ejc3:resize-debt-scrollback
Open

Pull pushed rows back down when a height shrink/grow cycle reverses#190
ejc3 wants to merge 1 commit into
manaflow-ai:mainfrom
ejc3:resize-debt-scrollback

Conversation

@ejc3

@ejc3 ejc3 commented Aug 7, 2026

Copy link
Copy Markdown

What breaks today

A no-reflow height shrink pushes the viewport's top rows into history. The matching grow declines to pull them back whenever the cursor sits above the bottom row — the intentional no-yank UX in PageList.resizeWithoutReflow. A full-screen TUI that parks its cursor in an input box (Claude Code, and ink apps generally) defeats that heuristic permanently, and when tmux sits above the TUI and fully repaints after every resize, each shrink/grow cycle appends one viewport-top of duplicated rows to scrollback. A window drag fires many cycles; the duplicates read as corrupted scrollback just above the screen. tmux's own history stays clean throughout — the drift is entirely in the terminal's ledger.

Repro: run a TUI under tmux with the alternate screen off, put real content on screen, shrink and grow the window height a few times, scroll up. totalRows() grows by the pushed count on every cycle.

The change

Track the rows a shrink pushes as resize_row_debt. The next cursor-above-bottom grow pulls at most that many rows back down before appending blanks. Nothing else moves:

  • Any content scroll clears the debt (grow()), so history that has become real is never yanked down — the existing no-pull UX is preserved for every ordinary grow.
  • The existing bottom-cursor pull-down consumes the debt too, so a reclaimed push can't be pulled a second time later.
  • The pull is capped by the history surplus that actually exists (totalRows() - rows), so pruned pages can't leave the active area short.
  • Reflow clears the debt (resizeCols): rewrapped history invalidates row-for-row accounting, so those paths keep upstream behavior exactly.

The comment at this branch says the no-pull choice was arbitrary and open to change if resize behavior isn't regressed in a user-hostile way; scoping the pull-down to reversing a just-made push is intended to meet that bar — a user who shrank and re-grew gets their screen back, and no one else sees anything new.

Edge cases considered

Sequence Behavior
shrink, grow (the bug) pushed rows come back; totalRows stable
shrink, shrink, grow beyond both all pushed rows return, remainder are fresh blanks
shrink, output scrolls, grow debt cleared; blanks appended (upstream behavior, pinned by test)
shrink, bottom-cursor grow, grow again first grow consumes debt; second appends blanks (pinned by test)
shrink, reflow (cols change), grow debt cleared at reflow; upstream behavior
history pruned between shrink and grow pull capped by real surplus
cols+rows in one resize call cols processed first; row logic sees final cols, debt unaffected

Tests, red first

Each behavioral piece was watched failing before its fix:

  • height cycle with cursor above bottom is symmetric — failed on unmodified code at 13 rows vs 10 on the first cycle.
  • bottom-cursor grow consumes the debt — failed against the first version of this change as a totalRows() >= rows assertion panic; the consumption in the pull-down path is what fixes it.
  • grow after content scroll keeps scrollback and multiple shrinks accumulate… pin the preserved behaviors and pass on both sides by design.

zig build test -Dtest-filter=PageList: 350/350. Full suite: 3328/3344 passed, 16 skipped, none failed.

Verified in a real window

The unit tests drive PageList directly, so they cannot show that a person dragging a window edge is fixed. A host app embedding this branch was built twice, once at this commit and once at its parent, and both were driven through the same script: fill a full screen with 400 numbered lines under tmux with the alternate screen off, park the cursor above the bottom row, then drag the window's bottom edge through 8 shrink/grow cycles and count the lines in scrollback.

Build Lines after 8 cycles
parent commit 437 (37 leaked, 30 pushed off the top and lost)
this branch 402

Both numbers repeated exactly across two runs. The script also checks that the drag reaches the terminal at all — a window resize that changes no rows would make either build look clean — and confirms 31 rows at the short height against 49 at the tall one before it grades anything.

Summary by CodeRabbit

  • Bug Fixes
    • Improved terminal resizing so shrinking and expanding the window preserves previously visible content.
    • Restored trimmed rows when space becomes available again, reducing unexpected blank lines or lost display history.
    • Improved cursor recovery and viewport positioning after repeated resize operations.
    • Fixed scrolling behavior when resizing near the bottom of terminal content.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Resize row debt recovery

Layer / File(s) Summary
Resize debt state and helpers
src/terminal/PageList.zig
PageList tracks resize-row debt, clears it during column reflow and content scrolling, and uses shared viewport normalization helpers.
Shrink and grow recovery
src/terminal/PageList.zig
No-reflow height shrink records untrimmed rows. Height growth restores recorded rows before adding blank rows or pulling down existing content.
Resize recovery regression tests
src/terminal/PageList.zig
Tests cover repeated resize cycles, bottom-cursor recovery, accumulated debt, excess blank-row growth, and preservation of real scrollback.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: mitchellh, lawrencecchen

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: restoring pushed rows after a reversed height shrink and grow cycle.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ejc3
ejc3 force-pushed the resize-debt-scrollback branch 2 times, most recently from 2f3ca73 to 29dec42 Compare August 7, 2026 15:28
@ejc3

ejc3 commented Aug 7, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (11aa609); the four tests updated to the new Cell codepoint shape, and the newer pin's larger PageList suite is 350/350.

One contract note for review, after an adversarial pass raised it: the carve-out pull moves the active-area origin without touching pins, so a caller tracking the cursor by raw (x, y) across the resize would write at a stale row. The real caller doesn't: Screen.resize hands its cursor's tracked pin into the resize and cursorReload() re-derives coordinates from that pin afterwards, which is the same contract the existing bottom-cursor pull-down already relies on. Direct PageList.resize consumers without pin reconciliation had this obligation before this change.

ejc3 added a commit to ejc3/cmux that referenced this pull request Aug 7, 2026
…reverses

A no-reflow height shrink pushes the viewport's top rows into history.
The matching grow declines to pull them back when the cursor sits above
the bottom row (the intentional no-yank UX), so a full-screen TUI that
parks its cursor in an input box -- with a multiplexer above it that
fully repaints after every resize -- leaks one viewport-top of duplicated
rows into scrollback per resize cycle. Interactive window drags fire
many such cycles, and the duplicates read as corrupted scrollback right
above the screen.

Track the rows a shrink pushes as a debt, and let the next grow pull at
most that many rows back down before appending blank rows. Any content
scroll clears the debt, so history that has become real is never yanked;
ordinary grows with no preceding shrink behave exactly as before. One
test walks three shrink/grow cycles and asserts totalRows() is stable
(it failed at 13 vs 10 before the fix); a second pins the existing
no-pull behavior when output intervenes.
@ejc3
ejc3 force-pushed the resize-debt-scrollback branch from 29dec42 to 2d5b4f7 Compare August 7, 2026 15:50
@ejc3
ejc3 marked this pull request as ready for review August 7, 2026 19:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/terminal/PageList.zig (2)

2361-2380: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider migrating the remaining duplicate viewport-pin checks to viewportPinToActive().

viewportPinToActive() correctly factors out the repeated switch (self.viewport) { .pin => if (self.pinIsActive(...)) ... } pattern, and it is used at line 2547. The identical inline pattern still exists, unconverted, in resize() (around lines 1326-1331) and resizeCols() (around lines 1494-1499). Migrating those call sites to the new helper would complete the deduplication this change already started.

This is not required for correctness in this PR; it is a follow-up consistency improvement now that the helper exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/terminal/PageList.zig` around lines 2361 - 2380, Replace the duplicated
viewport-pin switch checks in resize() and resizeCols() with calls to
viewportPinToActive(). Preserve the existing call in the nearby flow and keep
the helper’s behavior unchanged.

13054-13185: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage of the core symmetric/accumulation/content-scroll scenarios.

These four tests correctly exercise the primary contract (symmetric shrink/grow, bottom-cursor consumption, accumulated debt, and content-scroll clearing), and all match the traced implementation behavior.

One documented edge case is not directly exercised here: resizeWithoutReflow's comment at line ~2497 notes that "pruning can leave the debt stale," referring to a scenario where an operation like eraseHistory reduces real surplus below the outstanding resize_row_debt without clearing it (since eraseHistory does not call grow()). A regression test asserting that a subsequent grow only pulls the reduced, capped amount (not the stale debt value) would lock in this explicitly-called-out safety property.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/terminal/PageList.zig` around lines 13054 - 13185, Extend the PageList
resize tests with an eraseHistory scenario that leaves resize_row_debt larger
than the remaining history surplus, then grow the viewport and assert only the
reduced, capped surplus is pulled back rather than the stale debt amount. Anchor
the setup and assertions in resizeWithoutReflow, eraseHistory, and the existing
resize growth path, preserving the current debt and content-scroll scenarios.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/terminal/PageList.zig`:
- Around line 414-423: Update reset() to explicitly set resize_row_debt to zero
alongside the other mutable page-list state it clears, ensuring a reset leaves
no stale resize debt while preserving the existing reset behavior.

---

Nitpick comments:
In `@src/terminal/PageList.zig`:
- Around line 2361-2380: Replace the duplicated viewport-pin switch checks in
resize() and resizeCols() with calls to viewportPinToActive(). Preserve the
existing call in the nearby flow and keep the helper’s behavior unchanged.
- Around line 13054-13185: Extend the PageList resize tests with an eraseHistory
scenario that leaves resize_row_debt larger than the remaining history surplus,
then grow the viewport and assert only the reduced, capped surplus is pulled
back rather than the stale debt amount. Anchor the setup and assertions in
resizeWithoutReflow, eraseHistory, and the existing resize growth path,
preserving the current debt and content-scroll scenarios.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c50ebb2-271c-44fb-93b2-b487490a8e8c

📥 Commits

Reviewing files that changed from the base of the PR and between c0de068 and 2d5b4f7.

📒 Files selected for processing (1)
  • src/terminal/PageList.zig

Comment thread src/terminal/PageList.zig
Comment on lines +414 to +423
/// Rows that a no-reflow height shrink pushed into history and that the
/// next height grow may pull back down, so a shrink/grow cycle is
/// symmetric. Without this, an application that keeps its cursor above
/// the bottom row (a full-screen TUI on the primary screen) leaks one
/// viewport-top of duplicated rows into history on every resize cycle,
/// because the grow path deliberately refuses to pull down scrollback.
/// Any content scroll (grow()) clears it: after new output, history
/// above the screen is real again and pulling it down would be wrong.
resize_row_debt: usize = 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Clear resize_row_debt in reset() for invariant consistency.

reset() rebuilds the page list from scratch and explicitly clears almost every other piece of mutable state (viewport, tracked pins, total_rows, page_serial_epoch), but it does not clear resize_row_debt. After reset(), the field can hold a stale, pre-reset value.

This is currently harmless: every consumer of the debt (takeRowDebt) always caps the amount taken by the freshly computed surplus = self.total_rows - self.rows at the time of use, so a stale value can never cause an over-pull. But it leaves an inconsistent invariant: reset() is documented and designed to bring the list to a pristine state, and this field silently escapes that contract. A future change to the surplus-capping logic could turn this into a real bug.

🧹 Proposed fix
     // Move our viewport back to the active area since everything is gone.
     self.viewport = .active;
+
+    // Any pending resize-row debt is meaningless once the whole list is
+    // rebuilt from scratch.
+    self.resize_row_debt = 0;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/terminal/PageList.zig` around lines 414 - 423, Update reset() to
explicitly set resize_row_debt to zero alongside the other mutable page-list
state it clears, ensuring a reset leaves no stale resize debt while preserving
the existing reset behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant