diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 328ec2354a5..6e168d2f1ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -424,6 +424,8 @@ jobs: CmuxFileWatch CmuxFoundation CmuxGit + CmuxMobileShell + CmuxMobileTerminalKit CmuxProcess CmuxSettings CmuxSettingsUI diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift new file mode 100644 index 00000000000..ac41aa95ae9 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift @@ -0,0 +1,129 @@ +internal import CMUXMobileCore +internal import CmuxMobileDiagnostics +public import CmuxMobileRPC +public import Foundation +internal import OSLog + +private let mobileShellLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", + category: "mobile-shell" +) + +// MARK: - Terminal replay (cold attach + deeper-scrollback fetch) +extension MobileShellComposite { + /// Request a single deeper-scrollback replay for local (primary-screen) + /// scroll: when the phone scrolls to the top of locally-held history, this + /// re-requests the render-grid with a larger `scrollback_lines` budget so the + /// full-snapshot reflow grows the local surface's history. One RPC, not + /// per-frame; shares the in-flight guard with the cold-attach replay so it + /// can't pile up. The Mac clamps the budget to its own maximum. + /// - Parameters: + /// - surfaceID: The terminal surface identifier. + /// - scrollbackLines: How many scrollback rows to request. + public func requestDeeperScrollback(surfaceID: String, scrollbackLines: Int) { + requestTerminalReplay(surfaceID: surfaceID, scrollbackLines: max(0, scrollbackLines)) + } + + /// Cold-attach/self-heal replay. Prefer the Mac's bounded render-grid + /// snapshot, replacing the local iOS terminal state before live bytes + /// resume. The VT snapshot and raw byte ring remain fallbacks, but neither + /// is the target architecture: a byte tail is not a complete screen state + /// for TUIs, and a VT export is still a replay stream rather than state. + /// `scrollbackLines` (when set) requests a deeper-history snapshot for local + /// scroll; nil uses the Mac's default attach-time budget. + func requestTerminalReplay(surfaceID: String, scrollbackLines: Int? = nil) { + guard let client = remoteClient else { + #if DEBUG + mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=no_remote_client") + #endif + return + } + guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { + #if DEBUG + mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found") + #endif + return + } + guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else { + #if DEBUG + mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight") + #endif + return + } + terminalReplaySurfaceIDsInFlight.insert(surfaceID) + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.terminalReplaySurfaceIDsInFlight.remove(surfaceID) } + do { + var replayParams: [String: Any] = [ + "workspace_id": workspaceID.rawValue, + "surface_id": surfaceID, + ] + if let scrollbackLines { + replayParams["scrollback_lines"] = scrollbackLines + } + let request = try MobileCoreRPCClient.requestData( + method: "mobile.terminal.replay", + params: replayParams + ) + let data = try await client.sendRequest(request) + guard self.remoteClient === client else { return } + let payload = try? MobileTerminalReplayResponse.decode(data) + let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) } + let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) } + let decodedRenderGrid = payload?.renderGrid + let renderGrid = decodedRenderGrid?.surfaceID == surfaceID ? decodedRenderGrid : nil + let replaySeq = renderGrid?.stateSeq ?? payload?.sequence + #if DEBUG + let seq = replaySeq ?? 0 + let cols = payload?.columns ?? -1 + let rows = payload?.rows ?? -1 + mobileShellLog.info("CMUX_REPLAY response surface=\(surfaceID, privacy: .public) byteCount=\(bytes?.count ?? -1, privacy: .public) snapshotBytes=\(snapshotBytes?.count ?? -1, privacy: .public) renderGrid=\(renderGrid != nil, privacy: .public) seq=\(seq, privacy: .public) macGrid=\(cols, privacy: .public)x\(rows, privacy: .public) hasSink=\(self.hasTerminalOutputSink(surfaceID: surfaceID), privacy: .public)") + #endif + if let replaySeq, + let deliveredSeq = self.deliveredTerminalByteEndSeqBySurfaceID[surfaceID], + deliveredSeq > replaySeq { + MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) delivered=\(deliveredSeq) replay=\(replaySeq)") + return + } + let deliverBytes: Data? + if let renderGrid { + deliverBytes = renderGrid.vtPatchBytes() + MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)") + } else if let snapshotBytes, !snapshotBytes.isEmpty { + deliverBytes = Self.terminalSnapshotReplacementBytes(snapshotBytes) + MobileDebugLog.anchormux("CMUX_REPLAY snapshot surface=\(surfaceID) bytes=\(snapshotBytes.count) seq=\(replaySeq ?? 0)") + } else { + deliverBytes = bytes + MobileDebugLog.anchormux("CMUX_REPLAY raw_tail surface=\(surfaceID) bytes=\(bytes?.count ?? -1) seq=\(replaySeq ?? 0)") + } + if let replaySeq { + self.markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: replaySeq) + } + // A render-grid replay (cold attach OR deeper-scrollback fetch) is + // a full snapshot that re-flows scrollback into the local surface. + // Deliver its metadata (scrollback depth + active screen) and its + // bytes as ONE ordered element so the view classifies this exact + // snapshot (deeper-fetch result vs cold attach) and applies any + // scroll restore around this snapshot's own bytes, never around an + // interleaved live frame. + if let renderGrid { + self.deliverTerminalFrame(renderGrid, bytes: deliverBytes ?? Data()) + return + } + guard let deliverBytes, !deliverBytes.isEmpty else { + return + } + self.deliverTerminalBytes(deliverBytes, surfaceID: surfaceID) + } catch { + mobileShellLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)") + // The replay request is the view-only/foreground-resume path. A + // definitive auth failure here (after the RPC layer's + // force-refresh-and-retry already gave up) must drive the re-auth + // prompt instead of silently leaving a stale frame. + guard self.remoteClient === client else { return } + _ = self.disconnectForAuthorizationFailureIfNeeded(error) + } + } + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 6605a6e1b89..9fb8f98ba8a 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -241,7 +241,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// `public` so the DEV feedback-submit affordance can ``DiagnosticLog/export()`` /// it. public let diagnosticLog: DiagnosticLog? - private var remoteClient: MobileCoreRPCClient? { + var remoteClient: MobileCoreRPCClient? { didSet { if remoteClient == nil { stopTerminalRefreshPolling() @@ -272,9 +272,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private var createTerminalTaskID: UUID? private var connectionGeneration: UUID private var reportedViewportSizesByTerminalKey: [MobileTerminalViewportKey: MobileTerminalViewportSize] - private var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64] + var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64] private var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64] - private var terminalReplaySurfaceIDsInFlight: Set + var terminalReplaySurfaceIDsInFlight: Set private var terminalOutputTransport: TerminalOutputTransport private var rawTerminalInputBuffer: MobileTerminalInputSendBuffer private var pairingAttemptID: UUID @@ -2602,7 +2602,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) } - private func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) { + func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) { let current = deliveredTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0 deliveredTerminalByteEndSeqBySurfaceID[surfaceID] = max(current, endSeq) if let pendingSeq = pendingTerminalByteEndSeqBySurfaceID[surfaceID], @@ -2612,7 +2612,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } - private static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data { + 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) return bytes @@ -2620,24 +2620,46 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// Per-surface output continuations for the libghostty render path. A mounted /// `GhosttySurfaceView` obtains a stream via ``terminalOutputStream(surfaceID:)`` - /// and receives VT patch bytes derived from render-grid frames. Raw PTY bytes - /// flow through the same continuation as a compatibility fallback for older - /// Mac hosts. - private var terminalByteContinuationsBySurfaceID: [String: AsyncStream.Continuation] = [:] - - /// Yield a chunk of output bytes to the surface's stream, if one is attached. - private func deliverTerminalBytes(_ bytes: Data, surfaceID: String) { - terminalByteContinuationsBySurfaceID[surfaceID]?.yield(bytes) + /// and receives ordered ``MobileTerminalOutputChunk`` elements: VT patch bytes + /// derived from render-grid frames together with that frame's metadata, or raw + /// PTY bytes (no metadata) as a compatibility fallback for older Mac hosts. + private var terminalByteContinuationsBySurfaceID: [String: AsyncStream.Continuation] = [:] + + /// Yield raw output bytes (no frame metadata) to the surface's stream, if + /// one is attached. Compatibility path for older Mac hosts and the raw + /// byte-ring fallback. + func deliverTerminalBytes(_ bytes: Data, surfaceID: String) { + terminalByteContinuationsBySurfaceID[surfaceID]?.yield(MobileTerminalOutputChunk(bytes: bytes)) + } + + /// Yield one render-grid frame to the surface's stream: its metadata and + /// bytes travel as ONE ordered element, so the view applies the metadata + /// immediately before that frame's bytes. This makes the local-scroll + /// engine's arm-then-consume contract structural (a deeper-fetch restore is + /// consumed by the fetch snapshot's own apply, never by an interleaved live + /// frame, and bytes can never apply before their own metadata). Delivered + /// even when `bytes` is empty: a no-row-change frame still carries the + /// active screen. + func deliverTerminalFrame(_ frame: MobileTerminalRenderGridFrame, bytes: Data) { + let chunk = MobileTerminalOutputChunk( + meta: MobileTerminalOutputChunk.FrameMeta( + isAlternateScreen: frame.activeScreen == .alternate, + isFullSnapshot: frame.full, + scrollbackRows: frame.full ? frame.scrollbackRows : 0 + ), + bytes: bytes + ) + terminalByteContinuationsBySurfaceID[frame.surfaceID]?.yield(chunk) } /// Whether a surface currently has an attached output stream consumer. - private func hasTerminalOutputSink(surfaceID: String) -> Bool { + func hasTerminalOutputSink(surfaceID: String) -> Bool { terminalByteContinuationsBySurfaceID[surfaceID] != nil } private func registerTerminalOutput( surfaceID: String, - continuation: AsyncStream.Continuation + continuation: AsyncStream.Continuation ) { terminalByteContinuationsBySurfaceID[surfaceID] = continuation deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) @@ -2657,14 +2679,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { clearTerminalViewport(surfaceID: surfaceID) } - /// The output byte stream for a terminal surface. + /// The output stream for a terminal surface (frame metadata + bytes as one + /// ordered element per frame; raw compatibility bytes carry no metadata). /// /// Obtaining the stream arms a cold-attach replay so the surface catches up /// to current state; ending iteration (or cancelling the consuming task) /// unregisters the surface and clears its viewport pin on the Mac. /// - Parameter surfaceID: The terminal surface identifier. - /// - Returns: An `AsyncStream` of output byte chunks. - public func terminalOutputStream(surfaceID: String) -> AsyncStream { + /// - Returns: An `AsyncStream` of output chunks. + public func terminalOutputStream(surfaceID: String) -> AsyncStream { AsyncStream { continuation in registerTerminalOutput(surfaceID: surfaceID, continuation: continuation) continuation.onTermination = { [weak self] _ in @@ -2737,93 +2760,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } - /// Cold-attach/self-heal replay. Prefer the Mac's bounded render-grid - /// snapshot, replacing the local iOS terminal state before live bytes - /// resume. The VT snapshot and raw byte ring remain fallbacks, but neither - /// is the target architecture: a byte tail is not a complete screen state - /// for TUIs, and a VT export is still a replay stream rather than state. - private func requestTerminalReplay(surfaceID: String) { - guard let client = remoteClient else { - #if DEBUG - mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=no_remote_client") - #endif - return - } - guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { - #if DEBUG - mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found") - #endif - return - } - guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else { - #if DEBUG - mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight") - #endif - return - } - terminalReplaySurfaceIDsInFlight.insert(surfaceID) - Task { @MainActor [weak self] in - guard let self else { return } - defer { self.terminalReplaySurfaceIDsInFlight.remove(surfaceID) } - do { - let request = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.replay", - params: [ - "workspace_id": workspaceID.rawValue, - "surface_id": surfaceID, - ] - ) - let data = try await client.sendRequest(request) - guard self.remoteClient === client else { return } - let payload = try? MobileTerminalReplayResponse.decode(data) - let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) } - let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) } - let decodedRenderGrid = payload?.renderGrid - let renderGrid = decodedRenderGrid?.surfaceID == surfaceID ? decodedRenderGrid : nil - let replaySeq = renderGrid?.stateSeq ?? payload?.sequence - #if DEBUG - let seq = replaySeq ?? 0 - let cols = payload?.columns ?? -1 - let rows = payload?.rows ?? -1 - mobileShellLog.info("CMUX_REPLAY response surface=\(surfaceID, privacy: .public) byteCount=\(bytes?.count ?? -1, privacy: .public) snapshotBytes=\(snapshotBytes?.count ?? -1, privacy: .public) renderGrid=\(renderGrid != nil, privacy: .public) seq=\(seq, privacy: .public) macGrid=\(cols, privacy: .public)x\(rows, privacy: .public) hasSink=\(self.hasTerminalOutputSink(surfaceID: surfaceID), privacy: .public)") - #endif - if let replaySeq, - let deliveredSeq = self.deliveredTerminalByteEndSeqBySurfaceID[surfaceID], - deliveredSeq > replaySeq { - MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) delivered=\(deliveredSeq) replay=\(replaySeq)") - return - } - let deliverBytes: Data? - if let renderGrid { - deliverBytes = renderGrid.vtPatchBytes() - MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)") - } else if let snapshotBytes, !snapshotBytes.isEmpty { - deliverBytes = Self.terminalSnapshotReplacementBytes(snapshotBytes) - MobileDebugLog.anchormux("CMUX_REPLAY snapshot surface=\(surfaceID) bytes=\(snapshotBytes.count) seq=\(replaySeq ?? 0)") - } else { - deliverBytes = bytes - MobileDebugLog.anchormux("CMUX_REPLAY raw_tail surface=\(surfaceID) bytes=\(bytes?.count ?? -1) seq=\(replaySeq ?? 0)") - } - if let replaySeq { - self.markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: replaySeq) - } - guard let deliverBytes, !deliverBytes.isEmpty else { - return - } - self.deliverTerminalBytes(deliverBytes, surfaceID: surfaceID) - } catch { - mobileShellLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)") - // The replay request is the view-only/foreground-resume path. A - // definitive auth failure here (after the RPC layer's - // force-refresh-and-retry already gave up) must drive the re-auth - // prompt instead of silently leaving a stale frame. - guard self.remoteClient === client else { return } - _ = self.disconnectForAuthorizationFailureIfNeeded(error) - } - } - } - - private func workspaceID(forTerminalID terminalID: String) -> MobileWorkspacePreview.ID? { + func workspaceID(forTerminalID terminalID: String) -> MobileWorkspacePreview.ID? { for workspace in workspaces { if workspace.terminals.contains(where: { $0.id.rawValue == terminalID }) { return workspace.id @@ -2855,8 +2792,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { #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 - guard !bytes.isEmpty else { return } - deliverTerminalBytes(bytes, surfaceID: renderGrid.surfaceID) + // One ordered element: the frame's metadata (active screen, snapshot + // scrollback depth) rides with its bytes, so the view's local-scroll + // gates always see a frame's metadata immediately before its apply. + // Empty-byte frames still flow: a no-row-change frame carries the + // active screen. + deliverTerminalFrame(renderGrid, bytes: bytes) } private func handleTerminalBytesEvent(_ event: MobileEventEnvelope) { @@ -3010,7 +2951,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return true } - private func disconnectForAuthorizationFailureIfNeeded(_ error: any Error) -> Bool { + func disconnectForAuthorizationFailureIfNeeded(_ error: any Error) -> Bool { guard Self.shouldDisconnectForAuthorizationFailure(error) else { return false } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalOutputChunkDeliveryTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalOutputChunkDeliveryTests.swift new file mode 100644 index 00000000000..b228e86ddbf --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalOutputChunkDeliveryTests.swift @@ -0,0 +1,79 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Behavior tests for the one-ordered-element delivery contract of +/// ``MobileShellComposite/terminalOutputStream(surfaceID:)``: a render-grid +/// frame's metadata and bytes travel as a single chunk (so the local-scroll +/// engine's arm-then-consume decisions can never race a separate metadata +/// stream), an empty-byte frame still carries its metadata (an active-screen +/// flip with no row changes), and raw compatibility bytes carry none. +@MainActor +@Suite struct MobileTerminalOutputChunkDeliveryTests { + private static func makeFrame( + surfaceID: String = "surface-1", + full: Bool = true, + scrollbackRows: Int = 0, + activeScreen: MobileTerminalRenderGridFrame.Screen = .primary, + text: String = "hello" + ) throws -> MobileTerminalRenderGridFrame { + var frame = try MobileTerminalRenderGridFrame.fromPlainRows( + surfaceID: surfaceID, + stateSeq: 1, + columns: 10, + rows: 2, + text: text, + full: full + ) + frame.scrollbackRows = scrollbackRows + frame.activeScreen = activeScreen + return frame + } + + @Test("a render-grid frame's metadata and bytes arrive as one ordered chunk") + func frameMetaAndBytesArriveTogether() async throws { + let store = MobileShellComposite.preview() + var iterator = store.terminalOutputStream(surfaceID: "surface-1").makeAsyncIterator() + + let frame = try Self.makeFrame(full: true, scrollbackRows: 7) + let bytes = frame.vtPatchBytes() + store.deliverTerminalFrame(frame, bytes: bytes) + + let chunk = try #require(await iterator.next()) + let meta = try #require(chunk.meta) + #expect(meta.isFullSnapshot) + #expect(meta.scrollbackRows == 7) + #expect(!meta.isAlternateScreen) + #expect(chunk.bytes == bytes) + } + + @Test("an empty-byte frame still carries its metadata (active-screen flip)") + func emptyFrameCarriesMeta() async throws { + let store = MobileShellComposite.preview() + var iterator = store.terminalOutputStream(surfaceID: "surface-1").makeAsyncIterator() + + let frame = try Self.makeFrame(full: false, activeScreen: .alternate) + store.deliverTerminalFrame(frame, bytes: Data()) + + let chunk = try #require(await iterator.next()) + let meta = try #require(chunk.meta) + #expect(meta.isAlternateScreen) + #expect(!meta.isFullSnapshot) + #expect(meta.scrollbackRows == 0) + #expect(chunk.bytes.isEmpty) + } + + @Test("raw compatibility bytes carry no frame metadata") + func rawBytesCarryNoMeta() async throws { + let store = MobileShellComposite.preview() + var iterator = store.terminalOutputStream(surfaceID: "surface-1").makeAsyncIterator() + + store.deliverTerminalBytes(Data("legacy".utf8), surfaceID: "surface-1") + + let chunk = try #require(await iterator.next()) + #expect(chunk.meta == nil) + #expect(chunk.bytes == Data("legacy".utf8)) + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputChunk.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputChunk.swift new file mode 100644 index 00000000000..e1818916a79 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputChunk.swift @@ -0,0 +1,49 @@ +public import Foundation + +/// One ordered element of a terminal surface's output stream: a frame's +/// metadata together with the VT bytes that realize it. +/// +/// Metadata and bytes for the SAME frame must be applied back-to-back, in +/// stream order: the Stage 1 local-scroll engine arms decisions from a frame's +/// metadata (active-screen routing, deeper-fetch classification, the scroll +/// restore after a deeper-scrollback snapshot) and consumes them when that +/// frame's bytes apply. Delivering both as one element makes that ordering +/// structural. Metadata previously rode a separate stream, which raced the +/// byte stream three ways: a frame's bytes could apply before their own +/// metadata (dropping an armed restore), an interleaved live frame could +/// consume a restore armed for a later snapshot, and a restore armed while the +/// reader scrolled back to the bottom could go stale and fire much later. +public struct MobileTerminalOutputChunk: Sendable { + /// Per-frame metadata for the terminal view's local-scroll gates. + public struct FrameMeta: Sendable { + /// Whether the active screen is the alternate screen (TUI). Primary + /// scrolls locally; alternate forwards to the Mac. + public let isAlternateScreen: Bool + /// Whether this frame was a full snapshot (it rebuilt the local + /// surface at the live bottom and flowed ``scrollbackRows`` of + /// history). + public let isFullSnapshot: Bool + /// Scrollback rows flowed into the local surface by a full snapshot. + /// Zero for a delta (a delta grows no local history). + public let scrollbackRows: Int + + public init(isAlternateScreen: Bool, isFullSnapshot: Bool, scrollbackRows: Int) { + self.isAlternateScreen = isAlternateScreen + self.isFullSnapshot = isFullSnapshot + self.scrollbackRows = scrollbackRows + } + } + + /// The frame's metadata, or `nil` for raw PTY bytes (older Mac hosts / + /// compatibility fallback), which carry no frame identity. + public let meta: FrameMeta? + /// VT bytes to feed into the local libghostty surface. May be empty for a + /// metadata-only frame (e.g. a no-row-change frame that flips the active + /// screen). + public let bytes: Data + + public init(meta: FrameMeta? = nil, bytes: Data) { + self.meta = meta + self.bytes = bytes + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift index 177a631cc46..8bb24b0514c 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift @@ -1,22 +1,26 @@ public import Foundation -/// A seam exposing per-surface terminal output as an `AsyncStream`. +/// A seam exposing per-surface terminal output as an ordered `AsyncStream` of +/// ``MobileTerminalOutputChunk``. /// -/// A mounted terminal view obtains the stream for its surface and feeds every -/// yielded chunk into its libghostty surface (`process_output`). The bytes are -/// VT patch bytes derived from render-grid frames, or raw PTY bytes as a -/// compatibility fallback for older Mac hosts. Obtaining the stream also arms a -/// cold-attach replay so a freshly mounted surface catches up to current state; -/// ending iteration releases the surface so the Mac drops its viewport pin. +/// A mounted terminal view obtains the stream for its surface, applies each +/// chunk's frame metadata to its local-scroll gates, and feeds the chunk's +/// bytes into its libghostty surface (`process_output`). The bytes are VT +/// patch bytes derived from render-grid frames (with metadata), or raw PTY +/// bytes as a compatibility fallback for older Mac hosts (no metadata). +/// Obtaining the stream also arms a cold-attach replay so a freshly mounted +/// surface catches up to current state; ending iteration releases the surface +/// so the Mac drops its viewport pin. /// /// This replaces the previous `(Data) -> Void` sink registry so output /// propagation is a structured, cancellable `AsyncSequence` instead of a stored /// callback. public protocol MobileTerminalOutputSinking: Sendable { - /// The output byte stream for a terminal surface. + /// The output stream for a terminal surface. /// /// - Parameter surfaceID: The terminal surface identifier. - /// - Returns: An `AsyncStream` of output byte chunks. Ending iteration (or - /// cancelling the consuming task) unregisters the surface. - @MainActor func terminalOutputStream(surfaceID: String) -> AsyncStream + /// - Returns: An `AsyncStream` of output chunks (frame metadata + bytes). + /// Ending iteration (or cancelling the consuming task) unregisters the + /// surface. + @MainActor func terminalOutputStream(surfaceID: String) -> AsyncStream } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 827462aa703..9aa7af43590 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -76,10 +76,32 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { // Drive every output chunk into the libghostty surface. Ending this // task terminates the stream, which unregisters the surface and // clears its viewport pin on the Mac (see `terminalOutputStream`). + // + // A chunk carries a frame's metadata together with its bytes, so the + // Stage 1 local-scroll gates (active screen, snapshot scrollback + // depth) are applied immediately before that frame's apply, in + // order, with no separate stream to race: a deeper-fetch scroll + // restore is armed and consumed around the fetch snapshot's own + // bytes within this one iteration (no gesture or live frame can + // interleave on the main actor between these synchronous calls). outputTask = Task { @MainActor [weak surfaceView] in - for await data in store.terminalOutputStream(surfaceID: surfaceID) { + for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { guard !Task.isCancelled else { return } - surfaceView?.processOutput(data) + guard let view = surfaceView else { continue } + if let meta = chunk.meta { + view.setActiveScreen(isAlternate: meta.isAlternateScreen) + if meta.isFullSnapshot { + view.setHeldScrollbackRows(meta.scrollbackRows) + } + } + // A metadata-only delta (no row changes, e.g. a cursor-blink + // seq bump that flips the active screen) must not reach + // `processOutput`: nothing paints, so nothing may snap the + // scrolled-up reader. A full snapshot always applies, even + // byteless, so the snap + restore it armed runs in its slot. + if !chunk.bytes.isEmpty || chunk.meta?.isFullSnapshot == true { + view.processOutput(chunk.bytes) + } } } } @@ -142,12 +164,29 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) { // Forward to the Mac's real surface; libghostty scrolls scrollback // (normal screen) or sends mouse-wheel to the program (alt screen). + // The view only calls this for the ALTERNATE screen now; primary + // scrolls locally and never reaches here (Stage 1 smooth scroll). Task { @MainActor [weak self] in guard let self else { return } await self.store?.scrollTerminal(surfaceID: self.surfaceID, lines: lines, col: col, row: row) } } + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didReachLocalHistoryTopWithHeldScrollbackRows currentScrollbackRows: Int) { + // Stage 1 smooth scroll: the local (primary-screen) scroll reached the + // top of held history. Request ONE deeper-scrollback replay (not + // per-frame) to grow the local surface's history. Request a chunky + // window beyond what is held so boundary crossings are rare; the Mac + // clamps to its own max. + let nextWindow = max(currentScrollbackRows * 2, currentScrollbackRows + Self.scrollbackPageRows) + MobileDebugLog.anchormux("scroll.fetchDeeper held=\(currentScrollbackRows) request=\(nextWindow)") + store?.requestDeeperScrollback(surfaceID: surfaceID, scrollbackLines: nextWindow) + } + + /// How many extra scrollback rows to request per deeper-history fetch, so + /// the phone pages in chunks rather than one boundary fetch per row. + private static let scrollbackPageRows = 200 + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) { // Forward to the Mac's real surface as a left click; libghostty // reports it to a TUI with mouse mode, or no-ops on a normal screen. diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift new file mode 100644 index 00000000000..f71e33cc830 --- /dev/null +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift @@ -0,0 +1,146 @@ +#if canImport(UIKit) +import CmuxMobileDiagnostics +import CmuxMobileTerminalKit +import GhosttyKit +import UIKit + +// MARK: - Scrolling (Stage 1 smooth scroll) +// +// Two scroll modes, gated on the active screen: +// +// - ALTERNATE screen: forward to the MAC's real surface exactly as before. +// The program owns alt-screen scroll (mouse-wheel to the PTY) and a single +// `ghostty_surface_mouse_scroll` on the real surface does the mode-correct +// thing; the render-grid mirrors the result back. TUIs (vim/less +// --mouse/htop/lazygit) must keep this path untouched. +// +// - PRIMARY screen: scroll THIS phone's own libghostty surface over the +// scrollback it holds locally, with NO per-frame RPC to the Mac. The Mac +// stays the single source of truth for content; the phone only owns a +// read-only scroll position into already-received history and snaps back to +// live when new output arrives (see `processOutput`). +// +// Every gate/latch decision lives in `MobileLocalScrollEngine` +// (CmuxMobileTerminalKit, unit-tested); this file is the UIKit + libghostty +// glue. +extension GhosttySurfaceView { + @objc func handleScrollPan(_ gesture: UIPanGestureRecognizer) { + if gesture.state == .began || gesture.state == .changed || gesture.state == .ended { + MobileDebugLog.anchormux("scroll.pan state=\(gesture.state.rawValue) ty=\(Int(gesture.translation(in: self).y)) alt=\(localScroll.isAlternateScreen)") + } + switch gesture.state { + case .began: + localScroll.notePanBegan() + case .changed: + accumulatePanTranslation(gesture) + case .ended, .cancelled: + // The recognizer can carry residual translation since the last + // `.changed` callback; fold it in so the final chunk of the swipe + // is not dropped, then flush. + accumulatePanTranslation(gesture) + flushPendingScrollIfNeeded() + default: + break + } + } + + /// Fold the gesture's translation since the last call into the pending + /// scroll, converted to terminal lines. + /// + /// Aim for ~1:1 natural scrolling. Measured: the Mac applies a ~3x line + /// multiplier to the wheel delta, so dividing the finger travel by (cell + /// height in points × 3) makes a swipe move the content roughly its own + /// distance. Falls back to a fixed divisor before the first geometry pass + /// measures the cell. + private func accumulatePanTranslation(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: self) + guard translation.y != 0 else { return } + let cellHeightPt = cellPixelSize.height / max(preferredScreenScale, 1) + let divisor = cellHeightPt > 1 ? Double(cellHeightPt) * 3 : 42 + pendingScrollLines += Double(translation.y) / divisor + pendingScrollCell = scrollCell(at: gesture.location(in: self)) + gesture.setTranslation(.zero, in: self) + } + + /// Map a touch point to a grid cell (shared effective grid with the Mac), so + /// alt-screen mouse-wheel reports at the cell under the finger. + func scrollCell(at point: CGPoint) -> (col: Int, row: Int) { + let scale = max(preferredScreenScale, 1) + let cellW = max(cellPixelSize.width / scale, 1) + let cellH = max(cellPixelSize.height / scale, 1) + let col = max(0, Int((point.x - lastRenderRect.minX) / cellW)) + let row = max(0, Int((point.y - lastRenderRect.minY) / cellH)) + return (col, row) + } + + /// Flush the coalesced scroll once per display-link frame, routed by the + /// engine: alt screen (or no metadata yet) forwards to the Mac; primary + /// scrolls the local surface with no RPC. + func flushPendingScrollIfNeeded() { + guard pendingScrollLines != 0 else { return } + let lines = pendingScrollLines + let cell = pendingScrollCell + pendingScrollLines = 0 + + switch localScroll.flushRoute { + case .forwardToMac: + MobileDebugLog.anchormux("scroll.forward lines=\(String(format: "%.2f", lines)) cell=\(cell.col)x\(cell.row) meta=\(localScroll.hasReceivedFrameMeta)") + delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row) + case .scrollLocally: + scrollLocalSurface(lines: lines, atCell: cell) + } + } + + /// Scroll the phone's own libghostty surface over its locally-held history. + /// `lines` is signed (positive = scroll up into history, matching the wheel + /// delta convention `ghostty_surface_mouse_scroll` uses on the Mac). Runs the + /// surface mutation on `outputQueue` so it serializes with `process_output` + /// (same internal surface lock; firing it from the main-thread gesture would + /// race the off-main renderer/IO and can wedge on libghostty's futex). + private func scrollLocalSurface(lines: Double, atCell cell: (col: Int, row: Int)) { + guard let surface, !isDismantled else { return } + + let outcome = localScroll.applyLocalScroll(lines: lines) + MobileDebugLog.anchormux("scroll.local lines=\(String(format: "%.2f", lines)) up=\(String(format: "%.1f", outcome.upRows)) held=\(localScroll.heldScrollbackRows)") + + let scale = max(Double(preferredScreenScale), 1) + let cellW = Double(cellPixelSize.width) / scale + let cellH = Double(cellPixelSize.height) / scale + let posX = (Double(cell.col) + 0.5) * cellW + let posY = (Double(cell.row) + 0.5) * cellH + // Capture the surface pointer on the main actor; the off-main block only + // touches the C pointer (serialized with `process_output` on `outputQueue`) + // and hops back to main for the redraw flag. + Self.outputQueue.async { + ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE) + ghostty_surface_mouse_scroll(surface, 0, lines, 0) + DispatchQueue.main.async { [weak self] in + self?.needsDraw = true + } + } + + // Reached (or passed) the top of locally-held history while scrolling + // up: ask the host for ONE deeper-scrollback fetch (not per-frame). The + // fetch re-flows a deeper snapshot into the local surface, growing + // history. The engine dedupes and stops at the fully-loaded ceiling. + if outcome.requestDeeperFetch { + delegate?.ghosttySurfaceView(self, didReachLocalHistoryTopWithHeldScrollbackRows: localScroll.heldScrollbackRows) + } + } + + /// Record the active screen from the latest applied frame (see + /// ``MobileLocalScrollEngine/noteActiveScreen(isAlternate:)`` for the full + /// contract, including why the local offset is NOT zeroed here). + public func setActiveScreen(isAlternate: Bool) { + localScroll.noteActiveScreen(isAlternate: isAlternate) + } + + /// Record how much scrollback the local surface now holds, from a full + /// primary snapshot (see + /// ``MobileLocalScrollEngine/noteFullSnapshot(scrollbackRows:)`` for the + /// fetch-classification and restore-arming contract). + public func setHeldScrollbackRows(_ rows: Int) { + localScroll.noteFullSnapshot(scrollbackRows: rows) + } +} +#endif diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 8a2043c06c8..f699a17c739 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -60,6 +60,13 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { /// path into the terminal so a running TUI (e.g. Claude Code) attaches it. /// `format` is a lowercase file-extension hint (e.g. `"png"`). Optional. func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteImage data: Data, format: String) + /// Stage 1 smooth scroll: the user scrolled to (or past) the top of the + /// history the phone holds locally while in the **primary** screen, so the + /// host should issue a single deeper-scrollback replay fetch (one RPC, not + /// per-frame) to grow local history. `currentScrollbackRows` is how many + /// scrollback rows the phone believes it currently holds, so the host can + /// request a larger window. Optional. + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didReachLocalHistoryTopWithHeldScrollbackRows currentScrollbackRows: Int) } public extension GhosttySurfaceViewDelegate { @@ -67,6 +74,7 @@ public extension GhosttySurfaceViewDelegate { func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) {} func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) {} func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteImage data: Data, format: String) {} + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didReachLocalHistoryTopWithHeldScrollbackRows currentScrollbackRows: Int) {} } @MainActor @@ -570,7 +578,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// without holding a reference to the specific surface. private static weak var activeInputSurface: GhosttySurfaceView? private weak var runtime: GhosttyRuntime? - private weak var delegate: GhosttySurfaceViewDelegate? + weak var delegate: GhosttySurfaceViewDelegate? private let fontSize: Float32 /// Surface-owned live font size (points). Zoom mutates this; it is the /// source of truth for the current size, so the size accumulates correctly @@ -622,7 +630,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// the last applied state from the byte stream and hide the overlay to /// match. Defaults to visible (a normal shell shows its cursor). private var hostCursorVisible: Bool = true - private var needsDraw: Bool = false + var needsDraw: Bool = false /// Countdown of extra draw requests after a geometry change, so the /// renderer (which presents a frame behind) produces a frame at the final /// settled layer size rather than leaving a stale mid-animation surface. @@ -674,7 +682,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// Serial background queue for `ghostty_surface_process_output`, which /// blocks on libghostty's internal renderer/IO futex. Running it on the /// main thread hangs the app until the scene-update watchdog kills it. - private static let outputQueue = DispatchQueue( + static let outputQueue = DispatchQueue( label: "dev.cmux.GhosttySurfaceView.output", qos: .userInitiated ) @@ -754,7 +762,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// `ghostty_surface_size` measurement. Used to translate an effective /// cols×rows pin into a pixel box without re-round-tripping through /// Ghostty. Zero until the first layout has measured. - private var cellPixelSize: CGSize = .zero + var cellPixelSize: CGSize = .zero /// 1 px separator stroke drawn around the pinned surface rect when the /// container is larger than the render target (i.e., this device is /// not the smallest). Added lazily on first letterbox. @@ -762,7 +770,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// Last render rect used for the Ghostty surface inside the host view's /// coordinate space. Kept so the border layer can match it without a /// second set_size round-trip. - private var lastRenderRect: CGRect = .zero + var lastRenderRect: CGRect = .zero #if DEBUG struct DebugGeometrySnapshot { @@ -1030,7 +1038,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// surface. A dismantled surface performs no render, output, or /// accessibility work so a view SwiftUI has removed cannot keep driving the /// renderer or the accessibility tree. - private var isDismantled = false + var isDismantled = false /// Whether the hidden terminal input should become first responder when the /// surface attaches to a window. Set to `false` to suppress autofocus after /// chrome actions (create workspace/terminal, switch terminal) so the @@ -1133,60 +1141,18 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var pinchAccumulatedScale: CGFloat = 1.0 - @objc private func handleScrollPan(_ gesture: UIPanGestureRecognizer) { - if gesture.state == .began || gesture.state == .changed || gesture.state == .ended { - MobileDebugLog.anchormux("scroll.pan state=\(gesture.state.rawValue) ty=\(Int(gesture.translation(in: self).y))") - } - // Forward scroll to the MAC's real surface instead of scrolling this - // display-only mirror. The Mac owns scrollback (normal screen) and the - // program owns alt-screen scroll (mouse-wheel to the PTY); a single - // `ghostty_surface_mouse_scroll` on the real surface does the - // mode-correct thing, and the render-grid (which exports the live - // viewport, `vp_top`) mirrors the result back. Scrolling the local - // mirror could never do either: it has no scrollback and no program. - switch gesture.state { - case .changed: - let translation = gesture.translation(in: self) - // Aim for ~1:1 natural scrolling. Measured: the Mac applies a ~3x - // line multiplier to the wheel delta, so dividing the finger travel - // by (cell height in points × 3) makes a swipe move the content - // roughly its own distance. Falls back to a fixed divisor before the - // first geometry pass measures the cell. - let cellHeightPt = cellPixelSize.height / max(preferredScreenScale, 1) - let divisor = cellHeightPt > 1 ? Double(cellHeightPt) * 3 : 42 - pendingScrollLines += Double(translation.y) / divisor - pendingScrollCell = scrollCell(at: gesture.location(in: self)) - gesture.setTranslation(.zero, in: self) - case .ended, .cancelled: - flushPendingScrollIfNeeded() - default: - break - } - } + // MARK: - Scroll state (gesture + local-scroll glue lives in + // GhosttySurfaceView+Scroll.swift; decisions live in MobileLocalScrollEngine) - /// Coalesced scroll forwarded to the Mac once per display-link frame. - private var pendingScrollLines: Double = 0 - private var pendingScrollCell: (col: Int, row: Int) = (0, 0) - - /// Map a touch point to a grid cell (shared effective grid with the Mac), so - /// alt-screen mouse-wheel reports at the cell under the finger. - private func scrollCell(at point: CGPoint) -> (col: Int, row: Int) { - let scale = max(preferredScreenScale, 1) - let cellW = max(cellPixelSize.width / scale, 1) - let cellH = max(cellPixelSize.height / scale, 1) - let col = max(0, Int((point.x - lastRenderRect.minX) / cellW)) - let row = max(0, Int((point.y - lastRenderRect.minY) / cellH)) - return (col, row) - } + /// Coalesced scroll flushed once per display-link frame. + var pendingScrollLines: Double = 0 + var pendingScrollCell: (col: Int, row: Int) = (0, 0) - private func flushPendingScrollIfNeeded() { - guard pendingScrollLines != 0 else { return } - let lines = pendingScrollLines - let cell = pendingScrollCell - pendingScrollLines = 0 - MobileDebugLog.anchormux("scroll.forward lines=\(String(format: "%.2f", lines)) cell=\(cell.col)x\(cell.row)") - delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row) - } + /// Pure decision state for Stage 1 local (primary-screen) scroll: routing + /// (local vs forward-to-Mac), the read-only scroll position, deeper-fetch + /// latches, and snap-to-live/restore decisions. See + /// ``MobileLocalScrollEngine`` for the full contract. + var localScroll = MobileLocalScrollEngine() /// A tap both raises the software keyboard (so the user can type) and /// forwards a left click at the tapped cell to the Mac. The Mac's libghostty @@ -1463,6 +1429,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // previous visibility stands. let cursorVisibilityDelta = Self.lastCursorVisibility(in: forwarded) + // If the user is reading local (primary-screen) history, snap the surface + // to the live bottom just before this frame so its absolute-CUP paint + // lands in the viewport, not into scrolled-up history. If this frame is + // a deeper-scrollback fetch's snapshot, also restore the reader's + // position after it applies instead of leaving them bounced to the + // bottom. Decided here on the main actor (where the gesture mutated the + // offset); applied off-main in `process_output` order below. + let snapDecision = localScroll.consumeSnapRequest() + let snapLocalScrollToLive = snapDecision.snapToLive + let restoreLocalScrollUpRows = snapDecision.restoreUpRows + if snapLocalScrollToLive { + MobileDebugLog.anchormux( + "scroll.snapLive restore=\(restoreLocalScrollUpRows.map { String(format: "%.1f", $0) } ?? "none")" + ) + } + // `ghostty_surface_process_output` BLOCKS on libghostty's internal // renderer/IO synchronization (a futex). Device crash logs show it // hanging the main thread (`Thread.Futex.Deadline.wait`) until the @@ -1470,11 +1452,28 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // the main thread. Feed it on a serial background queue (order // preserved) and hop back to main only for the Swift-side UI state. Self.outputQueue.async { [weak self] in + if snapLocalScrollToLive { + // Serialized with `process_output` on this queue, so the snap and + // the frame apply cannot interleave. Exact, regardless of the + // tracked offset. + let action = "scroll_to_bottom" + action.withCString { pointer in + _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + } + } forwarded.withUnsafeBytes { buffer in guard let baseAddress = buffer.baseAddress else { return } let pointer = baseAddress.assumingMemoryBound(to: CChar.self) ghostty_surface_process_output(surface, pointer, UInt(buffer.count)) } + if let restoreLocalScrollUpRows { + // Deeper-fetch snapshot applied (rebuilt at the live bottom): + // scroll back up by the reader's preserved cumulative delta so + // they stay on the rows they were reading. Same queue, so it + // cannot interleave with the apply; libghostty clamps at the + // top of the history it now holds. + ghostty_surface_mouse_scroll(surface, 0, restoreLocalScrollUpRows, 0) + } #if DEBUG // `ghostty_surface_read_text` takes the same internal surface lock as // `process_output`. Reading it on the MAIN thread per-output (to feed @@ -1752,7 +1751,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - private var preferredScreenScale: CGFloat { + var preferredScreenScale: CGFloat { if let screen = window?.windowScene?.screen { return screen.scale } diff --git a/Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift b/Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift new file mode 100644 index 00000000000..90cbacfd655 --- /dev/null +++ b/Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Pure state machine for Stage 1 local (primary-screen) terminal scrolling on +/// the phone. +/// +/// The phone mirrors the Mac's terminal in its own libghostty surface. In a +/// NORMAL (primary, non-alt) shell a swipe scrolls that locally-held history +/// with no per-frame RPC to the Mac; alt-screen behavior (TUIs) still forwards +/// every scroll so the program owns it. This type owns every gate and latch of +/// that decision so the UIKit view keeps only glue (gesture plumbing and the +/// libghostty C calls) and the logic is unit-testable. +/// +/// One source of truth: the engine tracks only a *read-only scroll position* +/// into history the Mac already authored. It never owns content. Whenever the +/// Mac's live output could disagree with the local view position, the caller +/// asks ``consumeSnapRequest()`` and snaps the surface to the live bottom. +public struct MobileLocalScrollEngine: Sendable { + /// Where a coalesced scroll flush should be routed. + public enum FlushRoute: Equatable, Sendable { + /// Forward the wheel delta to the Mac's real surface (alt screen, or no + /// frame metadata yet so the local mirror holds no history). + case forwardToMac + /// Scroll the phone's own libghostty surface locally, no RPC. + case scrollLocally + } + + /// The result of applying one locally-routed scroll flush. + public struct LocalScrollOutcome: Equatable, Sendable { + /// The tracked offset (rows up from the live bottom) after the flush. + public let upRows: Double + /// True when this flush reached the top of locally-held history and the + /// caller should issue ONE deeper-scrollback fetch (not one per flush). + public let requestDeeperFetch: Bool + } + + /// What an incoming frame's bytes require of the surface, decided just + /// before the bytes apply (in `process_output` order). + public struct SnapDecision: Equatable, Sendable { + /// Snap the surface to the live bottom before applying the frame so its + /// absolute-CUP paint lands in the viewport, not scrolled-up history. + public let snapToLive: Bool + /// When set, re-issue this cumulative upward delta after the frame + /// applies: the frame is a deeper-scrollback fetch's snapshot and the + /// reader should land back on the rows they were reading instead of + /// being bounced to the bottom. + public let restoreUpRows: Double? + } + + /// Whether the mirrored surface's active screen is the alternate screen. + /// Updated from each applied render-grid frame's `activeScreen`. Primary → + /// scroll locally; alternate → forward to the Mac. Defaults to primary (a + /// freshly-attached shell). + public private(set) var isAlternateScreen = false + + /// True once any frame metadata has been received. Older Mac hosts (and the + /// raw-byte compatibility path) never send render-grid metadata, so the + /// phone holds no mirrored history at all; until the first meta arrives, + /// every scroll keeps the legacy forward-to-Mac path instead of scrolling a + /// history-less local mirror that the Mac would never see. + public private(set) var hasReceivedFrameMeta = false + + /// How many scrollback rows above the live viewport the phone believes it + /// currently holds in its local libghostty surface, from each full primary + /// snapshot's `scrollbackRows`. Decides when a local scroll has reached the + /// top of held history and a deeper-scrollback fetch is due. + public private(set) var heldScrollbackRows = 0 + + /// How many rows the phone is currently scrolled up from the live bottom in + /// local (primary-screen) scroll mode, accumulated as a Double so sub-row + /// per-flush residuals are not truncated away. 0 means "at live bottom". + /// Read-only view position into Mac-authored history; never a second source + /// of content. The snap-to-live path uses the exact `scroll_to_bottom` + /// binding action, so this value only needs to be accurate enough to gate + /// ``isLocalScrollActive`` and the deeper-fetch trigger. + public private(set) var upRowsExact: Double = 0 + + /// True while the user is reading local history (scrolled up) in primary + /// mode. While set, an incoming live frame snaps to the bottom before + /// applying (see ``consumeSnapRequest()``). + public var isLocalScrollActive: Bool { upRowsExact >= 0.5 } + + /// Dedupe/retry latch: true while a deeper-scrollback fetch issued by this + /// engine is believed outstanding, so a held pan at the history top fires + /// ONE fetch, not one per flush. Cleared when a full snapshot arrives, and + /// on a fresh pan `.began` as the retry point for a fetch that was dropped + /// (e.g. by the shared replay in-flight guard) and will never produce a + /// snapshot. + private var fetchInFlight = false + + /// Classification latch: true from the moment a deeper-scrollback fetch is + /// issued until the NEXT full snapshot arrives, so that snapshot is measured + /// as the fetch's result (growth vs no-growth) even if the user started a + /// new pan (which clears ``fetchInFlight`` for retry) before the + /// slow-but-valid response landed. Cleared only by a full snapshot. + private var fetchAwaitingSnapshot = false + + /// Scroll position (in the same units as ``upRowsExact``) to restore after a + /// deeper-scrollback fetch's snapshot applies. A full snapshot rebuilds the + /// local surface at the live bottom; without a restore, every history + /// page-in would bounce the reader back to the bottom instead of leaving + /// them on the rows they were reading. The fetch only grows history ABOVE + /// the unchanged live bottom, so re-issuing the same cumulative upward delta + /// lands on the same content rows. Armed when a fetch response is classified + /// (``noteFullSnapshot(scrollbackRows:)``), consumed by + /// ``consumeSnapRequest()`` so the snap, the snapshot apply, and the restore + /// run back-to-back in `process_output` order. The caller delivers a frame's + /// metadata and bytes as ONE ordered stream element (see + /// `MobileTerminalOutputChunk`), so the arm and the consume happen within + /// one synchronous apply: the restore is structurally consumed by the fetch + /// snapshot's own apply, never by an interleaved live frame, and cannot go + /// stale across a gesture. Cold-attach snapshots clear + /// it (content may have changed wholesale; snapping to live is correct). + private var pendingRestoreUpRows: Double? + + /// True once a deeper-scrollback fetch returned no additional history: the + /// shell's whole scrollback is now held locally. Gates the fetch trigger so + /// the view stops cleanly at the oldest known line instead of re-firing an + /// RPC (and re-anchoring to the bottom) on every scroll-to-top. Cleared on + /// genuine growth or a fresh cold-attach snapshot. + private var historyFullyLoaded = false + + public init() {} + + /// Record the active screen from the latest applied frame. Flipping into + /// the alternate screen mid-scroll immediately reverts routing to + /// forwarding (alt scroll must reach the program). The local offset is NOT + /// zeroed here: the surface may still be physically scrolled up, and only + /// ``consumeSnapRequest()`` (which runs in `process_output` order when the + /// next frame's bytes apply) may clear the tracked offset together with + /// snapping the real surface. Zeroing it here would suppress that snap and + /// let the alt program's CUP paints land in scrolled-up history. + public mutating func noteActiveScreen(isAlternate: Bool) { + hasReceivedFrameMeta = true + isAlternateScreen = isAlternate + } + + /// Record how much scrollback the local surface now holds, from a full + /// primary snapshot. If this snapshot is the response to a deeper fetch and + /// it carried no more history than before, the shell's whole scrollback is + /// now held: mark it fully loaded so scroll-to-top stops cleanly instead of + /// bouncing on a re-fetch. A genuinely larger snapshot (or a fresh cold + /// attach that is not a fetch response) clears that ceiling. + /// + /// Deliberately does NOT touch ``upRowsExact``: the snapshot's bytes have + /// not applied yet (the caller applies a frame's metadata immediately + /// before its bytes), so zeroing the offset here would suppress the + /// snap-to-live the caller dispatches (in `process_output` order) when + /// those bytes apply. The offset is only cleared where the surface itself + /// is snapped (``consumeSnapRequest()``), keeping the tracked position and + /// the real surface position in step. + public mutating func noteFullSnapshot(scrollbackRows: Int) { + hasReceivedFrameMeta = true + let newRows = max(0, scrollbackRows) + if fetchAwaitingSnapshot { + fetchAwaitingSnapshot = false + // A fetch that did not grow history means we have reached the + // oldest line the Mac can supply for now. + historyFullyLoaded = newRows <= heldScrollbackRows + // The reader was up in history when this fetch's snapshot was + // built; arm a restore so the snapshot apply (which rebuilds the + // surface at the live bottom) puts them back on the rows they were + // reading. The live bottom is unchanged by a deeper fetch, so the + // same cumulative upward delta lands on the same content rows; + // libghostty clamps at the top of whatever it actually holds. + pendingRestoreUpRows = isLocalScrollActive ? upRowsExact : nil + } else { + // Not a fetch response (cold attach / live full snapshot): history + // may have changed underneath us, so re-open the ceiling and snap + // to live rather than restoring a position into stale content. + historyFullyLoaded = false + pendingRestoreUpRows = nil + } + fetchInFlight = false + heldScrollbackRows = newRows + } + + /// A fresh swipe is the natural retry point for a deeper-scrollback fetch + /// that never returned (e.g. dropped by the shared replay in-flight guard). + /// Clears only the dedupe latch; the classification latch stays set so a + /// slow-but-valid response is still measured as a fetch result rather than + /// misread as a cold attach. A fetch that is genuinely still in flight is + /// deduped by the shared replay guard downstream. + public mutating func notePanBegan() { + fetchInFlight = false + } + + /// Where the next coalesced scroll flush should go. Alt screen forwards to + /// the Mac (the program owns alt-screen scroll); so does the + /// no-metadata-yet compatibility path (the local mirror holds no history + /// and the Mac would never see the gesture otherwise). Primary scrolls + /// locally. + public var flushRoute: FlushRoute { + (isAlternateScreen || !hasReceivedFrameMeta) ? .forwardToMac : .scrollLocally + } + + /// Apply one locally-routed flush of `lines` (positive = up into history, + /// matching the wheel-delta convention `ghostty_surface_mouse_scroll` uses + /// on the Mac). Tracks the read-only view position as a Double (no + /// per-flush rounding), clamped at 0 (live bottom). libghostty clamps at + /// the top of the history it actually holds, so over-scrolling never shows + /// blank rows; it just stops at the oldest held line until a deeper fetch + /// lands. + /// + /// Reaching (or passing) the top of locally-held history while scrolling up + /// requests ONE deeper-scrollback fetch (not per-flush): suppressed once a + /// fetch returned no growth (history fully loaded) or while one is already + /// outstanding, so a short-scrollback shell stops cleanly at the oldest + /// line instead of bouncing to the bottom on every scroll-to-top. + public mutating func applyLocalScroll(lines: Double) -> LocalScrollOutcome { + let priorUpRows = upRowsExact + let nextUpRows = max(0, priorUpRows + lines) + upRowsExact = nextUpRows + + var requestDeeperFetch = false + if lines > 0, + nextUpRows >= Double(heldScrollbackRows), + nextUpRows > priorUpRows, + !historyFullyLoaded, + !fetchInFlight { + fetchInFlight = true + fetchAwaitingSnapshot = true + requestDeeperFetch = true + } + return LocalScrollOutcome(upRows: nextUpRows, requestDeeperFetch: requestDeeperFetch) + } + + /// Decide what an incoming frame's bytes require of the surface, just + /// before they apply. If the reader is scrolled up locally, the surface + /// must snap to the live bottom first so the frame's absolute-CUP paint + /// lands in the viewport; if a deeper-fetch restore is armed, the caller + /// re-issues the preserved upward delta after the apply. Deliberately NOT + /// gated on the active screen: a stale offset left by an alt-flip + /// mid-scroll must still snap before the alt program's rows paint. This is + /// the only place the tracked offset is cleared (or re-armed to the restore + /// value), so it stays in step with the real surface position. + public mutating func consumeSnapRequest() -> SnapDecision { + guard isLocalScrollActive else { + return SnapDecision(snapToLive: false, restoreUpRows: nil) + } + upRowsExact = 0 + var restore: Double? + if let pending = pendingRestoreUpRows { + pendingRestoreUpRows = nil + restore = pending + upRowsExact = pending + } + return SnapDecision(snapToLive: true, restoreUpRows: restore) + } +} diff --git a/Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileLocalScrollEngineTests.swift b/Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileLocalScrollEngineTests.swift new file mode 100644 index 00000000000..02956a308e0 --- /dev/null +++ b/Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileLocalScrollEngineTests.swift @@ -0,0 +1,223 @@ +import Foundation +import Testing + +@testable import CmuxMobileTerminalKit + +@Suite("MobileLocalScrollEngine") +struct MobileLocalScrollEngineTests { + // MARK: - Routing gates + + @Test("no frame metadata yet forwards to the Mac (older host / raw-byte path)") + func noMetaForwards() { + let engine = MobileLocalScrollEngine() + #expect(engine.flushRoute == .forwardToMac) + } + + @Test("primary screen with metadata scrolls locally") + func primaryScrollsLocally() { + var engine = MobileLocalScrollEngine() + engine.noteActiveScreen(isAlternate: false) + #expect(engine.flushRoute == .scrollLocally) + } + + @Test("alternate screen forwards to the Mac") + func alternateForwards() { + var engine = MobileLocalScrollEngine() + engine.noteActiveScreen(isAlternate: true) + #expect(engine.flushRoute == .forwardToMac) + } + + @Test("alt-flip mid-scroll reverts routing but keeps the tracked offset for the snap") + func altFlipMidScroll() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 100) + _ = engine.applyLocalScroll(lines: 10) + engine.noteActiveScreen(isAlternate: true) + #expect(engine.flushRoute == .forwardToMac) + // The surface is still physically scrolled up; only the snap path may + // clear the offset, so the next frame still snaps to live. + #expect(engine.isLocalScrollActive) + let snap = engine.consumeSnapRequest() + #expect(snap.snapToLive) + #expect(engine.upRowsExact == 0) + } + + // MARK: - Offset accumulation and clamps + + @Test("scrolling down below the live bottom clamps at zero") + func clampsAtLiveBottom() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 100) + _ = engine.applyLocalScroll(lines: 5) + let outcome = engine.applyLocalScroll(lines: -50) + #expect(outcome.upRows == 0) + #expect(!engine.isLocalScrollActive) + } + + @Test("sub-row residuals accumulate without truncation") + func subRowResidualsAccumulate() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 100) + for _ in 0..<10 { + _ = engine.applyLocalScroll(lines: 0.3) + } + #expect(abs(engine.upRowsExact - 3.0) < 0.0001) + } + + @Test("below half a row is not local-scroll active") + func belowHalfRowInactive() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 100) + _ = engine.applyLocalScroll(lines: 0.4) + #expect(!engine.isLocalScrollActive) + _ = engine.applyLocalScroll(lines: 0.2) + #expect(engine.isLocalScrollActive) + } + + // MARK: - Deeper-fetch trigger + + @Test("reaching the top of held history requests one deeper fetch, not one per flush") + func fetchFiresOnceAtTop() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + let first = engine.applyLocalScroll(lines: 12) + #expect(first.requestDeeperFetch) + // Held pan keeps flushing past the top: deduped by the in-flight latch. + let second = engine.applyLocalScroll(lines: 3) + #expect(!second.requestDeeperFetch) + } + + @Test("scrolling down never requests a fetch") + func downwardNeverFetches() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 0) + let outcome = engine.applyLocalScroll(lines: -3) + #expect(!outcome.requestDeeperFetch) + } + + @Test("a fresh pan retries a fetch that never produced a snapshot") + func panBeganRetriesDroppedFetch() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + #expect(!engine.applyLocalScroll(lines: 1).requestDeeperFetch) + engine.notePanBegan() + #expect(engine.applyLocalScroll(lines: 1).requestDeeperFetch) + } + + @Test("a no-growth fetch response closes the ceiling: stop cleanly at the oldest line") + func noGrowthClosesCeiling() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + // Fetch response: same held rows -> whole scrollback is local now. + engine.noteFullSnapshot(scrollbackRows: 10) + engine.notePanBegan() + #expect(!engine.applyLocalScroll(lines: 5).requestDeeperFetch) + } + + @Test("a growing fetch response keeps paging in deeper history") + func growthKeepsPaging() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + engine.noteFullSnapshot(scrollbackRows: 200) + engine.notePanBegan() + // Not yet past the new top: no fetch. + #expect(!engine.applyLocalScroll(lines: 50).requestDeeperFetch) + // Past the new top: pages in again. + #expect(engine.applyLocalScroll(lines: 200).requestDeeperFetch) + } + + @Test("a slow fetch response is still classified as a fetch after a new pan began") + func slowFetchStillClassified() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + // User starts a new pan before the response lands; the classification + // latch must survive so the response is measured as a fetch result. + engine.notePanBegan() + engine.noteFullSnapshot(scrollbackRows: 10) + // No growth -> ceiling closed, not misread as a cold attach. + #expect(!engine.applyLocalScroll(lines: 5).requestDeeperFetch) + } + + @Test("a cold-attach snapshot re-opens the ceiling") + func coldAttachReopensCeiling() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + engine.noteFullSnapshot(scrollbackRows: 10) // fetch result: ceiling closed + engine.noteFullSnapshot(scrollbackRows: 10) // cold attach: ceiling re-opens + engine.notePanBegan() + #expect(engine.applyLocalScroll(lines: 1).requestDeeperFetch) + } + + // MARK: - Snap-to-live and restore + + @Test("at the live bottom an incoming frame does not snap") + func noSnapAtBottom() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 100) + let snap = engine.consumeSnapRequest() + #expect(!snap.snapToLive) + #expect(snap.restoreUpRows == nil) + } + + @Test("scrolled up, a live frame snaps to live and clears the offset") + func liveFrameSnaps() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 100) + _ = engine.applyLocalScroll(lines: 20) + let snap = engine.consumeSnapRequest() + #expect(snap.snapToLive) + #expect(snap.restoreUpRows == nil) + #expect(engine.upRowsExact == 0) + // Idempotent: the next frame does not snap again. + #expect(!engine.consumeSnapRequest().snapToLive) + } + + @Test("a deeper-fetch snapshot restores the reader's position instead of bouncing to bottom") + func fetchSnapshotRestoresPosition() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + // Fetch response grew history; reader was 12 rows up when it was built. + engine.noteFullSnapshot(scrollbackRows: 200) + let snap = engine.consumeSnapRequest() + #expect(snap.snapToLive) + #expect(snap.restoreUpRows == 12) + // The tracked offset re-arms to the restored position so a later live + // frame still snaps the reader back to live. + #expect(engine.upRowsExact == 12) + let next = engine.consumeSnapRequest() + #expect(next.snapToLive) + #expect(next.restoreUpRows == nil) + } + + @Test("a cold-attach snapshot clears an armed restore (content may have changed)") + func coldAttachClearsRestore() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + engine.noteFullSnapshot(scrollbackRows: 200) // fetch result: restore armed + engine.noteFullSnapshot(scrollbackRows: 50) // cold attach: restore dropped + let snap = engine.consumeSnapRequest() + #expect(snap.snapToLive) + #expect(snap.restoreUpRows == nil) + #expect(engine.upRowsExact == 0) + } + + @Test("a fetch response while the reader returned to bottom arms no restore") + func fetchAtBottomArmsNoRestore() { + var engine = MobileLocalScrollEngine() + engine.noteFullSnapshot(scrollbackRows: 10) + #expect(engine.applyLocalScroll(lines: 12).requestDeeperFetch) + // Reader returns to the live bottom before the response lands. + _ = engine.applyLocalScroll(lines: -20) + engine.noteFullSnapshot(scrollbackRows: 200) + let snap = engine.consumeSnapRequest() + #expect(!snap.snapToLive) + #expect(snap.restoreUpRows == nil) + } +} diff --git a/Sources/Mobile/MobileReplayScrollbackBudget.swift b/Sources/Mobile/MobileReplayScrollbackBudget.swift new file mode 100644 index 00000000000..57fb7eb56b8 --- /dev/null +++ b/Sources/Mobile/MobileReplayScrollbackBudget.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Scrollback budgets for `mobile.terminal.replay` render-grid snapshots. +enum MobileReplayScrollbackBudget { + /// Scrollback rows included in a cold-attach render-grid replay snapshot. + /// Live render-grid events carry no scrollback (the client already has it); + /// only the replay anchor needs history. Kept minimal on purpose: a + /// freshly-attached device gets the live screen immediately, and deeper + /// history is a follow-up (the phone pages it in on scroll-to-top). + /// Tune up to trade replay payload size for more attach-time history. + static let attachLineBudget = 1 + + /// Upper bound on the scrollback a single `mobile.terminal.replay` may + /// carry when the phone requests a deeper-history fetch for local scrolling + /// (Stage 1 smooth scroll). Bounds the replay payload so a hostile or + /// runaway `scrollback_lines` request can't bloat one frame; the phone + /// pages in chunks rather than asking for unbounded history. + static let fetchLineBudgetMax = 2000 + + /// Clamp a phone-requested deeper-history budget. Absent or invalid → + /// the minimal attach-time budget. + static func clamped(requested: Int?) -> Int { + guard let requested else { return attachLineBudget } + return max(attachLineBudget, min(requested, fetchLineBudgetMax)) + } +} diff --git a/Sources/Mobile/MobileTerminalRenderObserver.swift b/Sources/Mobile/MobileTerminalRenderObserver.swift index 1bc75d940a8..29c417300ca 100644 --- a/Sources/Mobile/MobileTerminalRenderObserver.swift +++ b/Sources/Mobile/MobileTerminalRenderObserver.swift @@ -211,7 +211,13 @@ final class MobileTerminalRenderObserver { cursor: snapshot.frame.cursor, full: false, styles: snapshot.frame.styles, - rowSpans: [] + rowSpans: [], + // Carry the real active screen: the phone gates local + // (primary) vs forward-to-Mac (alternate) scrolling on every + // frame's `activeScreen`, so a no-row-change frame defaulting + // to `.primary` would silently flip an alt-screen TUI back to + // local scrolling. + activeScreen: snapshot.frame.activeScreen ) else { return } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 701b2acb362..5e007dc8811 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -9194,19 +9194,11 @@ class TerminalController { return output } - /// Scrollback rows included in a cold-attach render-grid replay snapshot. - /// Live render-grid events carry no scrollback (the client already has it); - /// only the replay anchor needs history. Kept minimal on purpose: a - /// freshly-attached device gets the live screen immediately, and deeper - /// history is a follow-up (incremental scrollback paging on scroll-to-top). - /// Tune up to trade replay payload size for more attach-time history. - nonisolated static let mobileReplayScrollbackLineBudget = 1 - private func mobileTerminalRenderGridFrame( terminalPanel: TerminalPanel, surfaceID: UUID, seq: UInt64, - scrollbackLines: Int = TerminalController.mobileReplayScrollbackLineBudget + scrollbackLines: Int = MobileReplayScrollbackBudget.attachLineBudget ) -> MobileTerminalRenderGridFrame? { guard surfaceID == terminalPanel.id else { return nil } return terminalPanel.surface.mobileRenderGridFrame( @@ -21538,13 +21530,20 @@ class TerminalController { } let state = MobileTerminalByteTee.shared.replayState(surfaceID: surfaceId) let seq = state?.seq ?? 0 + // Optional deeper-history fetch for mobile local-scroll (Stage 1 smooth + // scroll): the phone re-requests its render-grid with a larger clamped + // scrollback budget when it scrolls to the top of locally-held history, + // so the primary-screen full-snapshot reflow lands deep scrollback in + // the phone's own libghostty surface and it can scroll offline. + let scrollbackLines = MobileReplayScrollbackBudget.clamped(requested: v2Int(params, "scrollback_lines")) let renderGrid = mobileTerminalRenderGridFrame( terminalPanel: terminalPanel, surfaceID: surfaceId, - seq: seq + seq: seq, + scrollbackLines: scrollbackLines ) #if DEBUG - cmuxDebugLog("mobile.terminal.replay surface=\(surfaceId.uuidString.prefix(8)) renderGrid=\(renderGrid != nil) seq=\(seq) hasState=\(state != nil)") + cmuxDebugLog("mobile.terminal.replay surface=\(surfaceId.uuidString.prefix(8)) renderGrid=\(renderGrid != nil) seq=\(seq) hasState=\(state != nil) scrollback=\(scrollbackLines)") #endif var payload: [String: Any] = [ "workspace_id": resolved.workspace.id.uuidString, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 47b35afa107..ae45e8a5a25 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -427,6 +427,7 @@ 325AF8814443BDA97AC3CC98 /* MobilePairingQRImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76EC5E03C345BEAC25828A7 /* MobilePairingQRImageView.swift */; }; A2538FE83AE79539E3BF9248 /* MobilePairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9BC53FA4991A6EBFB930EDF /* MobilePairingView.swift */; }; 18A8E29E9BD87E184983527C /* MobilePairingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE701F43A0B49F08835191C /* MobilePairingWindowController.swift */; }; + 5AD2E51BF3656E8C57E69F04 /* MobileReplayScrollbackBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F25293DC3CED1A6F483B756 /* MobileReplayScrollbackBudget.swift */; }; 9916B44C7A9914E172D112D4 /* MobileRouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A956C76162B79EC74AF9965 /* MobileRouteResolver.swift */; }; AB57F0C27BECE8EDFC6B2B97 /* MobileTerminalByteTee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05EBBC2FE0D5CB2F662B8A /* MobileTerminalByteTee.swift */; }; C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */; }; @@ -1161,6 +1162,7 @@ B76EC5E03C345BEAC25828A7 /* MobilePairingQRImageView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingQRImageView.swift"; sourceTree = ""; }; F9BC53FA4991A6EBFB930EDF /* MobilePairingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingView.swift"; sourceTree = ""; }; 5AE701F43A0B49F08835191C /* MobilePairingWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingWindowController.swift"; sourceTree = ""; }; + 0F25293DC3CED1A6F483B756 /* MobileReplayScrollbackBudget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileReplayScrollbackBudget.swift; sourceTree = ""; }; 7A956C76162B79EC74AF9965 /* MobileRouteResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileRouteResolver.swift; sourceTree = ""; }; 4F05EBBC2FE0D5CB2F662B8A /* MobileTerminalByteTee.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalByteTee.swift; sourceTree = ""; }; F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalRenderObserver.swift; sourceTree = ""; }; @@ -1608,6 +1610,7 @@ C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */, F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */, 4F05EBBC2FE0D5CB2F662B8A /* MobileTerminalByteTee.swift */, + 0F25293DC3CED1A6F483B756 /* MobileReplayScrollbackBudget.swift */, ); name = Mobile; path = Mobile; @@ -2882,6 +2885,7 @@ 325AF8814443BDA97AC3CC98 /* MobilePairingQRImageView.swift in Sources */, A2538FE83AE79539E3BF9248 /* MobilePairingView.swift in Sources */, 18A8E29E9BD87E184983527C /* MobilePairingWindowController.swift in Sources */, + 5AD2E51BF3656E8C57E69F04 /* MobileReplayScrollbackBudget.swift in Sources */, 9916B44C7A9914E172D112D4 /* MobileRouteResolver.swift in Sources */, AB57F0C27BECE8EDFC6B2B97 /* MobileTerminalByteTee.swift in Sources */, C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */, diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 9f7d075941b..85c6182b385 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -25,8 +25,8 @@ final class TerminalOutputCollector { /// Begin consuming the surface's output stream into ``lines``. func mount(store: CMUXMobileShellStore, surfaceID: String) { task = Task { @MainActor [weak self] in - for await data in store.terminalOutputStream(surfaceID: surfaceID) { - self?.lines.append(String(data: data, encoding: .utf8) ?? "") + for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { + self?.lines.append(String(data: chunk.bytes, encoding: .utf8) ?? "") } } }