Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2c43d31
Decouple iOS primary terminal scroll
lawrencecchen Jun 14, 2026
eee05fb
Configure iOS dev API base URL
lawrencecchen Jun 14, 2026
25eb92e
Fix iOS native auth LAN callback
lawrencecchen Jun 14, 2026
2438ab6
Fix native auth route tests
lawrencecchen Jun 14, 2026
171d624
Add iOS science demo bypass
lawrencecchen Jun 14, 2026
8e4636f
Improve iOS science demo scrollback
lawrencecchen Jun 14, 2026
a8b2ead
Update Ghostty pixel scroll renderer
lawrencecchen Jun 14, 2026
a1697cc
Enable desktop smooth scroll path
lawrencecchen Jun 14, 2026
c156503
Use wheel events for desktop smooth scroll
lawrencecchen Jun 14, 2026
f82bcbf
Use fractional row offsets for desktop smooth scroll
lawrencecchen Jun 14, 2026
aff8198
Add debug fractional scroll verification hook
lawrencecchen Jun 14, 2026
b6e9f97
Fix smooth scroll fractional direction
lawrencecchen Jun 14, 2026
f73e2c3
Preload Ghostty rows for smooth scroll edges
lawrencecchen Jun 15, 2026
415b28b
Add iOS instant terminal scroll mode
lawrencecchen Jun 15, 2026
c617afa
Hydrate iOS terminal replay with full scrollback
lawrencecchen Jun 15, 2026
d0797e3
Fix pixel scroll boundary clamp
lawrencecchen Jun 16, 2026
ed18dda
Retry mobile replay after workspace mapping
lawrencecchen Jun 16, 2026
6b54974
Fix mobile dogfood attach during session restore
lawrencecchen Jun 16, 2026
a7c2a20
Merge remote-tracking branch 'origin/main' into feat-ios-smooth-scrol…
lawrencecchen Jun 16, 2026
e05ca47
Fix iOS local scrollback offset control
lawrencecchen Jun 16, 2026
c3b8822
Gate iOS replay on terminal geometry
lawrencecchen Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ extension MobileShellComposite {
terminalOutputQueuesBySurfaceID[surfaceID] = queue
if let immediate {
continuation.yield(
MobileTerminalOutputChunk(data: immediate.bytes, streamToken: streamToken)
MobileTerminalOutputChunk(
data: immediate.bytes,
streamToken: streamToken,
activeScreen: immediate.activeScreen
)
)
}
}
Expand All @@ -45,6 +49,10 @@ extension MobileShellComposite {
terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else {
return
}
continuation.yield(MobileTerminalOutputChunk(data: next.bytes, streamToken: streamToken))
continuation.yield(MobileTerminalOutputChunk(
data: next.bytes,
streamToken: streamToken,
activeScreen: next.activeScreen
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ struct TerminalOutputDelivery: Equatable, Sendable {
frame.vtPatchBytes()
}
}

var activeScreen: MobileTerminalRenderGridFrame.Screen? {
switch payload {
case .bytes:
nil
case .renderGrid(let frame):
frame.activeScreen
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/// Backpressure queue for one mounted mobile terminal output stream.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ import Testing
#expect(!vt.contains("old"))
}

@Test func terminalOutputDeliveryCarriesRenderGridActiveScreen() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal",
stateSeq: 1,
columns: 12,
rows: 2,
rowSpans: [],
activeScreen: .alternate
)
let delivery = TerminalOutputDelivery(renderGrid: frame, replaceable: false)
let rawDelivery = TerminalOutputDelivery(bytes: Data("raw".utf8), replaceable: false)

#expect(delivery.activeScreen == .alternate)
#expect(rawDelivery.activeScreen == nil)
}

@Test func terminalOutputQueuePreservesNonreplaceableBarriers() {
var queue = TerminalOutputDeliveryQueue()
let inFlight = TerminalOutputDelivery(bytes: Data("in-flight".utf8), replaceable: false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
public import CMUXMobileCore
public import Foundation

/// A seam exposing per-surface terminal output as an `AsyncStream`.
Expand All @@ -14,10 +15,18 @@ public import Foundation
/// propagation is a structured, cancellable `AsyncSequence` instead of a stored
/// callback.
public struct MobileTerminalOutputChunk: Sendable {
/// The active terminal screen captured by the render-grid frame that
/// produced ``data``. Raw byte fallback chunks carry `nil`.
public let activeScreen: MobileTerminalRenderGridFrame.Screen?
public let data: Data
public let streamToken: UUID

public init(data: Data, streamToken: UUID) {
public init(
data: Data,
streamToken: UUID,
activeScreen: MobileTerminalRenderGridFrame.Screen? = nil
) {
self.activeScreen = activeScreen
self.data = data
self.streamToken = streamToken
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
for await chunk in store.terminalOutputStream(surfaceID: surfaceID) {
guard !Task.isCancelled else { return }
guard let surfaceView else { return }
surfaceView.applyTerminalOutputMetadata(activeScreen: chunk.activeScreen)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply screen metadata after processing output

When a render-grid chunk switches screens, this updates activeScreen before the VT patch has been applied to the local Ghostty surface. While processOutputAndWait is still queued/running, a user scroll can hit the old local surface state but the new forwarding policy; for example an exit-from-alt-screen chunk marks the view primary, the still-alt local scroll drops the wheel bytes, and the guard suppresses the host scroll, so the gesture is lost. Apply the metadata only after the chunk has been processed, or make the local scroll/forward decision use the same committed screen state.

Useful? React with 👍 / 👎.

await surfaceView.processOutputAndWait(chunk.data)

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 Metadata update races VT bytes during processOutputAndWait suspension

applyTerminalOutputMetadata commits the new activeScreen (e.g., .primary) before the await processOutputAndWait suspension returns. Because the @MainActor is free to interleave work during that suspension, a display-link scroll flush can fire in the window where activeScreen already says .primary but local Ghostty is still rendering the alternate surface. In that state flushPendingScrollIfNeeded calls applyLocalScrollbackScroll on what is still an alternate-screen surface, while simultaneously suppressing host forwarding — the net effect is a scroll event that neither reaches the TUI nor lands usefully in the local scrollback. Swapping the call order so metadata is applied after processOutputAndWait closes this window.

store.terminalOutputDidProcess(
surfaceID: surfaceID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
private var lastAppliedContentScale: CGFloat = 0
private var surfaceHasReceivedOutput: Bool = false
private var shouldScrollInitialOutputToBottom = true
private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary

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 Stale activeScreen survives stream tear-down

activeScreen is never reset when the output stream is detached and reattached (e.g., after a network drop or background transition). If the previous session ended while the terminal was in .alternate mode, the property stays .alternate until the first render-grid frame arrives in the new stream. During that window every primary-screen scroll gesture is forwarded to the host instead of staying local. Consider resetting activeScreen to .primary in the view's stream-teardown path, or alternatively applying the reset at the start of each new stream token before yielding the first chunk.

private let scrollForwardingPolicy = MobileTerminalScrollForwardingPolicy()
/// Serial background queue for `ghostty_surface_process_output`, which
Comment on lines +605 to 611

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

Handle unknown screen metadata as “forward” to preserve raw-byte fallback behavior.

Line 625 initializes activeScreen to .primary, and Line 2127 drops nil metadata. For raw-byte fallback chunks (where metadata is nil), Line 1816 always treats scroll as primary and suppresses host forwarding, which breaks alternate-screen TUI wheel events on legacy-host compatibility paths.

💡 Suggested fix
-    private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary
+    private var activeScreen: MobileTerminalRenderGridFrame.Screen?
@@
-        guard scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) else {
-            return
-        }
+        if let activeScreen,
+           !scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) {
+            return
+        }
@@
     public func applyTerminalOutputMetadata(
         activeScreen: MobileTerminalRenderGridFrame.Screen?
     ) {
         guard let activeScreen else { return }
         self.activeScreen = activeScreen
     }

Also applies to: 1816-1819, 2124-2129

🤖 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/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`
around lines 625 - 627, The code currently defaults to treating unknown screen
metadata (when nil) as primary screen and suppressing host forwarding, breaking
alternate-screen TUI wheel events on legacy hosts. Fix this by modifying the
scroll handling logic to forward scroll events when screen metadata is unknown
instead of suppressing them. At the activeScreen initialization site (lines
625-627), ensure the default handling accommodates nil metadata. At the scroll
processing logic (lines 1816-1819), modify the condition to forward scroll
events when metadata is nil rather than assuming primary screen behavior. At the
metadata handling site (lines 2124-2129), ensure nil metadata is preserved
through the pipeline rather than dropped so downstream logic can apply
forward-fallback behavior.

/// blocks on libghostty's internal renderer/IO futex. Running it on the
/// main thread hangs the app until the scene-update watchdog kills it.
Expand Down Expand Up @@ -1811,6 +1813,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
let cell = pendingScrollCell
pendingScrollLines = 0
applyLocalScrollbackScroll(lines: lines, col: cell.col, row: cell.row)
guard scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve alternate-screen state before gating scrolls

Here activeScreen is not authoritative for all chunks yet: raw-byte fallback events carry nil so the view remains at its default .primary, and render-grid cursor-only/no-row-change deltas are built in MobileTerminalRenderObserver.emitRenderGrid without snapshot.frame.activeScreen, so they encode .primary even while a TUI is still on the alternate screen. In both cases an alternate-screen app such as vim/less/htop stops receiving wheel events after that chunk because this returns before didScrollLines; keep forwarding for unknown state and preserve the screen on every delta before using it to suppress host scrolls.

Useful? React with 👍 / 👎.

return
}
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve scrollback prefetch for primary-screen scrolls

When activeScreen is .primary, this new guard returns before the delegate call, so GhosttySurfaceRepresentable never invokes store.scrollTerminal(...). That RPC path is also the only place that primes and refreshes the larger max_scrollback_rows render-grid prefetch, so after a user scrolls beyond the cold-attach local scrollback window the phone has no way to fetch older primary-screen history and scrollback stops short even though the Mac still has it.

Useful? React with 👍 / 👎.

delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row)
}

Expand Down Expand Up @@ -2109,6 +2114,20 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
processOutput(data, completion: nil)
}

/// Applies metadata attached to the next terminal output chunk.
///
/// Render-grid output carries the authoritative active screen, which lets
/// local scrollback stay phone-local on the primary screen while alternate
/// screen TUIs still receive host mouse-wheel events.
/// - Parameter activeScreen: The active screen from the render-grid frame,
/// or `nil` for raw byte fallback chunks.
public func applyTerminalOutputMetadata(
activeScreen: MobileTerminalRenderGridFrame.Screen?
) {
guard let activeScreen else { return }
self.activeScreen = activeScreen
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

/// Process terminal output and return after the output has been applied.
///
/// The call still performs libghostty output processing on the serial
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
public import CMUXMobileCore

/// Decides whether a mobile terminal scroll gesture must be sent to the Mac.
public struct MobileTerminalScrollForwardingPolicy: Sendable {
/// Creates the forwarding policy.
public init() {}

/// Returns whether a scroll should be forwarded to the host surface.
///
/// Primary-screen scrollback is already mirrored into the phone's local
/// Ghostty surface, so forwarding would make scroll feel network-bound.
/// Alternate-screen scroll must still reach the host so TUIs with mouse
/// reporting receive wheel events.
/// - Parameter activeScreen: The screen currently rendered by the mobile
/// Ghostty mirror.
/// - Returns: `true` when the scroll should be sent to the Mac.
public func shouldForwardToHost(
activeScreen: MobileTerminalRenderGridFrame.Screen
) -> Bool {
activeScreen == .alternate
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import CMUXMobileCore
import CmuxMobileTerminalKit
import Testing

@Suite struct MobileTerminalScrollForwardingPolicyTests {
@Test func primaryScreenScrollStaysLocal() {
let policy = MobileTerminalScrollForwardingPolicy()

#expect(policy.shouldForwardToHost(activeScreen: .primary) == false)
}

@Test func alternateScreenScrollForwardsToHost() {
let policy = MobileTerminalScrollForwardingPolicy()

#expect(policy.shouldForwardToHost(activeScreen: .alternate))
}
}
Loading