Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
33285 CLI/cmux.swift
19197 Sources/ContentView.swift
17899 Sources/AppDelegate.swift
14629 Sources/TerminalController.swift
14610 Sources/TerminalController.swift
13572 Sources/Panels/BrowserPanel.swift
12078 Sources/GhosttyTerminalView.swift
12046 cmuxTests/AppDelegateShortcutRoutingTests.swift
Expand All @@ -21,15 +21,15 @@
5969 cmuxTests/TerminalAndGhosttyTests.swift
5500 cmuxTests/BrowserConfigTests.swift
5470 Sources/cmuxApp.swift
4922 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
4938 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
4460 Sources/Panels/FilePreviewPanel.swift
4400 cmuxTests/BrowserPanelTests.swift
4227 Sources/BrowserWindowPortal.swift
3937 Sources/Feed/FeedPanelView.swift
3895 cmuxTests/WindowAndDragTests.swift
3764 cmuxTests/TabManagerUnitTests.swift
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
3663 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3397 Sources/CmuxConfig.swift
3331 cmuxTests/TabManagerSessionSnapshotTests.swift
3202 Sources/Update/UpdateTitlebarAccessory.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,17 @@ extension MobileShellComposite {
/// in flight, newer deltas are summed into the next request instead of
/// piling up stale scroll packets.
public func scrollTerminal(surfaceID: String, lines: Double, col: Int, row: Int) async {
enqueueTerminalScroll(TerminalScrollDelivery(surfaceID: surfaceID, lines: lines, col: col, row: row))
var prefetchState = terminalScrollbackPrefetchStatesBySurfaceID[surfaceID]
?? TerminalScrollbackPrefetchState()
let maxScrollbackRows = prefetchState.rowsToPrefetch(forScrollLines: lines)
terminalScrollbackPrefetchStatesBySurfaceID[surfaceID] = prefetchState
enqueueTerminalScroll(TerminalScrollDelivery(
surfaceID: surfaceID,
lines: lines,
col: col,
row: row,
maxScrollbackRows: maxScrollbackRows
))
}

private func enqueueTerminalScroll(_ delivery: TerminalScrollDelivery) {
Expand Down Expand Up @@ -58,18 +68,37 @@ extension MobileShellComposite {
return
}
do {
var params: [String: Any] = [
"workspace_id": workspaceID.rawValue,
"surface_id": delivery.surfaceID,
"client_id": clientID,
"delta_lines": delivery.lines,
"col": delivery.col,
"row": delivery.row,
]
if let maxScrollbackRows = delivery.maxScrollbackRows {
params["max_scrollback_rows"] = maxScrollbackRows
}
let request = try MobileCoreRPCClient.requestData(
method: "mobile.terminal.scroll",
params: [
"workspace_id": workspaceID.rawValue,
"surface_id": delivery.surfaceID,
"client_id": clientID,
"delta_lines": delivery.lines,
"col": delivery.col,
"row": delivery.row,
]
params: params
)
let data = try await client.sendRequest(request)
guard let maxScrollbackRows = delivery.maxScrollbackRows,
maxScrollbackRows > 0,
remoteClient === client else {
return
}
guard let payload = try? MobileTerminalReplayResponse.decode(data),
let renderGrid = payload.renderGrid,
renderGrid.surfaceID == delivery.surfaceID else {
return
}
deliverAuthoritativeTerminalRenderGrid(
renderGrid,
expectedSurfaceID: delivery.surfaceID,
source: "scroll_prefetch"
Comment on lines +86 to +100

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C2 '`@MainActor`|func performTerminalScroll|MobileTerminalReplayResponse\.decode' \
  Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift \
  Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift

echo

rg -n -C3 'max_scrollback_rows|render_grid|prefetch|scrollback' \
  Sources/TerminalController+MobileScrollPrefetch.swift

Repository: manaflow-ai/cmux

Length of output: 22542


Move scroll-prefetch JSON decode off the main actor.

MobileTerminalReplayResponse.decode(data) executes on the main actor inside performTerminalScroll(_:) within the scroll hot path. JSON decoding is CPU-bound work that can block frame rendering and scroll gesture responsiveness; detach it to a background task to avoid hitching.

♻️ Suggested change
             let data = try await client.sendRequest(request)
             guard let maxScrollbackRows = delivery.maxScrollbackRows,
                   maxScrollbackRows > 0,
                   remoteClient === client else {
                 return
             }
-            guard let payload = try? MobileTerminalReplayResponse.decode(data),
+            let payload = await Task.detached(priority: .userInitiated) {
+                try? MobileTerminalReplayResponse.decode(data)
+            }.value
+            guard remoteClient === client,
+                  let payload,
                   let renderGrid = payload.renderGrid,
                   renderGrid.surfaceID == delivery.surfaceID else {
                 return
             }
🤖 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
`@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+TerminalScrollDelivery.swift
around lines 86 - 100, The MobileTerminalReplayResponse.decode(data) call is
executing on the main actor within the scroll hot path, blocking frame rendering
and scroll gesture responsiveness. Move this CPU-bound JSON decoding operation
to a background task by wrapping the decode call and subsequent validation logic
in a detached Task or DispatchQueue.global() context, then dispatch the
deliverAuthoritativeTerminalRenderGrid call back to the main actor if needed.
This keeps the main actor free to handle frame rendering during scrolling.

)
_ = try await client.sendRequest(request)
} catch {
terminalScrollDeliveryLog.error("scroll forward failed surface=\(delivery.surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
var terminalOutputQueuesBySurfaceID: [String: TerminalOutputDeliveryQueue]
var terminalScrollQueueTokensBySurfaceID: [String: UUID]
var terminalScrollQueuesBySurfaceID: [String: TerminalScrollDeliveryQueue]
var terminalScrollbackPrefetchStatesBySurfaceID: [String: TerminalScrollbackPrefetchState]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep new scroll-prefetch bookkeeping out of this over-budget store.

These additions further grow MobileShellComposite.swift, which is already well past the repo’s Swift file-size budget. Please move the scroll-prefetch state and authoritative render-grid delivery helper into a smaller scroll/output-focused component so this path stays isolated and testable.

As per coding guidelines, Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility.

Also applies to: 670-670, 3083-3083, 4319-4337, 4371-4371

🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
at line 543, The scroll-prefetch bookkeeping additions (including the
terminalScrollbackPrefetchStatesBySurfaceID property and related functionality)
are causing MobileShellComposite.swift to exceed the file-size budget. Extract
all scroll-prefetch state management and authoritative render-grid delivery
helpers into a new, smaller scroll or output-focused component, then remove the
corresponding code from MobileShellComposite.swift. This will restore the file
to within budget and isolate the scroll-prefetch logic for better testability
and separation of concerns.

Source: Coding guidelines

private var rawTerminalInputBuffer: MobileTerminalInputSendBuffer
private var pairingAttemptID: UUID

Expand Down Expand Up @@ -666,6 +667,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.terminalOutputQueuesBySurfaceID = [:]
self.terminalScrollQueueTokensBySurfaceID = [:]
self.terminalScrollQueuesBySurfaceID = [:]
self.terminalScrollbackPrefetchStatesBySurfaceID = [:]
self.rawTerminalInputBuffer = MobileTerminalInputSendBuffer()
self.pairingAttemptID = UUID()
}
Expand Down Expand Up @@ -3078,6 +3080,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalOutputStreamTokensBySurfaceID = terminalOutputStreamTokensBySurfaceID.mapValues { _ in UUID() }
terminalScrollQueueTokensBySurfaceID = [:]
terminalScrollQueuesBySurfaceID = [:]
terminalScrollbackPrefetchStatesBySurfaceID = [:]
terminalOutputTransport = .rawBytes
supportedHostCapabilities = []
terminalSubscriptionRefreshTask?.cancel()
Expand Down Expand Up @@ -4313,6 +4316,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

func deliverAuthoritativeTerminalRenderGrid(
_ renderGrid: MobileTerminalRenderGridFrame,
expectedSurfaceID: String? = nil,
source: String
) {
guard expectedSurfaceID == nil || renderGrid.surfaceID == expectedSurfaceID,
hasTerminalOutputSink(surfaceID: renderGrid.surfaceID) else {
return
}
if let deliveredSeq = deliveredTerminalByteEndSeqBySurfaceID[renderGrid.surfaceID],
deliveredSeq > renderGrid.stateSeq {
Comment on lines +4328 to +4329

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.

P1 The stale-frame guard uses strict >, so a render-grid frame with stateSeq equal to an already-delivered frame bypasses the guard and is delivered a second time. With this PR introducing a second delivery path (scroll prefetch response + live event), both paths can produce a frame at the same stateSeq when the Mac processes the scroll and emits the event before responding to the RPC. The guard should be >= to treat an equal seq as stale.

Suggested change
if let deliveredSeq = deliveredTerminalByteEndSeqBySurfaceID[renderGrid.surfaceID],
deliveredSeq > renderGrid.stateSeq {
if let deliveredSeq = deliveredTerminalByteEndSeqBySurfaceID[renderGrid.surfaceID],
deliveredSeq >= renderGrid.stateSeq {

MobileDebugLog.anchormux(
"sync.render_grid_stale source=\(source) surface=\(renderGrid.surfaceID) delivered=\(deliveredSeq) frame=\(renderGrid.stateSeq)"
)
return
}
markTerminalBytesDelivered(surfaceID: renderGrid.surfaceID, endSeq: renderGrid.stateSeq)
deliverTerminalRenderGrid(renderGrid, surfaceID: renderGrid.surfaceID)
}

private static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data {
var bytes = Data("\u{1B}c\u{1B}[H\u{1B}[2J\u{1B}[3J".utf8)
bytes.append(snapshotBytes)
Expand Down Expand Up @@ -4345,6 +4368,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalOutputQueuesBySurfaceID.removeValue(forKey: surfaceID)
terminalScrollQueueTokensBySurfaceID.removeValue(forKey: surfaceID)
terminalScrollQueuesBySurfaceID.removeValue(forKey: surfaceID)
terminalScrollbackPrefetchStatesBySurfaceID.removeValue(forKey: surfaceID)
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
// Tell the Mac this device is no longer viewing the surface so it stops
Expand Down Expand Up @@ -4533,18 +4557,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
hasTerminalOutputSink(surfaceID: renderGrid.surfaceID) else {
return
}
if let deliveredSeq = deliveredTerminalByteEndSeqBySurfaceID[renderGrid.surfaceID],
deliveredSeq > renderGrid.stateSeq {
MobileDebugLog.anchormux(
"sync.render_grid_stale surface=\(renderGrid.surfaceID) delivered=\(deliveredSeq) frame=\(renderGrid.stateSeq)"
)
return
}
markTerminalBytesDelivered(surfaceID: renderGrid.surfaceID, endSeq: renderGrid.stateSeq)
#if DEBUG
mobileShellLog.info("CMUX_REPLAY live render_grid surface=\(renderGrid.surfaceID, privacy: .public) full=\(renderGrid.full, privacy: .public) spans=\(renderGrid.rowSpans.count, privacy: .public) cleared=\(renderGrid.clearedRows.count, privacy: .public) seq=\(renderGrid.stateSeq, privacy: .public) hasSink=true")
#endif
deliverTerminalRenderGrid(renderGrid, surfaceID: renderGrid.surfaceID)
deliverAuthoritativeTerminalRenderGrid(renderGrid, source: "event")
Comment on lines 4560 to +4563

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 The #if DEBUG log now fires before the stale-frame check inside deliverAuthoritativeTerminalRenderGrid, so frames that are subsequently rejected as stale will still emit CMUX_REPLAY live render_grid … hasSink=true. Moving the log inside deliverAuthoritativeTerminalRenderGrid (after the staleness guard) would keep the signal accurate for debugging.

Suggested change
#if DEBUG
mobileShellLog.info("CMUX_REPLAY live render_grid surface=\(renderGrid.surfaceID, privacy: .public) full=\(renderGrid.full, privacy: .public) spans=\(renderGrid.rowSpans.count, privacy: .public) cleared=\(renderGrid.clearedRows.count, privacy: .public) seq=\(renderGrid.stateSeq, privacy: .public) hasSink=true")
#endif
deliverTerminalRenderGrid(renderGrid, surfaceID: renderGrid.surfaceID)
deliverAuthoritativeTerminalRenderGrid(renderGrid, source: "event")
deliverAuthoritativeTerminalRenderGrid(renderGrid, source: "event")

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}

private func handleNotificationDismissedEvent(_ event: MobileEventEnvelope) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,49 @@ struct TerminalScrollDelivery: Equatable, Sendable {
var lines: Double
var col: Int
var row: Int
var maxScrollbackRows: Int? = nil

mutating func append(_ delivery: TerminalScrollDelivery) {
lines += delivery.lines
col = delivery.col
row = delivery.row
switch (maxScrollbackRows, delivery.maxScrollbackRows) {
case (.some(let current), .some(let incoming)):
maxScrollbackRows = max(current, incoming)
case (nil, .some(let incoming)):
maxScrollbackRows = incoming
case (.some, nil), (nil, nil):
break
}
}
}

struct TerminalScrollbackPrefetchState: Equatable, Sendable {
static let defaultWindowRows = 600
static let defaultRefreshDistanceRows = 120.0

var windowRows: Int
var refreshDistanceRows: Double
private var hasPrimedWindow = false
private var accumulatedRowsSincePrefetch = 0.0

init(
windowRows: Int = Self.defaultWindowRows,
refreshDistanceRows: Double = Self.defaultRefreshDistanceRows
) {
self.windowRows = max(0, windowRows)
self.refreshDistanceRows = max(1, refreshDistanceRows)
}
Comment on lines +34 to +40

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 | 🟡 Minor | ⚡ Quick win

Sanitize non-finite refreshDistanceRows in initializer.

Line 39 clamps lower bound, but NaN can still propagate and make the >= refreshDistanceRows check never pass after priming. Guarding finite input avoids a silent stuck-refresh state.

💡 Proposed fix
     init(
         windowRows: Int = Self.defaultWindowRows,
         refreshDistanceRows: Double = Self.defaultRefreshDistanceRows
     ) {
         self.windowRows = max(0, windowRows)
-        self.refreshDistanceRows = max(1, refreshDistanceRows)
+        let sanitizedRefresh = refreshDistanceRows.isFinite ? refreshDistanceRows : Self.defaultRefreshDistanceRows
+        self.refreshDistanceRows = max(1, sanitizedRefresh)
     }
🤖 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
`@Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift`
around lines 34 - 40, The initializer in TerminalScrollDelivery is clamping
refreshDistanceRows with max(1, refreshDistanceRows) on line 39, but this does
not guard against non-finite values like NaN or infinity. When NaN is passed,
max(1, NaN) still returns NaN, which causes the >= refreshDistanceRows
comparison check to always fail, leaving the refresh mechanism silently stuck.
Add a check to ensure refreshDistanceRows is finite (not NaN and not infinite)
before assigning it to self.refreshDistanceRows, either by rejecting invalid
input with a guard condition or by providing a safe default fallback value when
non-finite input is detected.


mutating func rowsToPrefetch(forScrollLines lines: Double) -> Int? {
guard lines != 0, windowRows > 0 else { return nil }
accumulatedRowsSincePrefetch += abs(lines)
guard !hasPrimedWindow || accumulatedRowsSincePrefetch >= refreshDistanceRows else {
return nil
}
hasPrimedWindow = true
accumulatedRowsSincePrefetch = 0
return windowRows
Comment on lines +42 to +50

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 | ⚡ Quick win

Preserve overflow distance after each prefetch trigger.

On Line 49, resetting accumulatedRowsSincePrefetch to 0 drops extra traveled distance above refreshDistanceRows. With coalesced/large deltas, this under-fires refreshes and drifts below the intended “about every N rows” cadence.

💡 Proposed fix
     mutating func rowsToPrefetch(forScrollLines lines: Double) -> Int? {
         guard lines != 0, windowRows > 0 else { return nil }
         accumulatedRowsSincePrefetch += abs(lines)
         guard !hasPrimedWindow || accumulatedRowsSincePrefetch >= refreshDistanceRows else {
             return nil
         }
+        let wasPrimed = hasPrimedWindow
         hasPrimedWindow = true
-        accumulatedRowsSincePrefetch = 0
+        accumulatedRowsSincePrefetch = wasPrimed
+            ? accumulatedRowsSincePrefetch.truncatingRemainder(dividingBy: refreshDistanceRows)
+            : 0
         return windowRows
     }
🤖 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
`@Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift`
around lines 42 - 50, In the rowsToPrefetch method, when
accumulatedRowsSincePrefetch reaches or exceeds refreshDistanceRows and triggers
a prefetch, resetting accumulatedRowsSincePrefetch to 0 on Line 49 discards the
overflow distance. Instead of setting accumulatedRowsSincePrefetch to 0, set it
to the overflow amount by subtracting refreshDistanceRows from the current
accumulatedRowsSincePrefetch value. This preserves the extra traveled distance
beyond the threshold, ensuring consistent refresh timing across large scroll
deltas.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,46 @@ import Testing
#expect(queue.isIdle)
}

@Test func terminalScrollQueueCoalescesLargestScrollbackPrefetchWindow() throws {
var queue = TerminalScrollDeliveryQueue()
let inFlight = TerminalScrollDelivery(surfaceID: "surface", lines: 1, col: 1, row: 1)
let firstPending = TerminalScrollDelivery(
surfaceID: "surface",
lines: 2,
col: 2,
row: 2,
maxScrollbackRows: 240
)
let latestPending = TerminalScrollDelivery(
surfaceID: "surface",
lines: 3,
col: 3,
row: 3,
maxScrollbackRows: 600
)

#expect(queue.enqueue(inFlight) == inFlight)
#expect(queue.enqueue(firstPending) == nil)
#expect(queue.enqueue(latestPending) == nil)

let maybeCoalesced = queue.completeInFlight()
let coalesced = try #require(maybeCoalesced)
#expect(coalesced.lines == 5)
#expect(coalesced.col == 3)
#expect(coalesced.row == 3)
#expect(coalesced.maxScrollbackRows == 600)
}

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

#expect(state.rowsToPrefetch(forScrollLines: 0) == nil)
#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)
}
Comment on lines +70 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add an overflow-carry regression case for prefetch cadence.

This test covers prime + exact-threshold crossing, but it doesn’t lock behavior for a large single delta (e.g., crossing refreshDistanceRows by more than one threshold). Add one case that verifies remainder is preserved between triggers.

🤖 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
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift`
around lines 70 - 78, Add a regression test case to the
terminalScrollbackPrefetchStatePrimesThenRefreshesByDistance test function that
verifies the prefetch cadence behavior when a single large scroll delta crosses
the refreshDistanceRows threshold by more than one unit. After the existing
`#expect` statements, add a case that demonstrates overflow-carry behavior by
scrolling a large distance (e.g., greater than twice the refreshDistanceRows
value of 10) and verify that the remainder is properly preserved between
prefetch triggers, ensuring the cadence logic correctly handles multi-threshold
crossings in a single operation.


@Test func terminalScrollQueueResetDropsPendingWork() {
var queue = TerminalScrollDeliveryQueue()
let inFlight = TerminalScrollDelivery(surfaceID: "surface", lines: 1, col: 1, row: 1)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#if canImport(UIKit)
import GhosttyKit
import UIKit

extension GhosttySurfaceView {
/// Apply the scroll to the phone's local Ghostty mirror immediately. On the
/// primary screen this consumes the preloaded local scrollback window, so a
/// drag/deceleration feels native while the Mac catches up. On alternate
/// screens libghostty turns this into mouse-wheel bytes; the mirror is
/// display-only and drops those bytes, so the authoritative Mac response
/// remains the visible update for TUIs.
func applyLocalScrollbackScroll(lines: Double, col: Int, row: Int) {
guard lines != 0, let surface else { return }
let displayScale = window?.windowScene?.screen.scale ?? traitCollection.displayScale
let scale = max(Double(displayScale), 1)
let size = ghostty_surface_size(surface)
let cellWidthPt = max(Double(size.cell_width_px) / scale, 1)
let cellHeightPt = max(Double(size.cell_height_px) / scale, 1)
let posX = (Double(max(0, col)) + 0.5) * cellWidthPt
let posY = (Double(max(0, row)) + 0.5) * cellHeightPt
ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE)
ghostty_surface_mouse_scroll(surface, 0, lines, 0)
drawForWakeup()
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -1810,6 +1810,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
let lines = pendingScrollLines
let cell = pendingScrollCell
pendingScrollLines = 0
applyLocalScrollbackScroll(lines: lines, col: cell.col, row: cell.row)
delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row)
}

Expand Down
65 changes: 65 additions & 0 deletions Sources/TerminalController+MobileScrollPrefetch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import CMUXMobileCore
import Foundation

extension TerminalController {
/// Scrollback rows included in a cold-attach render-grid replay snapshot.
/// Live render-grid events carry no scrollback; the phone keeps its own
/// bounded Ghostty scrollback mirror and scrolls that mirror locally while
/// the Mac remains authoritative.
nonisolated static let mobileReplayScrollbackLineBudget = 240

/// Larger history window returned only on explicit mobile scroll prefetch
/// requests, keeping ordinary scroll RPCs small.
nonisolated static let mobileScrollPrefetchScrollbackLineBudget = 600

func mobileTerminalRenderGridFrame(
terminalPanel: TerminalPanel,
surfaceID: UUID,
seq: UInt64,
scrollbackLines: Int = TerminalController.mobileReplayScrollbackLineBudget
) -> MobileTerminalRenderGridFrame? {
guard surfaceID == terminalPanel.id else { return nil }
return terminalPanel.surface.mobileRenderGridFrame(
stateSeq: seq,
scrollbackLines: scrollbackLines
)?.frame
}

func mobileTerminalScrollResponsePayload(
workspaceID: UUID,
terminalPanel: TerminalPanel,
surfaceID: UUID,
params: [String: Any]
) -> [String: Any] {
var payload: [String: Any] = [
"workspace_id": workspaceID.uuidString,
"surface_id": surfaceID.uuidString,
]
let scrollbackRows = mobileScrollPrefetchRows(params: params)
guard scrollbackRows > 0 else { return payload }
let stateSeq = MobileTerminalByteTee.shared.currentSequence(surfaceID: surfaceID) ?? 0
guard let renderGrid = mobileTerminalRenderGridFrame(
terminalPanel: terminalPanel,
surfaceID: surfaceID,
seq: stateSeq,
scrollbackLines: scrollbackRows
),
renderGrid.activeScreen == .primary,
let renderGridObject = try? renderGrid.jsonObject() else {
return payload
}
payload["columns"] = renderGrid.columns
payload["rows"] = renderGrid.rows
payload["render_grid"] = renderGridObject
payload["seq"] = renderGrid.stateSeq
return payload
}

private func mobileScrollPrefetchRows(params: [String: Any]) -> Int {
let requestedRows = (params["max_scrollback_rows"] as? NSNumber)?.intValue ?? 0
return min(
max(0, requestedRows),
Self.mobileScrollPrefetchScrollbackLineBudget
)
Comment on lines +59 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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify existing TerminalController parsing convention and this callsite.
set -euo pipefail

echo "== v2 numeric helper definitions/usages in Sources/TerminalController* =="
rg -n --type=swift -C2 '\bv2(Int|StrictInt)\s*\(' Sources/TerminalController.swift Sources/TerminalController+*.swift

echo
echo "== current max_scrollback_rows parsing in mobile prefetch extension =="
rg -n --type=swift -C3 'max_scrollback_rows|NSNumber|intValue|mobileScrollPrefetchRows' Sources/TerminalController+MobileScrollPrefetch.swift

Repository: manaflow-ai/cmux

Length of output: 9676


🏁 Script executed:

rg -n --type=swift -B2 -A5 'func v2(Int|StrictInt)\(' Sources/TerminalController.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n --type=swift 'func v2Int|func v2StrictInt' Sources/TerminalController.swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n --type=swift 'v2Int|v2StrictInt' Sources/TerminalController.swift | grep 'func\|private' | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

fd --type f --extension swift . Sources | xargs rg -l 'func v2Int|func v2StrictInt' | head -5

Repository: manaflow-ai/cmux

Length of output: 114


🏁 Script executed:

rg -n --type=swift -A8 'func v2Int|func v2StrictInt' Sources/TerminalControllerV2ParamParsingSupport.swift

Repository: manaflow-ai/cmux

Length of output: 1088


🏁 Script executed:

cat -n Sources/TerminalControllerV2ParamParsingSupport.swift | sed -n '159,180p'

Repository: manaflow-ai/cmux

Length of output: 923


🏁 Script executed:

cat -n Sources/TerminalController+MobileScrollPrefetch.swift | sed -n '58,65p'

Repository: manaflow-ai/cmux

Length of output: 409


Use v2StrictInt for strict numeric validation of max_scrollback_rows instead of unsafe NSNumber.intValue coercion.

Line 59 uses NSNumber.intValue, which silently accepts and coerces non-integer JSON values and booleans. Replace with v2StrictInt(params, "max_scrollback_rows") ?? 0 to match the codebase's strict validation pattern (used for port numbers and other resource budgets). This rejects non-integers and boolean values explicitly rather than coercing them, ensuring request semantics are preserved consistently with other TerminalController v2 handlers.

🤖 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 `@Sources/TerminalController`+MobileScrollPrefetch.swift around lines 59 - 63,
The code at line 59 uses unsafe NSNumber.intValue coercion on the
max_scrollback_rows parameter, which silently accepts and coerces non-integer
JSON values and booleans. Replace the entire expression
`(params["max_scrollback_rows"] as? NSNumber)?.intValue ?? 0` with
`v2StrictInt(params, "max_scrollback_rows") ?? 0` to apply strict numeric
validation that rejects non-integers and boolean values explicitly, consistent
with the codebase pattern used for port numbers and other resource budgets in
TerminalController v2 handlers.

Source: Learnings

}
}
Loading
Loading