Skip to content
Open
Show file tree
Hide file tree
Changes from 20 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,12 @@ 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,
scrollbackRows: immediate.scrollbackRows
)
)
}
}
Expand All @@ -45,6 +50,11 @@ 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,
scrollbackRows: next.scrollbackRows
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,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 @@ -720,6 +721,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 @@ -3549,6 +3551,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
deliveredTerminalByteEndSeqBySurfaceID = [:]
pendingTerminalByteEndSeqBySurfaceID = [:]
terminalReplaySurfaceIDsInFlight = []
terminalReplaySurfaceIDsPendingWorkspaceMapping = []
terminalOutputQueuesBySurfaceID = [:]
terminalOutputStreamTokensBySurfaceID = terminalOutputStreamTokensBySurfaceID.mapValues { _ in UUID() }
terminalScrollQueueTokensBySurfaceID = [:]
Expand Down Expand Up @@ -4831,6 +4834,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 @@ -4944,6 +4960,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 @@ -5042,11 +5059,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 @@ -5063,6 +5082,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 All @@ -5088,7 +5109,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let deliverBytes: Data?
if let renderGrid {
deliverBytes = nil
MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)")
MobileDebugLog.anchormux(
"CMUX_REPLAY render_grid surface=\(surfaceID) rows=\(renderGrid.rows) "
+ "scrollbackRows=\(renderGrid.scrollbackRows) spans=\(renderGrid.rowSpans.count) "
+ "scrollbackSpans=\(renderGrid.scrollbackSpans.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)")
Expand Down Expand Up @@ -5316,11 +5341,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 @@ -5329,6 +5356,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,24 @@ 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.
}

var scrollbackRows: Int? {
switch payload {
case .bytes:
nil
case .renderGrid(let frame):
frame.full ? frame.scrollbackRows : nil
}
}
}

/// 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,38 @@ import Testing
#expect(!vt.contains("old"))
}

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

#expect(delivery.activeScreen == MobileTerminalRenderGridFrame.Screen.alternate)
#expect(delivery.scrollbackRows == 42)
#expect(deltaDelivery.scrollbackRows == nil)
#expect(rawDelivery.activeScreen == nil)
#expect(rawDelivery.scrollbackRows == 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,24 @@ 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?
/// Number of scrollback rows included in a full render-grid snapshot.
/// Delta frames and raw byte fallback chunks carry `nil` because they do not
/// describe the local mirror's scrollback extent.
public let scrollbackRows: Int?
public let data: Data
public let streamToken: UUID

public init(data: Data, streamToken: UUID) {
public init(
data: Data,
streamToken: UUID,
activeScreen: MobileTerminalRenderGridFrame.Screen? = nil,
scrollbackRows: Int? = nil
) {
self.activeScreen = activeScreen
self.scrollbackRows = scrollbackRows
self.data = data
self.streamToken = streamToken
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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 @@ -88,7 +91,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 @@ -128,6 +133,7 @@ struct CMUXMobileRootView: View {
.onChange(of: isAuthenticated) { _, isAuthenticated in
syncShellAuthentication(isAuthenticated)
guard isAuthenticated else {
_ = connectLaunchAttachURLIfNeeded()
return
}
if consumePendingURLIfReady() {
Expand Down Expand Up @@ -332,10 +338,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 @@ -450,7 +455,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 @@ -465,14 +470,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
Loading
Loading