Skip to content

Fix terminal scrollback resets during output and resize#2506

Open
austinywang wants to merge 2 commits intomainfrom
issue-2504-scroll-position-crash
Open

Fix terminal scrollback resets during output and resize#2506
austinywang wants to merge 2 commits intomainfrom
issue-2504-scroll-position-crash

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Apr 1, 2026

Summary

  • add regression coverage for streamed output and resize scrollbar updates while reviewing scrollback
  • keep following non-bottom scrollbar updates so manual scrollback stays pinned during output growth and resize
  • continue suppressing passive bottom packets unless the scroll movement came from an explicit wheel action

Fixes #2504


Summary by cubic

Preserves manual scrollback during streaming output and window resize so the same rows stay visible and the viewport doesn’t snap to bottom. Only auto-scrolls to bottom on explicit wheel/scrollbar sync or when already at bottom; fixes #2504.

  • Bug Fixes
    • Honor non-bottom scrollbar updates while reviewing scrollback to keep the viewport pinned during output growth and resize.
    • Continue suppressing passive bottom updates; allow snap only after an explicit wheel action.
    • Added regression tests for streaming output and resize scenarios.

Written for commit 7065b66. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved scrollback viewport stability when new content streams to the terminal, preserving manually scrolled positions.
    • Fixed scroll position restoration behavior during terminal resize operations.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 1, 2026 9:33pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 1, 2026

📝 Walkthrough

Walkthrough

This PR refactors scroll-position auto-scroll logic in GhosttyTerminalView to prevent unwanted crashes to bottom during agent output and terminal resize. It introduces a scrollbarIsAtBottom() helper, updates auto-scroll conditions, and adds tests validating scroll stability during streaming output and resize operations.

Changes

Cohort / File(s) Summary
Scroll Logic Refactoring
Sources/GhosttyTerminalView.swift
Removed explicit "at bottom" computation and replaced auto-scroll eligibility with a new shouldAutoScroll condition that honors explicit scrollbar sync or continues auto-scrolling while userScrolledAwayFromBottom is false. Added scrollbarIsAtBottom(_:) helper and updated bottom detection in pendingExplicitWheelScroll handling.
Scroll Position Stability Tests
cmuxTests/TerminalAndGhosttySurfaceOverlayTests.swift
Added two new test methods (testStreamingOutputPreservesManualScrollbackPosition, testResizeScrollbarUpdatePreservesManualScrollbackPosition) to validate that scroll position remains stable when scrollbar state updates occur during streaming output and terminal resize.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A hop, a scroll, a rabbit's delight,
No more crashes when output streams bright!
The viewport now holds where fingers decree,
Through resize and update, scroll stays free! 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing terminal scrollback position resets that occur during output streaming and terminal resize, which directly corresponds to the primary objectives.
Description check ✅ Passed The description covers the main points but lacks detail in Testing and Demo Video sections as specified by the template; however, it adequately communicates the changes and includes linked issue reference.
Linked Issues check ✅ Passed The PR changes directly address issue #2504 objectives: preventing scroll jumps during agent output by honoring non-bottom scrollbar updates, preventing resize-triggered resets, and preserving manual scrollback position with regression tests.
Out of Scope Changes check ✅ Passed All changes are directly in-scope: logic modifications to scrolling behavior in GhosttyTerminalView.swift and regression test additions in test files, all aligned with fixing #2504 scrollback preservation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2504-scroll-position-crash

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 and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 1, 2026

Greptile Summary

This PR fixes two related scrollback-reset bugs (#2504): when streaming output grew the terminal history and when a resize changed the viewport height, the synchronizeScrollView path would snap the user back toward the bottom even while they were actively reviewing scrollback. The fix replaces the old pixel-level bottom check (which silently cleared userScrolledAwayFromBottom on every passive update) with a scrollbar-model predicate (scrollbarIsAtBottom) evaluated against the incoming packet. The new shouldAutoScroll short-circuits on the third arm — !scrollbarIsAtBottom(scrollbar) — to let non-bottom packets through, keeping the same visible rows pinned as the document grows. Passive bottom-snapping packets remain suppressed. Two regression tests covering the streaming-output and resize paths are added following the prescribed two-commit test-first policy.

Key changes:

  • synchronizeScrollView: Drops the pixel-distance bottom check that was mutating userScrolledAwayFromBottom as a side-effect; replaces it with the three-way allowExplicitScrollbarSync || !userScrolledAwayFromBottom || !scrollbarIsAtBottom(scrollbar) predicate.
  • handleScrollbarUpdate: Simplified to use the new scrollbarIsAtBottom helper (no semantic change).
  • scrollbarIsAtBottom: New private helper that tests whether offset + len >= total from the scrollbar model (first guard offset >= total is mathematically redundant — see inline comment).
  • Tests: testStreamingOutputPreservesManualScrollbackPosition and testResizeScrollbarUpdatePreservesManualScrollbackPosition both confirm the viewport stays at the user's chosen scrollback row after document growth or resize.

Confidence Score: 5/5

  • Safe to merge — the logic is correct, tests pass the two-commit policy, and the only finding is a minor redundant guard.
  • All findings are P2 style suggestions. The scrollbar-model logic is sound, the two new regression tests correctly validate both fixed scenarios, and the regression test commit policy from CLAUDE.md is properly followed (test-first, fix-second). No data loss, crash, or behavioral regression risk identified.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Replaces viewport-pixel bottom detection with scrollbar-model bottom detection; new scrollbarIsAtBottom helper; shouldAutoScroll now allows non-bottom packets through even when userScrolledAwayFromBottom is set; removes side-effect mutation of userScrolledAwayFromBottom from synchronizeScrollView.
cmuxTests/TerminalAndGhosttyTests.swift Adds two new regression tests — streaming output and resize — that verify the viewport stays pinned at the user's scrollback position rather than drifting toward the bottom during document growth.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[scrollbar notification arrives] --> B{pendingExplicitWheelScroll?}
    B -- yes --> C[userScrolledAwayFromBottom = !scrollbarIsAtBottom\nallowExplicitScrollbarSync = true\npendingExplicitWheelScroll = false]
    C --> D[surfaceView.scrollbar = scrollbar]
    B -- no --> D
    D --> E[synchronizeScrollView]

    E --> F{isLiveScrolling?}
    F -- yes --> G[skip scroll sync]
    F -- no --> H{cellHeight > 0 && scrollbar?}
    H -- no --> G
    H -- yes --> I[compute targetOrigin from scrollbar model]

    I --> J{shouldAutoScroll?}
    J --> K{allowExplicitScrollbarSync?}
    K -- yes --> L[auto-scroll to targetOrigin]
    K -- no --> M{!userScrolledAwayFromBottom?}
    M -- yes --> L
    M -- no --> N{!scrollbarIsAtBottom?}
    N -- yes → non-bottom packet → preserve rows --> L
    N -- no → passive bottom packet → stay pinned --> O[skip scroll]

    L --> P[reflectScrolledClipView]
    O --> P
    G --> P
Loading

Reviews (1): Last reviewed commit: "fix: preserve manual scrollback during o..." | Re-trigger Greptile

Comment on lines +9890 to +9892
private func scrollbarIsAtBottom(_ scrollbar: GhosttyScrollbar) -> Bool {
scrollbar.offset >= scrollbar.total || scrollbar.len >= scrollbar.total - scrollbar.offset
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant first condition in scrollbarIsAtBottom

The guard scrollbar.offset >= scrollbar.total is always a strict subset of the second condition. When offset >= total, then total - offset <= 0, and since len is non-negative, len >= total - offset is necessarily true. The first arm can never fire without the second also being true, so it adds no coverage.

If the intent is to protect against a hypothetical unsigned-underflow scenario (e.g. if these fields were C unsigned integers), a comment explaining that would help. Otherwise consider simplifying to the single condition:

Suggested change
private func scrollbarIsAtBottom(_ scrollbar: GhosttyScrollbar) -> Bool {
scrollbar.offset >= scrollbar.total || scrollbar.len >= scrollbar.total - scrollbar.offset
}
private func scrollbarIsAtBottom(_ scrollbar: GhosttyScrollbar) -> Bool {
scrollbar.len >= scrollbar.total - scrollbar.offset
}

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 9813-9819: The current shouldAutoScroll gating (using
allowExplicitScrollbarSync, userScrolledAwayFromBottom, and
scrollbarIsAtBottom(scrollbar)) suppresses packets that merely touch the bottom
even when a resize preserves the user's manual anchor; instead, change the logic
in the shouldAutoScroll calculation to compute whether the incoming packet would
change the manual anchor (e.g. compare the anchoredRow/targetOrigin before and
after applying the packet's offset/len/total) and only treat the packet as a
snap-to-bottom when it would actually move the anchor; update the code paths
around shouldAutoScroll, scrollbarIsAtBottom(scrollbar), and any resize/packet
handling to use this anchored-row comparison so near-bottom resize packets are
not dropped.
🪄 Autofix (Beta)

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

Run ID: f692f21e-1dd0-40f5-acd3-d64269cfe4f2

📥 Commits

Reviewing files that changed from the base of the PR and between e9b2090 and 7065b66.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/TerminalAndGhosttyTests.swift

Comment on lines +9813 to +9819
// While reviewing scrollback, still honor non-bottom scrollbar updates so
// streaming output and resize churn preserve the same visible rows. Only
// passive packets that would snap us back to bottom stay suppressed.
let shouldAutoScroll =
allowExplicitScrollbarSync ||
!userScrolledAwayFromBottom ||
!scrollbarIsAtBottom(scrollbar)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't suppress near-bottom resize packets.

This treats every atBottom packet as a snap-to-bottom, but resize can increase len enough that a packet now satisfies offset + len == total while still preserving the user's anchored row. Example: total=100, offset=70, len=20 should move from targetOrigin = 10 * cellHeight to 0 when len grows to 30; with the current gate, that update is dropped and the viewport shifts upward instead of staying pinned to the same scrollback. Please gate on whether the packet changes the manual anchor, not just whether it technically includes the bottom.

Possible direction
-                let shouldAutoScroll =
-                    allowExplicitScrollbarSync ||
-                    !userScrolledAwayFromBottom ||
-                    !scrollbarIsAtBottom(scrollbar)
+                let preservesCurrentAnchor =
+                    userScrolledAwayFromBottom &&
+                    lastSentRow.map(UInt64.init) == scrollbar.offset
+                let shouldAutoScroll =
+                    allowExplicitScrollbarSync ||
+                    !userScrolledAwayFromBottom ||
+                    preservesCurrentAnchor ||
+                    !scrollbarIsAtBottom(scrollbar)

Also applies to: 9867-9867, 9890-9892

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 9813 - 9819, The current
shouldAutoScroll gating (using allowExplicitScrollbarSync,
userScrolledAwayFromBottom, and scrollbarIsAtBottom(scrollbar)) suppresses
packets that merely touch the bottom even when a resize preserves the user's
manual anchor; instead, change the logic in the shouldAutoScroll calculation to
compute whether the incoming packet would change the manual anchor (e.g. compare
the anchoredRow/targetOrigin before and after applying the packet's
offset/len/total) and only treat the packet as a snap-to-bottom when it would
actually move the anchor; update the code paths around shouldAutoScroll,
scrollbarIsAtBottom(scrollbar), and any resize/packet handling to use this
anchored-row comparison so near-bottom resize packets are not dropped.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/TerminalAndGhosttyTests.swift">

<violation number="1" location="cmuxTests/TerminalAndGhosttyTests.swift:2234">
P2: New regression tests use fixed 10ms RunLoop sleeps for async scrollbar/scroll updates, which can cause flaky assertions on slower CI; use condition-based waiting instead.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

object: surfaceView,
userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)]
)
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 2026

Choose a reason for hiding this comment

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

P2: New regression tests use fixed 10ms RunLoop sleeps for async scrollbar/scroll updates, which can cause flaky assertions on slower CI; use condition-based waiting instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/TerminalAndGhosttyTests.swift, line 2234:

<comment>New regression tests use fixed 10ms RunLoop sleeps for async scrollbar/scroll updates, which can cause flaky assertions on slower CI; use condition-based waiting instead.</comment>

<file context>
@@ -2194,6 +2194,148 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
+            object: surfaceView,
+            userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)]
+        )
+        RunLoop.current.run(until: Date().addingTimeInterval(0.01))
+
+        surfaceView.nextScrollbar = makeScrollbar(total: 100, offset: 40, len: 10)
</file context>
Fix with Cubic

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.

Scroll position randomly crashes to bottom during agent output and on terminal resize

1 participant