Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2625,11 +2625,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// Mac hosts.
private var terminalByteContinuationsBySurfaceID: [String: AsyncStream<Data>.Continuation] = [:]

/// Per-frame metadata the byte stream cannot carry (it is opaque VT bytes),
/// surfaced to the terminal view for Stage 1 local (primary-screen) scroll:
/// the active screen (gate: primary scrolls locally, alternate forwards) and,
/// for a full snapshot, how many scrollback rows it just flowed into the
/// local surface (so the view knows when a local scroll reached the top of
/// held history and a deeper fetch is due).
private var terminalFrameMetaContinuationsBySurfaceID: [String: AsyncStream<MobileTerminalFrameMeta>.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)
}

/// Yield per-frame metadata to the surface's metadata stream, if one is
/// attached. Decoupled from `deliverTerminalBytes` so the byte stream stays a
/// pure opaque VT channel.
private func deliverTerminalFrameMeta(_ meta: MobileTerminalFrameMeta, surfaceID: String) {
terminalFrameMetaContinuationsBySurfaceID[surfaceID]?.yield(meta)
}

/// Whether a surface currently has an attached output stream consumer.
private func hasTerminalOutputSink(surfaceID: String) -> Bool {
terminalByteContinuationsBySurfaceID[surfaceID] != nil
Expand All @@ -2652,11 +2667,56 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalByteContinuationsBySurfaceID.removeValue(forKey: surfaceID)
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
// The frame-meta stream is consumed by the same coordinator and shares
// the byte stream's lifetime; finish it here so byte-stream teardown
// cannot leave a dangling meta continuation (its own onTermination also
// cleans up, this is the symmetric path).
terminalFrameMetaContinuationsBySurfaceID.removeValue(forKey: surfaceID)?.finish()
// 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)
}

/// Per-frame metadata for the terminal view (Stage 1 local scroll). Carries
/// only the active screen and, for a full snapshot, the scrollback rows it
/// flowed into the local surface. Never content; the byte stream owns content.
public struct MobileTerminalFrameMeta: Sendable {
/// Whether the active screen is the alternate screen (TUI). Primary
/// scrolls locally; alternate forwards to the Mac.
public let isAlternateScreen: Bool
/// Whether this 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
}

private func deliverTerminalFrameMeta(from frame: MobileTerminalRenderGridFrame) {
let meta = MobileTerminalFrameMeta(
isAlternateScreen: frame.activeScreen == .alternate,
isFullSnapshot: frame.full,
scrollbackRows: frame.full ? frame.scrollbackRows : 0
)
deliverTerminalFrameMeta(meta, surfaceID: frame.surfaceID)
}

/// The per-frame metadata stream for a terminal surface (active screen +
/// full-snapshot scrollback depth), consumed alongside
/// ``terminalOutputStream(surfaceID:)`` by the terminal view for local scroll.
/// - Parameter surfaceID: The terminal surface identifier.
/// - Returns: An `AsyncStream` of frame metadata.
public func terminalFrameMetaStream(surfaceID: String) -> AsyncStream<MobileTerminalFrameMeta> {
AsyncStream { continuation in
terminalFrameMetaContinuationsBySurfaceID[surfaceID] = continuation
continuation.onTermination = { [weak self] _ in
Task { @MainActor in
self?.terminalFrameMetaContinuationsBySurfaceID.removeValue(forKey: surfaceID)
}
}
}
}

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 Meta-stream registration has no matching unregister call in unregisterTerminalOutput

terminalOutputStream pairs registration in registerTerminalOutput with cleanup in unregisterTerminalOutput (called via onTermination). terminalFrameMetaStream only cleans up via its own onTerminationTask { @MainActor in removeValue }. If the output task is cancelled but the frame-meta task somehow outlives it, the meta continuation stays alive in terminalFrameMetaContinuationsBySurfaceID and future live frames continue to deliver metadata to a detached surface. detach() cancels both tasks today, but unregisterTerminalOutput not mirroring cleanup for the meta map leaves the two streams with asymmetric lifecycle guarantees. Consider also nullifying the meta continuation in unregisterTerminalOutput for belt-and-suspenders safety.


/// The output byte stream for a terminal surface.
///
/// Obtaining the stream arms a cold-attach replay so the surface catches up
Expand Down Expand Up @@ -2737,12 +2797,27 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

/// 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.
private func requestTerminalReplay(surfaceID: String) {
/// `scrollbackLines` (when set) requests a deeper-history snapshot for local
/// scroll; nil uses the Mac's default attach-time budget.
private 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")
Expand All @@ -2766,12 +2841,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
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: [
"workspace_id": workspaceID.rawValue,
"surface_id": surfaceID,
]
params: replayParams
)
let data = try await client.sendRequest(request)
guard self.remoteClient === client else { return }
Expand Down Expand Up @@ -2807,6 +2886,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
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;
// surface its scrollback depth + active screen so the view knows
// how much history it now holds (and can classify this snapshot
// as a deeper-fetch result or a cold attach).
if let renderGrid {
self.deliverTerminalFrameMeta(from: renderGrid)
}
guard let deliverBytes, !deliverBytes.isEmpty else {
return
}
Expand Down Expand Up @@ -2855,6 +2942,13 @@ 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
// Surface the active screen so the view gates local scroll. Meta and
// bytes ride two independent AsyncStreams consumed by two tasks, so
// cross-stream ordering is NOT guaranteed; nothing here relies on it.
// The snap-to-live decision is made by the view per applied byte chunk
// from its own scroll state (`processOutput`), and the active-screen
// gate self-heals on the next frame if a flip's meta lands late.
deliverTerminalFrameMeta(from: renderGrid)

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 the active screen on empty render-grid deltas

When this new metadata path consumes every live render-grid frame, an empty delta can incorrectly flip the phone back to primary mode. MobileTerminalRenderObserver.emitRenderGrid builds the no-row-change frame without passing activeScreen, so it defaults to .primary at Sources/Mobile/MobileTerminalRenderObserver.swift:206-215; after an alt-screen TUI emits output that changes only the cursor/state sequence, this line delivers isAlternateScreen=false, causing subsequent scroll gestures to be handled locally instead of forwarded to the program until another row-changing frame arrives.

Useful? React with 👍 / 👎.

guard !bytes.isEmpty else { return }
deliverTerminalBytes(bytes, surfaceID: renderGrid.surfaceID)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
weak var store: CMUXMobileShellStore?
weak var surfaceView: GhosttySurfaceView?
private var outputTask: Task<Void, Never>?
private var frameMetaTask: Task<Void, Never>?

init(surfaceID: String, store: CMUXMobileShellStore) {
self.surfaceID = surfaceID
Expand All @@ -82,11 +83,26 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
surfaceView?.processOutput(data)
}
}
// Carry per-frame metadata (active screen + full-snapshot scrollback
// depth) the opaque byte stream cannot: the view gates Stage 1 local
// scroll on the active screen and tracks how much history it holds. A
// separate stream so the byte channel stays pure content.
frameMetaTask = Task { @MainActor [weak surfaceView] in
for await meta in store.terminalFrameMetaStream(surfaceID: surfaceID) {
guard !Task.isCancelled else { return }
surfaceView?.setActiveScreen(isAlternate: meta.isAlternateScreen)
if meta.isFullSnapshot {
surfaceView?.setHeldScrollbackRows(meta.scrollbackRows)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

func detach() {
outputTask?.cancel()
outputTask = nil
frameMetaTask?.cancel()
frameMetaTask = nil
}

// MARK: - GhosttySurfaceViewDelegate
Expand Down Expand Up @@ -142,12 +158,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.
Expand Down
Loading
Loading