-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Make iOS terminal scrolling phone-owned (fix optimistic scroll jumps) #7197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1455fe5
75acd90
0506c05
5912408
142dc3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import CMUXMobileCore | ||
| import Foundation | ||
|
|
||
| struct TerminalScrollDelivery: Equatable, Sendable { | ||
|
|
@@ -25,18 +26,22 @@ struct TerminalScrollDelivery: Equatable, Sendable { | |
| struct TerminalScrollbackPrefetchState: Equatable, Sendable { | ||
| static let defaultWindowRows = 600 | ||
| static let defaultRefreshDistanceRows = 120.0 | ||
| static let defaultMaxWindowRows = 4800 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scroll coalescing mixes screen modesMedium Severity Single-flight scroll coalescing sums Additional Locations (1)Reviewed by Cursor Bugbot for commit 0506c05. Configure here. |
||
|
|
||
| var windowRows: Int | ||
| var refreshDistanceRows: Double | ||
| var maxWindowRows: Int | ||
| private var hasPrimedWindow = false | ||
| private var accumulatedRowsSincePrefetch = 0.0 | ||
|
|
||
| init( | ||
| windowRows: Int = Self.defaultWindowRows, | ||
| refreshDistanceRows: Double = Self.defaultRefreshDistanceRows | ||
| refreshDistanceRows: Double = Self.defaultRefreshDistanceRows, | ||
| maxWindowRows: Int = Self.defaultMaxWindowRows | ||
| ) { | ||
| self.windowRows = max(0, windowRows) | ||
| self.refreshDistanceRows = max(1, refreshDistanceRows) | ||
| self.maxWindowRows = max(self.windowRows, maxWindowRows) | ||
| } | ||
|
|
||
| mutating func rowsToPrefetch(forScrollLines lines: Double) -> Int? { | ||
|
|
@@ -45,12 +50,68 @@ struct TerminalScrollbackPrefetchState: Equatable, Sendable { | |
| guard !hasPrimedWindow || accumulatedRowsSincePrefetch >= refreshDistanceRows else { | ||
| return nil | ||
| } | ||
| // Sustained scrolling into history pages the window deeper so the | ||
| // local mirror can keep going past the initial window; scrolling back | ||
| // toward the bottom refreshes at the current depth instead. | ||
| if hasPrimedWindow, lines > 0 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Scrollback window can grow during mixed-direction scrolling, not just sustained upward history scrolling. The new deepening check keys off only the last positive delta while the threshold uses absolute movement from both directions, so a tiny upward tick after mostly downward movement can still page deeper. Prompt for AI agents |
||
| windowRows = min(windowRows + Self.defaultWindowRows, maxWindowRows) | ||
| } | ||
| hasPrimedWindow = true | ||
| accumulatedRowsSincePrefetch = 0 | ||
| return windowRows | ||
| } | ||
| } | ||
|
|
||
| extension TerminalScrollDelivery { | ||
| /// Pure routing decision for a phone scroll gesture; nil means nothing is | ||
| /// sent to the Mac. | ||
| /// | ||
| /// Primary screen: the phone's local Ghostty mirror owns the viewport, so | ||
| /// no scroll delta is ever sent to the Mac; the only RPC is a | ||
| /// `delta_lines = 0` scrollback-window fetch when the prefetch state says | ||
| /// the local history needs (re)priming or deepening. Alternate screen: the | ||
| /// wheel must reach the real PTY, so the delta is forwarded unchanged. | ||
| /// | ||
| /// Unknown screen (`nil`): no render grid has reported the mode yet, which | ||
| /// covers the first moments after attach and, permanently, legacy raw-byte | ||
| /// hosts that never send render grids. Route the legacy way (forward the | ||
| /// delta AND request prefetch) so an alternate-screen TUI never loses the | ||
| /// wheel; once a grid arrives the mode is known and phone-owned routing | ||
| /// takes over. | ||
| static func forScrollGesture( | ||
| surfaceID: String, | ||
| activeScreen: MobileTerminalRenderGridFrame.Screen?, | ||
| lines: Double, | ||
| col: Int, | ||
| row: Int, | ||
| prefetchState: inout TerminalScrollbackPrefetchState | ||
| ) -> TerminalScrollDelivery? { | ||
| switch activeScreen { | ||
| case .alternate: | ||
| return TerminalScrollDelivery(surfaceID: surfaceID, lines: lines, col: col, row: row) | ||
| case nil: | ||
| return TerminalScrollDelivery( | ||
| surfaceID: surfaceID, | ||
| lines: lines, | ||
| col: col, | ||
| row: row, | ||
| maxScrollbackRows: prefetchState.rowsToPrefetch(forScrollLines: lines) | ||
| ) | ||
| case .primary: | ||
| guard let maxScrollbackRows = prefetchState.rowsToPrefetch(forScrollLines: lines) else { | ||
| return nil | ||
| } | ||
| return TerminalScrollDelivery( | ||
| surfaceID: surfaceID, | ||
| lines: 0, | ||
| col: col, | ||
| row: row, | ||
| maxScrollbackRows: maxScrollbackRows | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| struct TerminalScrollDeliveryQueue: Sendable { | ||
| private var inFlight = false | ||
| private var pending: TerminalScrollDelivery? | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,7 +176,9 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { | |
| break | ||
| } | ||
| if !chunk.data.isEmpty { | ||
| let applied = await surfaceView.processOutputAndWait(chunk.data) | ||
| let applied = chunk.isFullReplacement | ||
| ? await surfaceView.processFullReplacementOutputAndWait(chunk.data) | ||
| : await surfaceView.processOutputAndWait(chunk.data) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alt-screen full replay scroll restoreMedium Severity Every output chunk with Reviewed by Cursor Bugbot for commit 5912408. Configure here. |
||
| guard applied else { | ||
| store.terminalOutputDidReset( | ||
| surfaceID: surfaceID, | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
mobile.terminal.replayfalls back tosnapshot_data_b64because a render-grid frame is unavailable or fails to decode,MobileShellCompositewraps those bytes withterminalSnapshotReplacementBytes, which prependsESC c. This new flag still reports.bytesdeliveries as not full replacements, so the consumer takes the plainprocessOutputAndWaitpath and the reset snaps the local mirror back to the bottom instead of restoring the phone-owned scrollback offset. Please mark the snapshot-byte replay path as full replacement too, or carry an explicit replacement kind throughTerminalOutputDelivery.Useful? React with 👍 / 👎.