Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ extension MobileShellComposite {
MobileTerminalOutputChunk(
data: immediate.bytes,
streamToken: streamToken,
viewportPolicy: immediate.viewportPolicy
viewportPolicy: immediate.viewportPolicy,
isFullReplacement: immediate.isFullReplacement
)
)
}
Expand Down Expand Up @@ -159,7 +160,8 @@ extension MobileShellComposite {
continuation.yield(MobileTerminalOutputChunk(
data: next.bytes,
streamToken: streamToken,
viewportPolicy: next.viewportPolicy
viewportPolicy: next.viewportPolicy,
isFullReplacement: next.isFullReplacement
))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@ private let terminalScrollDeliveryLog = Logger(
)

extension MobileShellComposite {
/// Forward a scroll gesture to the Mac's real surface. libghostty does the
/// mode-correct thing: normal screen moves the viewport into scrollback;
/// alt screen + mouse reporting encodes mouse-wheel to the PTY for the
/// program. The render-grid mirrors the result (it exports the live
/// `vp_top`).
/// Route a scroll gesture by active screen.
///
/// Primary screen: the phone's local Ghostty mirror owns the viewport. The
/// Mac's real viewport is never scrolled from here; render-grid exports
/// follow the Mac's live `vp_top`, so scrolling it would repaint the
/// phone's grid with the Mac's scrolled viewport while the mirror is also
/// scrolled locally (the same gesture applied twice, drifting apart). The
/// RPC is used only to fetch scrollback history windows (`delta_lines = 0`,
/// which the Mac treats as a no-op scroll), and the mirror rebuilds from
/// the response while preserving its own scroll position.
///
/// Alternate screen: libghostty needs the wheel on the real PTY
/// (vim/less/htop mouse reporting), so deltas are forwarded; the
/// display-only mirror drops its own wheel bytes and the Mac's render-grid
/// response is the visible update.
///
/// Fire-and-forget and single-flight per surface. Native iOS scrolling can
/// continue through deceleration after the finger lifts; while one RPC is
Expand All @@ -21,19 +31,21 @@ extension MobileShellComposite {
public func scrollTerminal(surfaceID: String, lines: Double, col: Int, row: Int) async {
var prefetchState = terminalScrollbackPrefetchStatesBySurfaceID[surfaceID]
?? TerminalScrollbackPrefetchState()
let maxScrollbackRows = prefetchState.rowsToPrefetch(forScrollLines: lines)
terminalScrollbackPrefetchStatesBySurfaceID[surfaceID] = prefetchState
enqueueTerminalScroll(TerminalScrollDelivery(
let delivery = TerminalScrollDelivery.forScrollGesture(
surfaceID: surfaceID,
activeScreen: terminalActiveScreenBySurfaceID[surfaceID],
lines: lines,
col: col,
row: row,
maxScrollbackRows: maxScrollbackRows
))
prefetchState: &prefetchState
)
terminalScrollbackPrefetchStatesBySurfaceID[surfaceID] = prefetchState
guard let delivery else { return }
enqueueTerminalScroll(delivery)
}

private func enqueueTerminalScroll(_ delivery: TerminalScrollDelivery) {
guard delivery.lines != 0 else { return }
guard delivery.lines != 0 || delivery.maxScrollbackRows != nil else { return }
let queueToken = terminalScrollQueueTokensBySurfaceID[delivery.surfaceID] ?? UUID()
terminalScrollQueueTokensBySurfaceID[delivery.surfaceID] = queueToken
var queue = terminalScrollQueuesBySurfaceID[delivery.surfaceID] ?? TerminalScrollDeliveryQueue()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private var reportedViewportSizesByTerminalKey: [MobileTerminalViewportKey: MobileTerminalViewportSize]
var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64]
var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64]
private var terminalActiveScreenBySurfaceID: [String: MobileTerminalRenderGridFrame.Screen]
var terminalActiveScreenBySurfaceID: [String: MobileTerminalRenderGridFrame.Screen]
var terminalReplaySurfaceIDsInFlight: Set<String>
private var terminalReplayRequestIDsInFlightBySurfaceID: [String: UUID]
private var terminalReplayTasksBySurfaceID: [String: Task<Void, Never>]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ struct TerminalOutputDelivery: Equatable, Sendable {
frame.vtPatchBytes()
}
}

/// True when the payload replays a full render-grid snapshot, whose bytes
/// begin with an `ESC c` terminal reset. The apply side preserves the local
/// viewport scroll position across these.
var isFullReplacement: Bool {
switch payload {
case .bytes:
false
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve offsets for snapshot replay fallbacks

When mobile.terminal.replay falls back to snapshot_data_b64 because a render-grid frame is unavailable or fails to decode, MobileShellComposite wraps those bytes with terminalSnapshotReplacementBytes, which prepends ESC c. This new flag still reports .bytes deliveries as not full replacements, so the consumer takes the plain processOutputAndWait path 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 through TerminalOutputDelivery.

Useful? React with 👍 / 👎.

case .renderGrid(let frame):
frame.full
}
}
}

/// Backpressure queue for one mounted mobile terminal output stream.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CMUXMobileCore
import Foundation

struct TerminalScrollDelivery: Equatable, Sendable {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scroll coalescing mixes screen modes

Medium Severity

Single-flight scroll coalescing sums lines on every pending delivery. Primary prefetch RPCs now use delta_lines = 0 with maxScrollbackRows, so a pending alternate-screen wheel delta merges into that packet and the Mac receives a non-zero delta_lines after the user is back on the primary screen—violating phone-owned primary scrolling.

Additional Locations (1)
Fix in Cursor Fix in Web

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? {
Expand All @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift, line 56:

<comment>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.</comment>

<file context>
@@ -45,12 +50,50 @@ struct TerminalScrollbackPrefetchState: Equatable, Sendable {
+        // 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 {
+            windowRows = min(windowRows + Self.defaultWindowRows, maxWindowRows)
+        }
</file context>

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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,86 @@ import Testing
#expect(state.rowsToPrefetch(forScrollLines: 1) == 600)
#expect(state.rowsToPrefetch(forScrollLines: 4) == nil)
#expect(state.rowsToPrefetch(forScrollLines: -5.5) == nil)
#expect(state.rowsToPrefetch(forScrollLines: 0.5) == 600)
// Downward refresh keeps the current depth instead of paging deeper.
#expect(state.rowsToPrefetch(forScrollLines: -0.5) == 600)
}

@Test func terminalScrollbackPrefetchStatePagesDeeperOnSustainedUpwardScroll() {
var state = TerminalScrollbackPrefetchState(
windowRows: 600,
refreshDistanceRows: 10,
maxWindowRows: 1800
)

#expect(state.rowsToPrefetch(forScrollLines: 1) == 600)
#expect(state.rowsToPrefetch(forScrollLines: 10) == 1200)
#expect(state.rowsToPrefetch(forScrollLines: 10) == 1800)
// Capped at maxWindowRows.
#expect(state.rowsToPrefetch(forScrollLines: 10) == 1800)
}

@Test func terminalScrollGestureRoutingForwardsAlternateScreenDeltasUnchanged() {
var prefetchState = TerminalScrollbackPrefetchState(windowRows: 600, refreshDistanceRows: 10)

let delivery = TerminalScrollDelivery.forScrollGesture(
surfaceID: "surface",
activeScreen: .alternate,
lines: -3.5,
col: 7,
row: 9,
prefetchState: &prefetchState
)

#expect(delivery == TerminalScrollDelivery(surfaceID: "surface", lines: -3.5, col: 7, row: 9))
// Alternate screen never touches the primary-screen prefetch pacing.
#expect(prefetchState.rowsToPrefetch(forScrollLines: 1) == 600)
}

@Test func terminalScrollGestureRoutingKeepsLegacyForwardingWhileScreenUnknown() {
var prefetchState = TerminalScrollbackPrefetchState(windowRows: 600, refreshDistanceRows: 10)

let delivery = TerminalScrollDelivery.forScrollGesture(
surfaceID: "surface",
activeScreen: nil,
lines: 2,
col: 1,
row: 1,
prefetchState: &prefetchState
)

// No render grid has reported the screen mode (cold attach, or a legacy
// raw-bytes host that never sends one): forward the delta so alt-screen
// TUIs never lose the wheel, and still prime the local window.
#expect(delivery?.lines == 2)
#expect(delivery?.maxScrollbackRows == 600)
}

@Test func terminalScrollGestureRoutingKeepsPrimaryScreenDeltasLocal() {
var prefetchState = TerminalScrollbackPrefetchState(windowRows: 600, refreshDistanceRows: 10)

let primed = TerminalScrollDelivery.forScrollGesture(
surfaceID: "surface",
activeScreen: .primary,
lines: 2,
col: 1,
row: 1,
prefetchState: &prefetchState
)
// The first scroll primes the local scrollback window but sends no delta:
// the Mac's viewport must not move for a phone-local scroll.
#expect(primed?.lines == 0)
#expect(primed?.maxScrollbackRows == 600)

let followUp = TerminalScrollDelivery.forScrollGesture(
surfaceID: "surface",
activeScreen: .primary,
lines: 3,
col: 1,
row: 1,
prefetchState: &prefetchState
)
// Below the refresh distance there is nothing to send at all.
#expect(followUp == nil)
}

@Test func terminalScrollQueueResetDropsPendingWork() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,22 @@ public struct MobileTerminalOutputChunk: Sendable {
public let data: Data
public let streamToken: UUID
public let viewportPolicy: MobileTerminalOutputViewportPolicy?
/// True when `data` replays a full render-grid snapshot (`ESC c` reset +
/// scrollback + viewport repaint). The consuming surface must preserve its
/// local scrollback scroll position across the apply so an authoritative
/// rebuild never moves the viewport the user is holding.
public let isFullReplacement: Bool

public init(
data: Data,
streamToken: UUID,
viewportPolicy: MobileTerminalOutputViewportPolicy? = nil
viewportPolicy: MobileTerminalOutputViewportPolicy? = nil,
isFullReplacement: Bool = false
) {
self.data = data
self.streamToken = streamToken
self.viewportPolicy = viewportPolicy
self.isFullReplacement = isFullReplacement
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Alt-screen full replay scroll restore

Medium Severity

Every output chunk with isFullReplacement goes through processFullReplacementOutputAndWait, which reapplies the mirror’s cached scrollback offset after an ESC c rebuild. Alternate-screen full snapshots use remoteGrid and Mac-owned viewport semantics; reusing the primary scroll offset there can call scroll_page_lines on the TUI buffer after entry or resync, misaligning the visible grid.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5912408. Configure here.

guard applied else {
store.terminalOutputDidReset(
surfaceID: surfaceID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,8 @@ extension GhosttySurfaceView {
}
}

@MainActor
func recordBottomScrollStressScrollbar(total: Int, offset: Int, len: Int) {
debugLastScrollbar = (total: total, offset: offset, len: len)
}

var bottomScrollDebugScrollbarAtBottom: Bool {
guard let snapshot = debugLastScrollbar else { return false }
guard let snapshot = lastScrollbarSnapshot else { return false }
return snapshot.total > snapshot.len && snapshot.offset >= max(0, snapshot.total - snapshot.len - 1)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,18 +383,18 @@ public final class GhosttyRuntime {
return true
}

#if DEBUG
if action.tag == GHOSTTY_ACTION_SCROLLBAR {
let sb = action.action.scrollbar
#if DEBUG
MobileDebugLog.anchormux("scroll.bar total=\(sb.total) offset=\(sb.offset) len=\(sb.len)")
#endif
if target.tag == GHOSTTY_TARGET_SURFACE, let surface = target.target.surface {
Task { @MainActor in
GhosttySurfaceView.view(for: surface)?.recordBottomScrollStressScrollbar(total: Int(sb.total), offset: Int(sb.offset), len: Int(sb.len))
GhosttySurfaceView.view(for: surface)?.recordScrollbarSnapshot(total: Int(sb.total), offset: Int(sb.offset), len: Int(sb.len))
}
}
return true
}
#endif

return false
}
Expand Down
Loading