Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
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
@@ -0,0 +1,15 @@
public enum MobileTerminalScrollbackBudget {
/// Small fallback used when an older client asks for replay without an
/// explicit scrollback window.
public static let defaultReplayRows = 240

/// Bounded repair window for host-coupled scroll responses. The decoupled
/// primary-screen path should not depend on this during normal iPhone
/// scrolling.
public static let scrollPrefetchRows = 600
}

public enum MobileTerminalScrollbackReplayRequest {
public static let scopeParameter = "scrollback_scope"
public static let fullScope = "full"
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ extension ControlCommandCoordinator {
return debugSimulateTerminalFileDrop(request.params)
case "debug.terminal.read_text":
return debugReadTerminalText(request.params)
case "debug.terminal.scroll_to_row_offset":
return debugTerminalScrollToRowOffset(request.params)
case "debug.terminal.render_stats":
return debugRenderStats(request.params)
case "debug.layout":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,28 @@ extension ControlCommandCoordinator {
return .ok(.object(["base64": .string(b64)]))
}

/// `debug.terminal.scroll_to_row_offset` — drive Ghostty's fractional
/// row-offset scroll API for renderer verification.
func debugTerminalScrollToRowOffset(_ params: [String: JSONValue]) -> ControlCallResult {
let surfaceArg = string(params, "surface_id") ?? ""
guard let rowOffset = double(params, "row_offset"), rowOffset.isFinite else {
return .err(code: "invalid_params", message: "Missing or invalid row_offset", data: nil)
}
let didScroll = debugContext?.controlDebugTerminalScrollToRowOffset(
surfaceArgument: surfaceArg,
rowOffset: rowOffset
) ?? false
guard didScroll else {
return .err(code: "not_found", message: "Terminal surface not found", data: .object([
"surface_id": .string(surfaceArg)
]))
}
return .ok(.object([
"surface_id": .string(surfaceArg),
"row_offset": .double(rowOffset)
]))
}

/// `debug.terminal.render_stats` — renderer stats (shared v1 body's JSON,
/// decoded exactly as the legacy wrapper did).
func debugRenderStats(_ params: [String: JSONValue]) -> ControlCallResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ public protocol ControlDebugContext: AnyObject {
/// - Returns: The raw v1 response (`"OK <base64>"` or an `ERROR:` line).
func controlDebugReadTerminalText(surfaceArgument: String) -> String

/// Drives a terminal's Ghostty scrollback to a fractional row offset for
/// renderer verification.
///
/// - Parameters:
/// - surfaceArgument: The surface id/index argument (may be empty for the
/// focused surface).
/// - rowOffset: Fractional row offset from the top of primary scrollback.
/// - Returns: `true` when the target terminal exists and has a live surface.
func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool

/// Runs the shared v1 `render_stats` body for `debug.terminal.render_stats`.
///
/// - Parameter surfaceArgument: The surface id/index argument.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension ControlDebugContext {
func controlDebugActivateApp() -> String { "ERROR: not implemented" }
func controlDebugIsTerminalFocused(surfaceArgument: String) -> String { "ERROR: not implemented" }
func controlDebugReadTerminalText(surfaceArgument: String) -> String { "ERROR: not implemented" }
func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool { false }
func controlDebugRenderStats(surfaceArgument: String) -> String { "ERROR: not implemented" }
func controlDebugLayout() -> String { "ERROR: not implemented" }
func controlDebugBonsplitUnderflowCount() -> String { "ERROR: not implemented" }
Expand Down
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 @@ -534,6 +534,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64]
private var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64]
private var terminalReplaySurfaceIDsInFlight: Set<String>
private var terminalReplaySurfaceIDsPendingWorkspaceMapping: Set<String>
private var terminalOutputTransport: TerminalOutputTransport
var terminalByteContinuationsBySurfaceID: [String: AsyncStream<MobileTerminalOutputChunk>.Continuation]
var terminalOutputStreamTokensBySurfaceID: [String: UUID]
Expand Down Expand Up @@ -661,6 +662,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.deliveredTerminalByteEndSeqBySurfaceID = [:]
self.pendingTerminalByteEndSeqBySurfaceID = [:]
self.terminalReplaySurfaceIDsInFlight = []
self.terminalReplaySurfaceIDsPendingWorkspaceMapping = []
self.terminalOutputTransport = .rawBytes
self.terminalByteContinuationsBySurfaceID = [:]
self.terminalOutputStreamTokensBySurfaceID = [:]
Expand Down Expand Up @@ -3076,6 +3078,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
deliveredTerminalByteEndSeqBySurfaceID = [:]
pendingTerminalByteEndSeqBySurfaceID = [:]
terminalReplaySurfaceIDsInFlight = []
terminalReplaySurfaceIDsPendingWorkspaceMapping = []
terminalOutputQueuesBySurfaceID = [:]
terminalOutputStreamTokensBySurfaceID = terminalOutputStreamTokensBySurfaceID.mapValues { _ in UUID() }
terminalScrollQueueTokensBySurfaceID = [:]
Expand Down Expand Up @@ -4258,6 +4261,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

private func retryTerminalReplaysPendingWorkspaceMapping(reason: String) {
guard remoteClient != nil, connectionState == .connected else { return }
let surfaceIDs = terminalReplaySurfaceIDsPendingWorkspaceMapping
.filter { hasTerminalOutputSink(surfaceID: $0) && workspaceID(forTerminalID: $0) != nil }
guard !surfaceIDs.isEmpty else { return }
MobileDebugLog.anchormux(
"sync.replay_mapping_ready reason=\(reason) surfaces=\(surfaceIDs.count)"
)
for surfaceID in surfaceIDs {
requestTerminalReplay(surfaceID: surfaceID)
}
}

private func handleTerminalInputResponse(_ data: Data, surfaceID: String) {
guard hasTerminalOutputSink(surfaceID: surfaceID),
let payload = try? MobileTerminalInputResponse.decode(data),
Expand Down Expand Up @@ -4371,6 +4387,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalScrollbackPrefetchStatesBySurfaceID.removeValue(forKey: surfaceID)
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
terminalReplaySurfaceIDsPendingWorkspaceMapping.remove(surfaceID)
// Tell the Mac this device is no longer viewing the surface so it stops
// pinning the shared grid to our viewport and clears the macOS border.
clearTerminalViewport(surfaceID: surfaceID)
Expand Down Expand Up @@ -4469,11 +4486,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return
}
guard let workspaceID = workspaceID(forTerminalID: surfaceID) else {
terminalReplaySurfaceIDsPendingWorkspaceMapping.insert(surfaceID)
#if DEBUG
mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found")
#endif
return
}
terminalReplaySurfaceIDsPendingWorkspaceMapping.remove(surfaceID)
guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else {
#if DEBUG
mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight")
Expand All @@ -4490,6 +4509,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
params: [
"workspace_id": workspaceID.rawValue,
"surface_id": surfaceID,
MobileTerminalScrollbackReplayRequest.scopeParameter:
MobileTerminalScrollbackReplayRequest.fullScope,
Comment thread
cursor[bot] marked this conversation as resolved.
]
)
let data = try await client.sendRequest(request)
Expand Down Expand Up @@ -4743,11 +4764,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
workspaceGroups = response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
}
if preferActiveTicketTarget, selectActiveTicketTargetIfAvailable() {
retryTerminalReplaysPendingWorkspaceMapping(reason: "workspace_list")
return
}
if let selectedWorkspaceID,
workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
syncSelectedTerminalForWorkspace()
retryTerminalReplaysPendingWorkspaceMapping(reason: "workspace_list")
return
}
setSelectedWorkspaceID(
Expand All @@ -4756,6 +4779,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
?? workspaces.first?.id
)
syncSelectedTerminalForWorkspace()
retryTerminalReplaysPendingWorkspaceMapping(reason: "workspace_list")
}

private func remoteWorkspacesPreservingSnapshots(
Expand Down
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 @@ -75,7 +75,10 @@ struct CMUXMobileRootView: View {
.animation(.snappy(duration: 0.18), value: isAuthenticated)
.animation(.snappy(duration: 0.18), value: store.phase)
.onAppear {
syncShellAuthentication(isAuthenticated)
let startedLaunchAttachURL = connectLaunchAttachURLIfNeeded()
if !startedLaunchAttachURL {
syncShellAuthentication(isAuthenticated)
}
store.resumeForegroundRefresh()
#if os(iOS)
pushCoordinator.bind(store: store)
Expand All @@ -85,7 +88,9 @@ struct CMUXMobileRootView: View {
// so kick off the stored-Mac reconnect here too. Without this the
// restoring gate could stay on RestoringSessionView forever because
// nothing ever resolves `didFinishStoredMacReconnectAttempt`.
reconnectStoredMacIfNeeded()
if !startedLaunchAttachURL {
reconnectStoredMacIfNeeded()
}
}
#if os(iOS)
// A notification tap can arrive before the workspace (or terminal) it
Expand Down Expand Up @@ -125,6 +130,7 @@ struct CMUXMobileRootView: View {
.onChange(of: isAuthenticated) { _, isAuthenticated in
syncShellAuthentication(isAuthenticated)
guard isAuthenticated else {
_ = connectLaunchAttachURLIfNeeded()
return
}
if let rawURL = pendingAttachURL {
Expand Down Expand Up @@ -308,10 +314,9 @@ struct CMUXMobileRootView: View {
/// sign-in that completes after mount) so the restoring gate always resolves
/// even when the auth state never transitions while this view is mounted.
private func reconnectStoredMacIfNeeded() {
if connectLaunchAttachURLIfNeeded() { return }
guard isAuthenticated else { return }
let startedUITestAttachURL = connectUITestAttachURLIfNeeded()
guard !startedUITestAttachURL,
MobileRootAuthGate.shouldReconnectStoredMac(
guard MobileRootAuthGate.shouldReconnectStoredMac(
stackAuthenticated: authManager.isAuthenticated,
attachTicketAuthenticated: hasActiveAttachTicketAuthentication,
connectionState: store.connectionState
Expand Down Expand Up @@ -379,7 +384,7 @@ struct CMUXMobileRootView: View {
}

@discardableResult
private func connectUITestAttachURLIfNeeded() -> Bool {
private func connectLaunchAttachURLIfNeeded() -> Bool {
#if DEBUG
// Auto-pair when an attach URL is supplied at launch. Two sources:
// - CMUX_DOGFOOD_ATTACH_URL (UITestConfig.dogfoodAttachURL): NOT gated on
Expand All @@ -394,14 +399,11 @@ struct CMUXMobileRootView: View {
// No-op unless one of those env vars is set, so normal launches are
// unaffected.
guard !didConsumeUITestAttachURL,
isAuthenticated,
let attachURL = UITestConfig.dogfoodAttachURL ?? UITestConfig.attachURL else {
return false
}
didConsumeUITestAttachURL = true
Task {
await store.connectPairingURL(attachURL)
}
connectAttachURL(attachURL)
return true
#else
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
/// band and pins first responder so the keyboard hands over in place; when it
/// flips off, the field is unmounted and the band collapses to zero height.
var isComposerActive: Bool = false
/// Whether normal-screen scrollback should move on the phone without a Mac
/// round trip. Turning this off restores host-coupled scroll for dogfood
/// comparison.
var decouplePrimaryScreenScroll: Bool = true

func makeCoordinator() -> Coordinator {
Coordinator(surfaceID: surfaceID, store: store)
Expand All @@ -59,6 +63,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
fontSize: fontSize
)
view.autoFocusOnWindowAttach = autoFocusOnWindowAttach
view.decouplePrimaryScreenScroll = decouplePrimaryScreenScroll
#if DEBUG
// Hand the surface the structured diagnostic log so the composer-dock
// probes land in the blob the "Send to agent" feedback pane exports.
Expand Down Expand Up @@ -88,6 +93,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
// state write, so it is safe in `updateUIView`.
guard let surfaceView = uiView as? GhosttySurfaceView else { return }
surfaceView.autoFocusOnWindowAttach = autoFocusOnWindowAttach
surfaceView.decouplePrimaryScreenScroll = decouplePrimaryScreenScroll
surfaceView.setComposerActive(isComposerActive)
context.coordinator.setComposerMounted(isComposerActive)
// A width change (rotation) is not a text change, so the field-content trigger
Expand Down Expand Up @@ -137,6 +143,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
Loading