Skip to content

iOS smooth scroll (stage 1): decouple non-alt scroll from the Mac#5628

Closed
lawrencecchen wants to merge 6 commits into
mainfrom
feat-ios-smooth-scroll-stage1
Closed

iOS smooth scroll (stage 1): decouple non-alt scroll from the Mac#5628
lawrencecchen wants to merge 6 commits into
mainfrom
feat-ios-smooth-scroll-stage1

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Stage 1 of iOS terminal smooth scroll. Behind the alt-mode gate, TUIs unaffected.

What changed

In a NORMAL (primary, non-alt) shell, a swipe now scrolls the phone's own locally-held history with no per-frame mobile.terminal.scroll RPC to the Mac. Alt-screen behavior (vim, less --mouse, htop, lazygit) is unchanged: it still forwards to the Mac so the program owns the scroll.

  • The phone tracks the active screen from each applied render-grid frame via a new per-surface metadata stream (the opaque byte stream cannot carry it). Primary scrolls locally; alternate forwards to the Mac. (MobileShellComposite.terminalFrameMetaStream, consumed in GhosttySurfaceRepresentable.Coordinator.)
  • Local scroll drives ghostty_surface_mouse_scroll on the phone's own libghostty surface, serialized on the same outputQueue as process_output, no RPC. (GhosttySurfaceView.scrollLocalSurface.)
  • When the local scroll reaches the top of locally-held history, the phone issues ONE deeper-scrollback replay fetch (not per-frame). The Mac's mobile.terminal.replay now takes an optional, clamped scrollback_lines param; the primary full-snapshot reflow lands deeper history into the phone's surface so it keeps scrolling offline. (TerminalController.v2MobileTerminalReplay, MobileShellComposite.requestDeeperScrollback.)
  • Snap-to-live: before any live frame applies, if the user is scrolled up the surface snaps to the live bottom via the exact scroll_to_bottom binding action, so a live delta's absolute-CUP paint lands in the viewport rather than into scrolled-up history. The Mac stays the single source of truth; the phone only owns a read-only scroll position into already-received history.

The local offset is accumulated as a Double (no per-frame rounding) so sub-row residuals don't drift it below the surface's real position; the snap is exact regardless, so correctness doesn't depend on offset precision.

Why this is safe for TUIs

Everything is gated on activeScreen. In the alternate screen the gesture path is byte-for-byte the old forward-to-Mac path (didScrollLines delegate, mobile.terminal.scroll). Only the primary screen takes the new local path. Flipping into alt mid-scroll immediately drops the local offset and reverts to forwarding.

Known Stage 1 limitations (deferred to later stages)

  • Row-quantized, not pixel-smooth. Pixel-smooth + momentum is Stage 2 (UIScrollView-hosted layer), see the design doc.
  • The deeper-scrollback fetch re-flows a full snapshot, which rebuilds the local surface at the live bottom, so crossing a history boundary re-anchors the view to the bottom. Streamed scrolled-off lines (truly seamless, no fetch) is the Stage 1.5 follow-up.
  • The active-screen flag rides a separate stream from the bytes, so there is a sub-millisecond window at attach / right after an alt-flip where a scroll could be misclassified. Transient and self-healing (the initial frame is consumed long before a user scroll); noted for a reviewer rather than hidden.
  • The deeper-fetch shares the in-flight guard with the cold-attach replay, so one can be dropped while the other is in flight; it retries on the next boundary.

Design doc

Full staged design (continuous local-scrollback accumulation: fetch-on-demand vs streamed scrolled-off lines with a recommendation; the one-source-of-truth / read-only-overlay answer to the prior "two sources of truth" objection; snap-to-live reconciliation; pixel-smooth via overscan+CALayer vs UIScrollView-hosted layer with a recommendation) lives in plans/feat-ios-smooth-scroll/DESIGN.md in the cmuxterm-hq control repo (not in this repo).

Verification

  • iOS simulator build (arm64): BUILD SUCCEEDED.
  • macOS Swift change is small and self-contained (read an optional clamped int param, pass it through an existing frame-builder method); gated by CI macOS build here (local macOS build needs the ghostty + bonsplit submodules, deferred to CI).
  • Behavioral dogfood is gated on a paired physical device; the simulator cannot pair to a Mac host, so the scroll behavior itself is verified on-device by the user, not in CI.
  • Localization audit: no user-facing strings added (only debug-log strings and code comments). The eventual "jump to bottom" affordance is a Stage 2 item and will be localized then.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches mobile terminal sync, scroll routing, and Mac replay RPC semantics; alt-screen path is preserved but snap/restore and replay ordering are easy to get wrong under load.

Overview
Stage 1 iOS smooth scroll on the primary screen: pan gestures scroll the phone’s local libghostty history (no per-frame scroll RPC), while alternate screens still forward wheel deltas to the Mac unchanged.

Terminal output is now MobileTerminalOutputChunk (frame metadata + VT bytes in one ordered stream element). GhosttySurfaceRepresentable applies active-screen and scrollback metadata immediately before processOutput, fixing races between metadata and bytes. Live and replay render-grid paths use deliverTerminalFrame; empty-byte frames still flow for active-screen flips.

MobileLocalScrollEngine (in CmuxMobileTerminalKit) owns routing, offset tracking, snap-to-live, and deeper-fetch latches; GhosttySurfaceView+Scroll wires pan → local ghostty_surface_mouse_scroll on outputQueue. processOutput snaps to live bottom before applying live output when the reader is scrolled up, and restores scroll position after a deeper-fetch snapshot.

requestDeeperScrollback / replay extension calls mobile.terminal.replay with optional scrollback_lines; the Mac clamps via MobileReplayScrollbackBudget (max 2000). No-row-change Mac frames now propagate activeScreen. CI runs swift test on CmuxMobileShell and CmuxMobileTerminalKit.

Reviewed by Cursor Bugbot for commit 102a8b8. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Stage 1 of iOS smooth scroll: on the primary (non‑alt) screen, swipes scroll locally on-device with no per-frame RPCs; TUIs on the alt screen are unchanged and still forward to the Mac. This update also delivers frame metadata and bytes as one ordered stream to remove races and keep the reader anchored across deeper-history fetches.

  • New Features

    • Single ordered output stream: terminalOutputStream now yields MobileTerminalOutputChunk (frame metadata + VT bytes together). The view applies activeScreen and snapshot scrollbackRows before the same frame’s bytes; empty-byte frames still carry the screen flag. This replaces the separate per-surface metadata stream.
    • Primary screen: local scroll drives ghostty_surface_mouse_scroll on the phone’s libghostty surface (serialized on outputQueue) via GhosttySurfaceView.scrollLocalSurface. Until the first metadata arrives (older host/raw bytes), gestures keep forwarding to the Mac.
    • Deeper history: at the local top, request one replay via store.requestDeeperScrollbackmobile.terminal.replay(scrollback_lines); clamped by MobileReplayScrollbackBudget (max 2000). No ceiling bounce when fully loaded.
    • Snap-to-live + restore: before any frame applies, snap to bottom if scrolled up; after a deeper-fetch snapshot, re-apply the preserved upward offset so the reader stays anchored.
  • Refactors

    • Extracted all local-scroll decisions into MobileLocalScrollEngine in CmuxMobileTerminalKit with unit tests; GhosttySurfaceView+Scroll.swift holds the UIKit/libghostty glue. Pan-end now folds residual translation before the final flush.
    • Removed the separate frame-metadata stream and MobileTerminalFrameMetaHub; introduced MobileTerminalOutputChunk and updated MobileTerminalOutputSinking/MobileShellComposite to deliver metadata+bytes as one element. Added tests for the delivery contract and enabled CmuxMobileShell and CmuxMobileTerminalKit in CI Swift tests.

Written for commit 102a8b8. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features

    • Added smooth local terminal scrolling on iOS, enabling users to browse terminal history without network latency
    • Implemented automatic deeper scrollback fetching when scrolling reaches the limit of available history
  • Tests

    • Added comprehensive test coverage for terminal output streaming and local scrolling functionality

@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 10, 2026 1:04am
cmux-staging Building Building Preview, Comment Jun 10, 2026 1:04am

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Terminal output streams now deliver frame metadata and VT bytes together via MobileTerminalOutputChunk. Deeper scrollback is requested with budgeted row counts. Primary-screen scrolling is applied locally using a state machine that tracks snap-to-live and restore-offset decisions, while alternate-screen scrolling forwards to the Mac. Metadata flows through the coordinator to update surface state and trigger deeper-history fetches.

Changes

Local scrollback and metadata flow

Layer / File(s) Summary
Terminal output chunk type and protocol contract
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputChunk.swift, Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift
MobileTerminalOutputChunk pairs frame metadata (active screen, snapshot flag, scrollback row count) with VT bytes; MobileTerminalOutputSinking protocol now exposes AsyncStream<MobileTerminalOutputChunk> instead of raw Data.
Replay scrollback budgeting and terminal controller integration
Sources/Mobile/MobileReplayScrollbackBudget.swift, Sources/TerminalController.swift, cmux.xcodeproj/project.pbxproj
MobileReplayScrollbackBudget defines cold-attach and deeper-fetch row limits; TerminalController clamps optional scrollback_lines request params and passes budgeted counts into mobileTerminalRenderGridFrame.
Active-screen preservation in render-grid frames
Sources/Mobile/MobileTerminalRenderObserver.swift
Empty render-grid frames (unchanged rows) now preserve activeScreen from snapshot to prevent alternate-screen UI flipping.
MobileShellComposite chunk delivery and internal visibility widening
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
remoteClient, replay bookkeeping, and helper methods widened for cross-component use; delivery/sink plumbing refactored to use AsyncStream<MobileTerminalOutputChunk>.Continuation with separate raw-bytes and render-grid paths; terminalOutputStream returns chunks.
Terminal replay RPC implementation and deeper-scrollback API
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift
Public requestDeeperScrollback API; internal requestTerminalReplay guards against missing client/workspace, deduplicates per-surface replays, calls mobile.terminal.replay with optional scrollback, suppresses stale replays, delivers via appropriate sink methods, and triggers re-auth on RPC errors.
MobileLocalScrollEngine state machine
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift
Sendable state machine tracking upward scroll offset, routing gestures to Mac or local, modeling frame/snapshot application with deeper-fetch vs cold-attach classification, computing snap-to-live and restore decisions, and arming deduped deeper-scrollback requests at history boundaries.
MobileLocalScrollEngine test coverage
Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileLocalScrollEngineTests.swift
Tests validate routing gates, offset clamping, deeper-fetch dedup/retry, and snap-to-live with restore across cold-attach, growth/no-growth, and mid-scroll scenarios.
GhosttySurfaceView local-scroll state and snap-to-live behavior
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
Adds localScroll engine instance and pending-scroll buffers; exposes properties for scroll extensions; extends delegate with didReachLocalHistoryTopWithHeldScrollbackRows callback; updates processOutput to consume snap decision and apply snap-to-live (scroll_to_bottom binding) before frame processing and restore-offset after.
Pan gesture coalescing and local/remote scroll routing
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift
Accumulates pan gesture translations and flushes on end/cancel; maps touches to grid cells for routing; routes via localScroll.flushRoute to Mac (with cell metadata) or local surface mutation; notifies delegate on deeper-fetch requests; exposes setActiveScreen and setHeldScrollbackRows setters.
Coordinator chunk metadata consumption and deeper-scrollback requests
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
Coordinator processes per-chunk metadata to update surface active-screen and held scrollback state; clarifies didScrollLines as alternate-screen scrolling; adds deeper-fetch callback to expand scrollback window and request from store; defines paging window constant.
Chunk delivery test coverage and CI integration
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalOutputChunkDeliveryTests.swift, .github/workflows/ci.yml, ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift
Tests verify metadata and bytes arrive together, empty frames carry metadata, raw bytes carry none; CI extended to test CmuxMobileShell and CmuxMobileTerminalKit packages; feature test updated to consume chunks.

🎯 4 (Complex) | ⏱️ ~75 minutes

🐰 A rabbit hops through scrollback's delight,
Metadata and bytes now grouped just right!
Local scrolling flows with snaps so bright,
While deeper fetches keep the view in sight. 🌙


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (5 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error New file MobileShellComposite+TerminalReplay.swift introduces a file-scoped Logger without the nonisolated modifier, violating Swift 6 actor isolation guidelines for pure helpers. Mark the Logger as nonisolated private let mobileShellLog = Logger(...) to avoid unnecessary MainActor coupling of this file-scoped utility.
Cmux Cache Substitution Correctness ❌ Error registerTerminalOutput() clears deliveredTerminalByteEndSeqBySurfaceID before requestTerminalReplay() checks it; cold cache allows stale replays to overwrite newer live frames. Preserve the delivered sequence before clearing, or snapshot last-delivered sequence at register time to use for staleness checks instead of post-clear cache state.
Cmux Swift Logging ❌ Error Logger declaration 'private let mobileShellLog' in MobileShellComposite+TerminalReplay.swift violates logging rules: should be 'nonisolated private let' in MainActor-isolated code. Change 'private let mobileShellLog' to 'nonisolated private let mobileShellLog' in MobileShellComposite+TerminalReplay.swift (line 7) per .github/review-bot-rules/swift-logging.md requirements.
Cmux Architecture Rethink ❌ Error GhosttySurfaceRepresentable calls setHeldScrollbackRows without checking isAlternateScreen, leaving engine with primary-screen state while in alternate mode, violating architectural rethink rule 11. Add !meta.isAlternateScreen guard on line 93: if meta.isFullSnapshot, !meta.isAlternateScreen
Cmux Source Artifacts ❌ Error PR adds 31 forbidden artifact files: .claude/ (25), .agents/, .cursor/, .greptile/, .coderabbit.yaml—matching source-control-artifacts rule prohibitions. Remove .claude/, .agents/, .cursor/, .greptile/, and .coderabbit.yaml from commit, or add patterns to .gitignore.
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (15 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly describes the main change: iOS smooth scroll Stage 1, decoupling non-alt scroll from the Mac.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Blocking Runtime ✅ Passed No blocking/timing synchronization primitives (semaphores, sleeps, locks, sync dispatch) were introduced in production code; new code uses proper async/await and actor patterns.
Cmux Expensive Synchronous Load ✅ Passed No expensive synchronous loaders added. The workspaceID(forTerminalID:) lookup is pre-existing in-memory code, not disk I/O or syscalls like the rule forbids.
Cmux No Hacky Sleeps ✅ Passed PR modifies 1493 Swift files only; no production TypeScript/JavaScript/shell scripts modified. GitHub Actions YAML out of scope per rules. Compliant with no-hacky-sleeps check.
Cmux Algorithmic Complexity ✅ Passed All hot paths use O(1) operations: Set/Dictionary lookups on terminal surfaces, state mutations in MobileLocalScrollEngine, and per-frame processing with no nested scans.
Cmux Swift Concurrency ✅ Passed No legacy async patterns found. MobileLocalScrollEngine is pure state machine. GhosttySurfaceView+Scroll's DispatchQueue.main.async is allowed UIKit boundary crossing. Tasks have proper cleanup.
Cmux Swift @Concurrent ✅ Passed No violations found. Task { @MainActor } patterns are correct, CPU work properly hops off-main via outputQueue, and no improper async/concurrent annotations.
Cmux Swift File And Package Boundaries ✅ Passed Five new files all under 400 lines with clear single responsibilities properly scoped in packages; oversized files touched incidentally with no 250+ line additions.
Cmux User-Facing Error Privacy ✅ Passed PR adds iOS smooth scroll but doesn't add/change user-facing error messages. New files contain only internal logic and DEBUG logs. No alerts, error strings, or sensitive information exposure added.
Cmux Full Internationalization ✅ Passed No new user-facing strings requiring localization were introduced. All new functionality uses debug-only logs or existing localized APIs. No .xcstrings or Info.plist files were modified.
Cmux Swiftui State Layout ✅ Passed No violations found: UIViewRepresentable and UIView subclasses (allowed), data models lack problematic state, no GeometryReader, Tasks in proper lifecycle callbacks.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR is iOS-only smooth scrolling feature; no NSWindow/NSPanel/SwiftUI Window creation or cmux.* window identifiers detected in any new or changed files.
Description check ✅ Passed Pull request description is comprehensive, well-structured, and addresses all required template sections with detailed explanations of changes, rationale, testing, and design considerations.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-smooth-scroll-stage1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ca9aa6693

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

#endif
// Surface the active screen so the view gates local scroll; deliver before
// the bytes so the view can snap to live before the delta applies.
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 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements Stage 1 iOS smooth scroll for the primary (non-alt) terminal screen: swipes now drive ghostty_surface_mouse_scroll on the phone's own libghostty surface with no per-frame RPCs, while alt-screen TUIs are byte-for-byte unchanged. All three race conditions flagged in previous review rounds — (1) metadata arriving on a separate stream racing the byte stream, (2) the meta-stream lifecycle having no matching unregister in unregisterTerminalOutput, and (3) setHeldScrollbackRows zeroing the offset without dispatching the snap — are structurally resolved by delivering frame metadata and VT bytes as a single ordered MobileTerminalOutputChunk element.

  • MobileLocalScrollEngine (new, unit-tested pure value type): owns every routing/latch decision — fetchInFlight/fetchAwaitingSnapshot dual-latch dedupes deeper-fetch triggers, consumeSnapRequest is the single place upRowsExact is cleared, and historyFullyLoaded stops the bounce-on-refetch once the Mac has no more history.
  • MobileTerminalOutputChunk: bundles a frame's metadata with its bytes into one AsyncStream element, making the arm-then-consume contract for snap-to-live and scroll-restore structural rather than relying on inter-task scheduling order.
  • MobileTerminalRenderObserver: empty-row delta frames now forward the real activeScreen instead of defaulting to .primary, closing the TUI misclassification window on cursor-blink-only frames.

Confidence Score: 5/5

Safe to merge. The three previously-flagged races are structurally closed by the unified chunk stream; alt-screen TUI handling is byte-for-byte unchanged; engine logic is fully unit-tested.

All previous inter-stream race conditions are resolved by collapsing metadata and bytes into a single ordered stream element. The MobileLocalScrollEngine state machine is clean and exhaustively unit-tested. The only open items are style-level: remoteClient could use private(set) instead of plain internal, and the processOutput(Data()) path for byteless full snapshots has an implicit assumption about libghostty's 0-byte input handling. Neither affects correctness in current usage.

No files require special attention. GhosttySurfaceView.swift and MobileShellComposite.swift have minor access-control widening worth tightening before the pattern spreads.

Important Files Changed

Filename Overview
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift New pure-value-type state machine for local scroll decisions. fetchInFlight/fetchAwaitingSnapshot dual-latch correctly separates dedup from classification; consumeSnapRequest is the only place upRowsExact is cleared, keeping engine and surface in sync.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift New UIKit + libghostty glue; all routing decisions delegated to MobileLocalScrollEngine; scroll mutations serialized on outputQueue with process_output.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Several private properties widened to internal for the extension split; snap+restore logic added in processOutput's outputQueue block. processOutput(Data()) is now valid for byteless full snapshots — snap and restore work correctly but ghostty_surface_process_output may be called with count=0.
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputChunk.swift New Sendable value type bundling frame metadata with bytes into one ordered stream element, eliminating the previous separate-stream race.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift Replay logic extracted from MobileShellComposite. Requires several private members widened to internal, including remoteClient which the extension only reads — private(set) would be more precise.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Stream type changed to AsyncStream; empty-byte frames now flow (old guard !bytes.isEmpty removed); several private members widened to internal for the extension split.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift Coordinator applies chunk metadata synchronously before processOutput and implements didReachLocalHistoryTop with a doubling fetch-window formula clamped server-side at 2000.
Sources/Mobile/MobileReplayScrollbackBudget.swift New enum centralising scrollback budget constants with documented bounds and a clamped() guard against hostile or out-of-range phone requests.
Sources/Mobile/MobileTerminalRenderObserver.swift Empty delta frames now carry the real activeScreen, preventing alt-screen TUIs from being misclassified as primary on cursor-blink-only frames.
Sources/TerminalController.swift v2MobileTerminalReplay reads an optional clamped scrollback_lines param and forwards it; budget constant moved to MobileReplayScrollbackBudget.

Reviews (4): Last reviewed commit: "iOS smooth scroll stage 1: deliver a fra..." | Re-trigger Greptile

Comment on lines +1301 to +1306
// 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.
if lines > 0, nextUpRows >= Double(heldScrollbackRows), nextUpRows > priorUpRows {
delegate?.ghosttySurfaceView(self, didReachLocalHistoryTopWithHeldScrollbackRows: heldScrollbackRows)
}

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 Deeper-fetch trigger fires on every flush past the history top, not only on first crossing

The condition nextUpRows >= Double(heldScrollbackRows) && nextUpRows > priorUpRows remains true for every subsequent upward-scroll flush once the position has passed the threshold — not just at the moment of first crossing. When heldScrollbackRows == 0 (initial state, before any snapshot arrives), nextUpRows >= 0.0 is vacuously true, so the delegate fires on the very first scroll frame and again on every frame until the RPC returns and setHeldScrollbackRows resets the position. The in-flight guard in requestTerminalReplay de-duplicates the actual RPC, but the delegate and the requestTerminalReplay guard-check still execute at up to 60 calls/sec during scrolling. The comment above this block says "ask the host for ONE deeper-scrollback fetch," and the trigger condition should enforce that guarantee rather than relying solely on the in-flight guard.

Comment on lines +2634 to +2643
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.

Comment on lines +2866 to +2869
#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; deliver before

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 Inter-task ordering of meta vs. bytes on @MainActor is not guaranteed

The comment says "deliver before the bytes so the view can snap to live before the delta applies." deliverTerminalFrameMeta and deliverTerminalBytes yield into two separate AsyncStream continuations consumed by two independent @MainActor tasks (frameMetaTask and outputTask). When both continuations get items in the same scheduler turn, Swift concurrency does not guarantee that frameMetaTask resumes and completes its loop body before outputTask resumes. The snap via ghostty_surface_binding_action("scroll_to_bottom") is idempotent so the worst case is an extra snap or a frame applying with a stale isAlternateScreen value, but the correctness argument rests on an unspecified implementation detail of the Swift runtime rather than a language-level guarantee.

Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eba96067c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1295 to +1296
// Primary screen: scroll the phone's own surface locally, no RPC.
scrollLocalSurface(lines: lines, atCell: cell)

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 Keep forwarding scroll when render-grid metadata is absent

When the connection is on the raw-bytes compatibility path, no MobileTerminalFrameMeta is ever delivered (handleTerminalBytesEvent only yields bytes and replay falls back to snapshot/raw when render_grid is absent), so isAlternateScreen stays at its new default false. In that context every one-finger scroll now takes this local-primary branch instead of the previous didScrollLines path, which means TUIs running on an older/no-terminal.render_grid.v1 host stop receiving mouse-wheel scroll events even though the code still advertises raw-bytes fallback support.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 1146-1147: Wrap the MobileDebugLog.anchormux(...) calls used on
the per-pan and per-flush hot paths inside conditional compilation guards so
they do not run in release builds; specifically, locate the anchormux calls that
log gesture.state.rawValue / gesture.translation(in: self) / isAlternateScreen
and the other anchormux probes mentioned and surround each call with `#if` DEBUG /
`#endif` so interpolation and logging cost is eliminated from
release/smooth-scroll paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 878e21bd-230e-4b9b-abf8-d36cdc6f8c83

📥 Commits

Reviewing files that changed from the base of the PR and between 4424644 and 2ca9aa6.

📒 Files selected for processing (4)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Sources/TerminalController.swift

Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated
Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 1162-1168: Don't clear the fetch-response latch in the gesture
.began handler; removing the line that sets localScrollFetchInFlight = false
prevents valid slow replays from being misclassified as cold-attaches. Instead,
leave localScrollFetchInFlight true in the .began case and only clear it when
the fetch response is actually consumed — e.g., from the code path that calls
setHeldScrollbackRows / handles the deeper-replay response and sets
localHistoryFullyLoaded — so that the latch survives until the snapshot consumer
processes the response (also apply same change around the 1257-1265 region).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e89df89e-798c-496b-8590-f66ee33ffc3a

📥 Commits

Reviewing files that changed from the base of the PR and between 2ca9aa6 and eba9606.

📒 Files selected for processing (1)
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift

Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated
Comment on lines 1354 to 1368
/// actor just before a live frame is dispatched to `outputQueue`; the caller
/// applies the `scroll_to_bottom` binding action off-main on that queue (in
/// `process_output` order) so a live delta's absolute-CUP paint lands in the
/// viewport rather than into scrolled-up history. The binding action is exact
/// (independent of the tracked offset), so a residual in `localScrollUpRowsExact`
/// can never leave the surface partway up. Returns `false` when no snap is
/// needed (at bottom, or alternate screen). Keeps the Mac as the single source
/// of truth; the phone only owns the scroll position.
private func consumeLocalScrollSnapRequest() -> Bool {
guard !isAlternateScreen, isLocalScrollActive else { return false }
MobileDebugLog.anchormux("scroll.snapLive from=\(String(format: "%.1f", localScrollUpRowsExact))")
localScrollUpRowsExact = 0
return true
}

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.

P1 setHeldScrollbackRows clears the tracking offset without dispatching the scroll_to_bottom action

setHeldScrollbackRows zeroes localScrollUpRowsExact on the main actor, but the actual libghostty surface snap — dispatching scroll_to_bottom to outputQueue — only ever happens inside consumeLocalScrollSnapRequest() called from processOutput. Because frameMetaTask and outputTask are two independent @MainActor tasks consuming separate AsyncStreams, their resumption order is not guaranteed by the Swift runtime.

When a full snapshot arrives (deeper-fetch response or cold-attach) and frameMetaTask happens to be scheduled first:

  1. setHeldScrollbackRows zeroes localScrollUpRowsExact to 0.
  2. processOutput runs next; consumeLocalScrollSnapRequest checks isLocalScrollActive (localScrollUpRowsExact >= 0.5) and returns false.
  3. No scroll_to_bottom is dispatched to outputQueue; libghostty's actual scroll position is unchanged (still scrolled up).
  4. localScrollUpRowsExact now says 0 while the libghostty surface is visually scrolled up — the two are out of sync.

From this point every live delta frame sees isLocalScrollActive = false and skips the snap. Live output paints at the VT cursor (the live viewport bottom in the buffer) but the window is positioned elsewhere in scrollback, so new content appears to vanish rather than paint into the visible area.

lawrencecchen and others added 4 commits June 9, 2026 15:58
Stage 1 of the iOS terminal smooth-scroll work. In a NORMAL (primary,
non-alt) shell, a swipe now scrolls the phone's own locally-held history
with no per-frame `mobile.terminal.scroll` RPC to the Mac. Alt-screen
behavior (TUIs: vim/less --mouse/htop/lazygit) is unchanged: it still
forwards to the Mac so the program owns the scroll.

How it works:
- The phone tracks the active screen from each applied render-grid frame
  via a new per-surface metadata stream (the opaque byte stream cannot
  carry it). Primary -> scroll locally; alternate -> forward to the Mac.
- Local scroll drives `ghostty_surface_mouse_scroll` on the phone's own
  libghostty surface (serialized on the same `outputQueue` as
  `process_output`), with no RPC.
- When the local scroll reaches the top of locally-held history, the
  phone issues ONE deeper-scrollback replay fetch (not per-frame): the
  Mac's `mobile.terminal.replay` now takes an optional `scrollback_lines`
  param (clamped) and the primary full-snapshot reflow lands deeper
  history in the phone's surface so it can keep scrolling offline.
- Snap-to-live: before any live frame applies, if the user is scrolled
  up the surface snaps to the live bottom via the exact `scroll_to_bottom`
  binding action, so a live delta's absolute-CUP paint lands in the
  viewport rather than into scrolled-up history. The Mac stays the single
  source of truth; the phone only owns a read-only scroll position.

The local offset is accumulated as a Double (no per-frame rounding) so
sub-row residuals do not drift it below the surface's real position; the
snap is exact regardless, so correctness does not depend on offset
precision.

Behind the alt-mode gate, so TUIs are unaffected. Build-verified on the
iOS simulator (arm64). Behavioral dogfood is gated on a paired device
(the simulator cannot pair). Design + later stages (streamed
scrolled-off lines; UIScrollView-hosted pixel-smooth + momentum) are in
plans/feat-ios-smooth-scroll/DESIGN.md in cmuxterm-hq.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unce)

The deeper-scrollback fetch had no terminal condition: at the top of a
shell whose whole scrollback is already held (e.g. a fresh attach with
held=1), every scroll-to-top re-fired a replay RPC whose full-snapshot
response re-anchored the view to the bottom, so a short-scrollback shell
jittered on every swipe.

Add a `localHistoryFullyLoaded` latch: when a deeper fetch returns no
additional history (`scrollbackRows <= held`), stop firing fetches and
let libghostty clamp at the oldest held line. Cleared on genuine growth,
resize, or a fresh cold-attach snapshot. Also guard against a second
fetch while one is in flight, and clear that in-flight latch on a fresh
swipe (`.began`) so a dropped fetch self-heals without racing the
metadata/byte delivery order.

This realizes the "stop cleanly at the oldest known line" behavior the
design doc already specified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oll gates

Review fixes from PR #5628:

- Mac: a no-row-change render-grid frame (cursor blink / seq bump) was built
  without activeScreen, defaulting to .primary, which silently flipped an
  alt-screen TUI back to local scrolling on the phone. The empty frame now
  carries the snapshot's real active screen.
- Phone: until the first render-grid frame metadata arrives (older Mac host or
  the raw-byte compatibility path), every scroll keeps the legacy
  forward-to-Mac path instead of scrolling a history-less local mirror.
- Phone: split the deeper-fetch latch into a dedupe/retry latch (cleared on a
  new pan and on snapshot arrival) and a classification latch (cleared only by
  a full snapshot), so a slow-but-valid deeper replay is still measured as a
  fetch result instead of being misread as a cold attach and re-opening the
  fully-loaded ceiling (fetch-bounce at the oldest line).
- Phone: the tracked scroll offset is now cleared in exactly one place, the
  snap path that runs in process_output order when a frame's bytes apply.
  setHeldScrollbackRows and setActiveScreen no longer zero it out-of-band,
  which could race the separate meta/byte streams and suppress the snap, and
  the snap is no longer gated on the active screen so a stale offset left by
  an alt-flip mid-scroll still snaps before the alt program's rows paint.
- Composite: byte-stream teardown also finishes the frame-meta continuation,
  and the comment claiming meta-before-bytes ordering now documents that
  cross-stream ordering is not guaranteed and nothing relies on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tory fetches

A deeper-scrollback fetch's full snapshot rebuilds the local surface at the
live bottom, so every history page-in bounced the reader back to the bottom.
When setHeldScrollbackRows classifies a snapshot as a fetch response and the
reader is scrolled up, it now arms a restore; the snap path in processOutput
applies the snapshot and then re-issues the preserved cumulative upward delta
on the same serial queue, landing the reader back on the rows they were
reading (the live bottom is unchanged by a deeper fetch, and libghostty
clamps at the top of what it holds). Cold-attach snapshots still snap to
live, and live-output snap-to-live behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen lawrencecchen force-pushed the feat-ios-smooth-scroll-stage1 branch from eba9606 to b58d872 Compare June 9, 2026 23:33
Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated
Comment thread Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift (1)

73-98: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Subscribe to frame metadata before opening the output stream.

Line 81 opens terminalOutputStream, and registerTerminalOutput immediately triggers a replay. The metadata stream is not registered until Line 91, so the initial full-snapshot meta can be dropped if that replay wins the race. On an idle alt-screen attach, the view then starts with stale primary-screen state and the first scroll gestures go local instead of forwarding to the Mac.

♻️ Suggested fix
         func attach(surfaceView: GhosttySurfaceView) {
             self.surfaceView = surfaceView
             guard let store else { return }
             let surfaceID = surfaceID
+            let frameMetaStream = store.terminalFrameMetaStream(surfaceID: surfaceID)
+            let outputStream = store.terminalOutputStream(surfaceID: surfaceID)
             // 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`).
             outputTask = Task { `@MainActor` [weak surfaceView] in
-                for await data in store.terminalOutputStream(surfaceID: surfaceID) {
+                for await data in outputStream {
                     guard !Task.isCancelled else { return }
                     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) {
+                for await meta in frameMetaStream {
                     guard !Task.isCancelled else { return }
                     surfaceView?.setActiveScreen(isAlternate: meta.isAlternateScreen)
                     if meta.isFullSnapshot {
                         surfaceView?.setHeldScrollbackRows(meta.scrollbackRows)
                     }
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift`
around lines 73 - 98, In GhosttySurfaceRepresentable.attach, the outputTask
currently opens terminalOutputStream before frameMetaTask subscribes to
terminalFrameMetaStream, which can lose the initial replayed full-snapshot meta;
fix by creating/starting frameMetaTask (subscribing to terminalFrameMetaStream
with the same surfaceID) before creating outputTask (terminalOutputStream) so
metadata is registered first; ensure you still capture the same surfaceID/local
variables and keep both tasks as `@MainActor` with weak surfaceView.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 996-1051: This type has grown beyond a single responsibility;
extract the registry-refresh logic out of MobileShellComposite by creating a
dedicated helper/service (e.g. RegistryRouteRefresher) that owns the work
currently in refreshRoutesFromRegistry(for:stackUserID:): move the network call
to deviceRegistry.freshRoutes, the selection via
DeviceRegistryService.selectReconnectRoutes, the active-mac recheck
(pairedMacStore.activeMac), the shouldApplyRegistryRefresh guard, and the
pairedMacStore.upsert call into that new type, and inject dependencies
(deviceRegistry, pairedMacStore, identityProvider, mobileShellLog) via
initializer so MobileShellComposite simply calls
RegistryRouteRefresher.refresh(for:stackUserID:) (or similar) and awaits it; do
the same for the terminal metadata/replay pipeline by creating a
TerminalMetaReplayPipeline type that encapsulates the per-surface metadata
parsing, replay buffering, and streaming interfaces (move the code referenced
around lines 2628-2911), inject required dependencies, and replace the in-class
implementations with thin delegating calls so MobileShellComposite keeps only
orchestration logic and those subsystems are unit-testable in isolation.
- Around line 2670-2717: The meta-stream continuation is removed by plain
surfaceID which allows a stale termination to clobber a newer attach; change the
storage and teardown to be generation-aware by pairing the continuation with the
current attach token/generation and only removing/finishing if the stored
generation matches. Concretely: replace
terminalFrameMetaContinuationsBySurfaceID[String:
AsyncStream<MobileTerminalFrameMeta>.Continuation] with a map that stores
(generationToken, continuation) (or a small struct) when creating the stream in
terminalFrameMetaStream(surfaceID:), and in continuation.onTermination and in
unregisterTerminalOutput check that the generation token matches the current
attach/generation for that surface before calling removeValue(forKey:) or
finish() so old terminations cannot clear a newer continuation.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 1239-1244: The single-boolean latch
localScrollFetchAwaitingSnapshot is racy and must be replaced with an explicit
replay-response discriminator: change localScrollFetchAwaitingSnapshot to hold a
unique request id or enum (e.g., localScrollFetchAwaitingSnapshotID: UUID? or an
incremental Int/enum) and set that id when issuing a deeper-scroll fetch; when
snapshots arrive (both replay responses and full snapshots), only clear or apply
fetch-result logic if the snapshot's metadata/request id matches
localScrollFetchAwaitingSnapshotID; ensure cold-attach/resize/full-snapshot
paths include the snapshot source id so they do not accidentally consume
unrelated latches, and clear the id only on a matching full snapshot or explicit
cancellation.
- Around line 1702-1719: The code snaps local history to live before verifying
the frame actually contains VT bytes, so calls like
consumeLocalScrollSnapRequest() and clearing localScrollUpRowsExact happen even
for empty frames; fix by deferring the snap/restore logic until after you
confirm the frame payload is non-empty (i.e., forwarded contains VT bytes) — for
example, only set snapLocalScrollToLive and apply
pendingLocalScrollRestoreUpRows/localScrollUpRowsExact when forwarded.isEmpty ==
false (or equivalent check) so processOutput(Data()) won't queue
scroll_to_bottom or clear localScrollUpRowsExact on empty frames; update the
same guard around the restore branch that references
pendingLocalScrollRestoreUpRows and MobileDebugLog.anchormux to match.

---

Outside diff comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift`:
- Around line 73-98: In GhosttySurfaceRepresentable.attach, the outputTask
currently opens terminalOutputStream before frameMetaTask subscribes to
terminalFrameMetaStream, which can lose the initial replayed full-snapshot meta;
fix by creating/starting frameMetaTask (subscribing to terminalFrameMetaStream
with the same surfaceID) before creating outputTask (terminalOutputStream) so
metadata is registered first; ensure you still capture the same surfaceID/local
variables and keep both tasks as `@MainActor` with weak surfaceView.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53d777d8-44e7-4486-90b4-67778c70c6b4

📥 Commits

Reviewing files that changed from the base of the PR and between eba9606 and b58d872.

📒 Files selected for processing (5)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Sources/Mobile/MobileTerminalRenderObserver.swift
  • Sources/TerminalController.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift (1)

73-98: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Subscribe to frame metadata before opening the output stream.

Line 81 opens terminalOutputStream, and registerTerminalOutput immediately triggers a replay. The metadata stream is not registered until Line 91, so the initial full-snapshot meta can be dropped if that replay wins the race. On an idle alt-screen attach, the view then starts with stale primary-screen state and the first scroll gestures go local instead of forwarding to the Mac.

♻️ Suggested fix
         func attach(surfaceView: GhosttySurfaceView) {
             self.surfaceView = surfaceView
             guard let store else { return }
             let surfaceID = surfaceID
+            let frameMetaStream = store.terminalFrameMetaStream(surfaceID: surfaceID)
+            let outputStream = store.terminalOutputStream(surfaceID: surfaceID)
             // 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`).
             outputTask = Task { `@MainActor` [weak surfaceView] in
-                for await data in store.terminalOutputStream(surfaceID: surfaceID) {
+                for await data in outputStream {
                     guard !Task.isCancelled else { return }
                     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) {
+                for await meta in frameMetaStream {
                     guard !Task.isCancelled else { return }
                     surfaceView?.setActiveScreen(isAlternate: meta.isAlternateScreen)
                     if meta.isFullSnapshot {
                         surfaceView?.setHeldScrollbackRows(meta.scrollbackRows)
                     }
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift`
around lines 73 - 98, In GhosttySurfaceRepresentable.attach, the outputTask
currently opens terminalOutputStream before frameMetaTask subscribes to
terminalFrameMetaStream, which can lose the initial replayed full-snapshot meta;
fix by creating/starting frameMetaTask (subscribing to terminalFrameMetaStream
with the same surfaceID) before creating outputTask (terminalOutputStream) so
metadata is registered first; ensure you still capture the same surfaceID/local
variables and keep both tasks as `@MainActor` with weak surfaceView.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 996-1051: This type has grown beyond a single responsibility;
extract the registry-refresh logic out of MobileShellComposite by creating a
dedicated helper/service (e.g. RegistryRouteRefresher) that owns the work
currently in refreshRoutesFromRegistry(for:stackUserID:): move the network call
to deviceRegistry.freshRoutes, the selection via
DeviceRegistryService.selectReconnectRoutes, the active-mac recheck
(pairedMacStore.activeMac), the shouldApplyRegistryRefresh guard, and the
pairedMacStore.upsert call into that new type, and inject dependencies
(deviceRegistry, pairedMacStore, identityProvider, mobileShellLog) via
initializer so MobileShellComposite simply calls
RegistryRouteRefresher.refresh(for:stackUserID:) (or similar) and awaits it; do
the same for the terminal metadata/replay pipeline by creating a
TerminalMetaReplayPipeline type that encapsulates the per-surface metadata
parsing, replay buffering, and streaming interfaces (move the code referenced
around lines 2628-2911), inject required dependencies, and replace the in-class
implementations with thin delegating calls so MobileShellComposite keeps only
orchestration logic and those subsystems are unit-testable in isolation.
- Around line 2670-2717: The meta-stream continuation is removed by plain
surfaceID which allows a stale termination to clobber a newer attach; change the
storage and teardown to be generation-aware by pairing the continuation with the
current attach token/generation and only removing/finishing if the stored
generation matches. Concretely: replace
terminalFrameMetaContinuationsBySurfaceID[String:
AsyncStream<MobileTerminalFrameMeta>.Continuation] with a map that stores
(generationToken, continuation) (or a small struct) when creating the stream in
terminalFrameMetaStream(surfaceID:), and in continuation.onTermination and in
unregisterTerminalOutput check that the generation token matches the current
attach/generation for that surface before calling removeValue(forKey:) or
finish() so old terminations cannot clear a newer continuation.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 1239-1244: The single-boolean latch
localScrollFetchAwaitingSnapshot is racy and must be replaced with an explicit
replay-response discriminator: change localScrollFetchAwaitingSnapshot to hold a
unique request id or enum (e.g., localScrollFetchAwaitingSnapshotID: UUID? or an
incremental Int/enum) and set that id when issuing a deeper-scroll fetch; when
snapshots arrive (both replay responses and full snapshots), only clear or apply
fetch-result logic if the snapshot's metadata/request id matches
localScrollFetchAwaitingSnapshotID; ensure cold-attach/resize/full-snapshot
paths include the snapshot source id so they do not accidentally consume
unrelated latches, and clear the id only on a matching full snapshot or explicit
cancellation.
- Around line 1702-1719: The code snaps local history to live before verifying
the frame actually contains VT bytes, so calls like
consumeLocalScrollSnapRequest() and clearing localScrollUpRowsExact happen even
for empty frames; fix by deferring the snap/restore logic until after you
confirm the frame payload is non-empty (i.e., forwarded contains VT bytes) — for
example, only set snapLocalScrollToLive and apply
pendingLocalScrollRestoreUpRows/localScrollUpRowsExact when forwarded.isEmpty ==
false (or equivalent check) so processOutput(Data()) won't queue
scroll_to_bottom or clear localScrollUpRowsExact on empty frames; update the
same guard around the restore branch that references
pendingLocalScrollRestoreUpRows and MobileDebugLog.anchormux to match.

---

Outside diff comments:
In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift`:
- Around line 73-98: In GhosttySurfaceRepresentable.attach, the outputTask
currently opens terminalOutputStream before frameMetaTask subscribes to
terminalFrameMetaStream, which can lose the initial replayed full-snapshot meta;
fix by creating/starting frameMetaTask (subscribing to terminalFrameMetaStream
with the same surfaceID) before creating outputTask (terminalOutputStream) so
metadata is registered first; ensure you still capture the same surfaceID/local
variables and keep both tasks as `@MainActor` with weak surfaceView.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53d777d8-44e7-4486-90b4-67778c70c6b4

📥 Commits

Reviewing files that changed from the base of the PR and between eba9606 and b58d872.

📒 Files selected for processing (5)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Sources/Mobile/MobileTerminalRenderObserver.swift
  • Sources/TerminalController.swift
🛑 Comments failed to post (4)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (2)

996-1051: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

MobileShellComposite is absorbing too many responsibilities.

This file is already well past the repo’s size threshold, and this PR adds two more subsystems to it: registry-route reconciliation and the per-surface terminal metadata/replay pipeline. Keeping pairing/reconnect, workspace state, terminal streaming, and registry refresh in the same 3k+ line type makes this path much harder to reason about and unit-test in isolation. Please peel the new registry-refresh and terminal-meta/replay code into dedicated helpers instead of extending this store further.

As per coding guidelines, Packages/**/*.swift: "Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility".

Also applies to: 2628-2911

🤖 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/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 996 - 1051, This type has grown beyond a single responsibility;
extract the registry-refresh logic out of MobileShellComposite by creating a
dedicated helper/service (e.g. RegistryRouteRefresher) that owns the work
currently in refreshRoutesFromRegistry(for:stackUserID:): move the network call
to deviceRegistry.freshRoutes, the selection via
DeviceRegistryService.selectReconnectRoutes, the active-mac recheck
(pairedMacStore.activeMac), the shouldApplyRegistryRefresh guard, and the
pairedMacStore.upsert call into that new type, and inject dependencies
(deviceRegistry, pairedMacStore, identityProvider, mobileShellLog) via
initializer so MobileShellComposite simply calls
RegistryRouteRefresher.refresh(for:stackUserID:) (or similar) and awaits it; do
the same for the terminal metadata/replay pipeline by creating a
TerminalMetaReplayPipeline type that encapsulates the per-surface metadata
parsing, replay buffering, and streaming interfaces (move the code referenced
around lines 2628-2911), inject required dependencies, and replace the in-class
implementations with thin delegating calls so MobileShellComposite keeps only
orchestration logic and those subsystems are unit-testable in isolation.

Source: Coding guidelines


2670-2717: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Guard meta-stream teardown by attach generation.

unregisterTerminalOutput and terminalFrameMetaStream(...).onTermination both tear down the metadata continuation by bare surfaceID. On a fast remount, an older stream can terminate after the new attach and remove or finish the current continuation for that same surface, leaving bytes flowing while isAlternateScreen / scrollbackRows stop updating. Tie continuation removal to an attach token/generation before clearing it.

🤖 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/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2670 - 2717, The meta-stream continuation is removed by plain
surfaceID which allows a stale termination to clobber a newer attach; change the
storage and teardown to be generation-aware by pairing the continuation with the
current attach token/generation and only removing/finishing if the stored
generation matches. Concretely: replace
terminalFrameMetaContinuationsBySurfaceID[String:
AsyncStream<MobileTerminalFrameMeta>.Continuation] with a map that stores
(generationToken, continuation) (or a small struct) when creating the stream in
terminalFrameMetaStream(surfaceID:), and in continuation.onTermination and in
unregisterTerminalOutput check that the generation token matches the current
attach/generation for that surface before calling removeValue(forKey:) or
finish() so old terminations cannot clear a newer continuation.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift (2)

1239-1244: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Correlate replay responses explicitly instead of using “next full snapshot wins.”

localScrollFetchAwaitingSnapshot is a single boolean, so any intervening full snapshot (resize/reconnect/cold reattach) consumes the latch before the deeper-replay response arrives. The real replay snapshot then falls through the cold-attach path, which drops the restore state and can bounce the reader back to live after the fetch they just triggered. This needs an explicit replay-response discriminator (request id, source bit, or equivalent metadata), not a bare “next snapshot” flag.

Also applies to: 1298-1316

🤖 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 1239 - 1244, The single-boolean latch
localScrollFetchAwaitingSnapshot is racy and must be replaced with an explicit
replay-response discriminator: change localScrollFetchAwaitingSnapshot to hold a
unique request id or enum (e.g., localScrollFetchAwaitingSnapshotID: UUID? or an
incremental Int/enum) and set that id when issuing a deeper-scroll fetch; when
snapshots arrive (both replay responses and full snapshots), only clear or apply
fetch-result logic if the snapshot's metadata/request id matches
localScrollFetchAwaitingSnapshotID; ensure cold-attach/resize/full-snapshot
paths include the snapshot source id so they do not accidentally consume
unrelated latches, and clear the id only on a matching full snapshot or explicit
cancellation.

1702-1719: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't snap out of local history on empty frame payloads.

The stack already emits empty render frames for unchanged-row emissions. Here the snap decision runs before checking whether forwarded actually contains VT bytes, so processOutput(Data()) still clears localScrollUpRowsExact and queues scroll_to_bottom. That will kick the user back to live even though no frame bytes were applied.

Possible fix
         let forwarded = Self.forwardDaemonOutputBytes(data)
+        let hasFrameBytes = !forwarded.isEmpty
         // 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. Decided here on the
         // main actor (where the gesture mutated the offset); applied off-main in
         // `process_output` order below via the exact `scroll_to_bottom` action.
-        let snapLocalScrollToLive = consumeLocalScrollSnapRequest()
+        let snapLocalScrollToLive = hasFrameBytes && consumeLocalScrollSnapRequest()
         // If this frame is a deeper-scrollback fetch's snapshot (classified by
         // `setHeldScrollbackRows` off the metadata stream), restore the reader's
         // position after it applies instead of leaving them bounced to the
         // bottom. Re-arm the tracked offset here on the main actor so the
         // restore and the offset stay in step; the surface-side scroll runs
         // after `process_output` below, in queue order.
         var restoreLocalScrollUpRows: Double?
-        if snapLocalScrollToLive, let pending = pendingLocalScrollRestoreUpRows {
+        if hasFrameBytes, snapLocalScrollToLive, let pending = pendingLocalScrollRestoreUpRows {
             pendingLocalScrollRestoreUpRows = nil
             restoreLocalScrollUpRows = pending
             localScrollUpRowsExact = pending
             MobileDebugLog.anchormux("scroll.restore up=\(String(format: "%.1f", pending))")
         }

Also applies to: 1729-1750

🤖 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 1702 - 1719, The code snaps local history to live before verifying
the frame actually contains VT bytes, so calls like
consumeLocalScrollSnapRequest() and clearing localScrollUpRowsExact happen even
for empty frames; fix by deferring the snap/restore logic until after you
confirm the frame payload is non-empty (i.e., forwarded contains VT bytes) — for
example, only set snapLocalScrollToLive and apply
pendingLocalScrollRestoreUpRows/localScrollUpRowsExact when forwarded.isEmpty ==
false (or equivalent check) so processOutput(Data()) won't queue
scroll_to_bottom or clear localScrollUpRowsExact on empty frames; update the
same guard around the restore branch that references
pendingLocalScrollRestoreUpRows and MobileDebugLog.anchormux to match.

… engine

The stage-1 logic grew three god files past the Swift file-length budget
(GhosttySurfaceView.swift +273, MobileShellComposite.swift +94,
TerminalController.swift +19). Instead of refreshing budgets, split along
the seams the logic already wanted:

- MobileLocalScrollEngine (CmuxMobileTerminalKit): every Stage 1 gate and
  latch (routing, read-only scroll offset, deeper-fetch dedupe and
  classification latches, fully-loaded ceiling, snap-to-live + restore
  decisions) as a pure value type with 20 unit tests. The UIKit view keeps
  only gesture plumbing and libghostty C calls
  (GhosttySurfaceView+Scroll.swift), so stage 2's sub-line interpolation
  and momentum math lands in a testable type instead of view code.
- MobileTerminalFrameMetaHub (CmuxMobileShell): owns the per-surface
  frame-metadata streams; MobileShellComposite+TerminalReplay.swift owns
  the cold-attach / deeper-scrollback replay path.
- MobileReplayScrollbackBudget (Sources/Mobile): the Mac-side replay
  scrollback budgets and clamping, out of TerminalController.

No behavior change; same decisions, same call sites, same wire protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift (1)

1439-1445: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate hot-path anchormux probes behind #if DEBUG.

Line 1443 is on the per-frame output path; keeping this probe active in release adds avoidable work on a latency-sensitive route. Apply the same guard to the new probes in Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift (Lines 29, 75, 92).

Suggested fix
-        if snapLocalScrollToLive {
-            MobileDebugLog.anchormux(
-                "scroll.snapLive restore=\(restoreLocalScrollUpRows.map { String(format: "%.1f", $0) } ?? "none")"
-            )
-        }
+        `#if` DEBUG
+        if snapLocalScrollToLive {
+            MobileDebugLog.anchormux(
+                "scroll.snapLive restore=\(restoreLocalScrollUpRows.map { String(format: "%.1f", $0) } ?? "none")"
+            )
+        }
+        `#endif`

As per coding guidelines, apply the cmux custom Swift lint rules in .github/review-bot-rules/; debug instrumentation should not run on release hot paths.

🤖 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 1439 - 1445, The new MobileDebugLog.anchormux probes added in
GhosttySurfaceView+Scroll.swift (referenced near the snap/snapping logic and the
localScroll handling at the probes around lines 29, 75, and 92) must be guarded
with the same compile-time check used elsewhere (`#if` DEBUG) to avoid executing
debug instrumentation on the release per-frame hot path; wrap each
MobileDebugLog.anchormux call in the file (and any related string construction
or map formatting used just for the log) with `#if` DEBUG / `#endif` so the logging
and its allocations are compiled out in release builds, matching the existing
pattern used in GhosttySurfaceView.swift.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView`+Scroll.swift:
- Around line 46-47: The gesture handler ends currently call
flushPendingScrollIfNeeded() without applying the gesture's final translation;
update the .ended and .cancelled branch in the pan handler (the method handling
the UIPanGestureRecognizer in GhosttySurfaceView+Scroll.swift) to first read the
recognizer's final translation (e.g., recognizer.translation(in: view)), convert
it into the same delta units used by addPendingScroll / appendPendingScroll,
call the existing function that queues scroll chunks (the same symbol used
elsewhere to accumulate scroll deltas), reset the recognizer translation to
.zero, and only then call flushPendingScrollIfNeeded() so the final residual
delta is not lost.

---

Duplicate comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 1439-1445: The new MobileDebugLog.anchormux probes added in
GhosttySurfaceView+Scroll.swift (referenced near the snap/snapping logic and the
localScroll handling at the probes around lines 29, 75, and 92) must be guarded
with the same compile-time check used elsewhere (`#if` DEBUG) to avoid executing
debug instrumentation on the release per-frame hot path; wrap each
MobileDebugLog.anchormux call in the file (and any related string construction
or map formatting used just for the log) with `#if` DEBUG / `#endif` so the logging
and its allocations are compiled out in release builds, matching the existing
pattern used in GhosttySurfaceView.swift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d32ca22-326f-46d1-8072-ed63dae0cf32

📥 Commits

Reviewing files that changed from the base of the PR and between b58d872 and 1930fcb.

📒 Files selected for processing (10)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalFrameMetaHub.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift
  • Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileLocalScrollEngineTests.swift
  • Sources/Mobile/MobileReplayScrollbackBudget.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj

…e ordered chunk

Review fixes from PR #5628 (Cursor
bugbot, three findings, one root cause): the deeper-fetch scroll restore was
armed on the frame-meta stream but consumed by the byte stream's next chunk,
and the two streams raced. An interleaved live frame could consume a restore
armed for a later snapshot (high), the restore went stale if the reader
scrolled back to the bottom before the snapshot bytes ran (medium), and a
snapshot's bytes could apply before their own metadata, dropping the restore
entirely (medium).

Instead of patching each window, remove the race class: a render-grid frame's
metadata and VT bytes now travel as ONE element (MobileTerminalOutputChunk)
on the single ordered output stream, so the view applies a frame's metadata
immediately before that frame's bytes in the same synchronous main-actor
iteration. The engine's arm-then-consume contract becomes structural: a
deeper-fetch restore is consumed by the fetch snapshot's own apply, never by
an interleaved live frame, and cannot go stale across a gesture. The separate
MobileTerminalFrameMetaHub stream and its coordinator task are deleted; raw
compatibility bytes flow as chunks with no metadata, and metadata-only frames
(no row changes) still carry the active screen without reaching
process_output. New CmuxMobileShellTests cover the delivery contract, and
the CmuxMobileShell + CmuxMobileTerminalKit package suites are added to the
CI swift-test gate.

Also fold the pan gesture's residual translation into the pending scroll on
.ended/.cancelled so the final chunk of a swipe is not dropped (CodeRabbit).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 102a8b8. Configure here.

/// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sub-half-row scroll skips snap

Medium Severity

When primary local scroll leaves upRowsExact below half a row, isLocalScrollActive is false so consumeSnapRequest() does not snap, but scrollLocalSurface still applies the same delta via ghostty_surface_mouse_scroll. Live frames then apply without scroll_to_bottom, so new output can paint into a slightly scrolled-up viewport.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 102a8b8. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift (1)

71-74: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't turn malformed replay payloads into silent success.

Line 71 swallows MobileTerminalReplayResponse.decode(data) failures, and Lines 72-73 do the same for invalid base64 fields. That drops cold-attach or deeper-scrollback replays as a quiet no-op, so the surface stays stale and never reaches the existing error/re-auth path. Treat malformed replay payloads as invalidResponse and fail into the catch block instead.

Possible fix
-                let payload = try? MobileTerminalReplayResponse.decode(data)
-                let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) }
-                let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) }
+                let payload = try MobileTerminalReplayResponse.decode(data)
+                let bytes = try payload.dataBase64.map {
+                    guard let decoded = Data(base64Encoded: $0) else {
+                        throw MobileShellConnectionError.invalidResponse
+                    }
+                    return decoded
+                }
+                let snapshotBytes = try payload.snapshotBase64.map {
+                    guard let decoded = Data(base64Encoded: $0) else {
+                        throw MobileShellConnectionError.invalidResponse
+                    }
+                    return decoded
+                }

Also applies to: 114-117

🤖 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/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+TerminalReplay.swift
around lines 71 - 74, Currently the code silently converts malformed replay
payloads into nils (using try? and flatMap with Data(base64Encoded:)), which
masks errors; change MobileTerminalReplayResponse.decode(data) to use try so
decoding failures throw, and explicitly validate base64 fields (convert
payload.dataBase64 and payload.snapshotBase64 with Data(base64Encoded:) and if
they return nil throw MobileShellComposite.Error.invalidResponse) instead of
mapping to nil; do the same for payload.renderGrid validation so any malformed
payload triggers the catch path. Also apply the identical fix to the other
occurrence that currently uses the same optional-decoding pattern (the block
around lines 114-117).
🤖 Prompt for all review comments with 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.

Inline comments:
In `@ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift`:
- Around line 28-29: The loop consuming store.terminalOutputStream(surfaceID:)
is appending empty strings for metadata-only chunks (chunk.bytes is empty),
which pollutes collector.lines; change the consumer in cmuxFeatureTests.swift
(the for await chunk in store.terminalOutputStream(...) body where
self?.lines.append(...) is called) to skip metadata-only frames by guarding that
String(data: chunk.bytes, encoding: .utf8) produces a non-empty string before
appending — e.g., continue when chunk.bytes.isEmpty or when the decoded string
is nil/empty — so collector.lines only receives real text lines for accurate
collector.lines.count-based waits/assertions.

In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift`:
- Around line 91-95: The loop currently feeds every full snapshot into
MobileLocalScrollEngine.noteFullSnapshot(scrollbackRows:), which lets alt-screen
snapshots overwrite primary-screen latches; change the logic so that after
view.setActiveScreen(isAlternate: meta.isAlternateScreen) and
view.setHeldScrollbackRows(meta.scrollbackRows) you only call
MobileLocalScrollEngine.noteFullSnapshot(scrollbackRows:) when
meta.isAlternateScreen is false (i.e., for primary screen snapshots only).
Locate the invocation of noteFullSnapshot and wrap it with a guard checking
!meta.isAlternateScreen (keep view.setHeldScrollbackRows as-is so UI state still
receives the rows).

---

Outside diff comments:
In
`@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+TerminalReplay.swift:
- Around line 71-74: Currently the code silently converts malformed replay
payloads into nils (using try? and flatMap with Data(base64Encoded:)), which
masks errors; change MobileTerminalReplayResponse.decode(data) to use try so
decoding failures throw, and explicitly validate base64 fields (convert
payload.dataBase64 and payload.snapshotBase64 with Data(base64Encoded:) and if
they return nil throw MobileShellComposite.Error.invalidResponse) instead of
mapping to nil; do the same for payload.renderGrid validation so any malformed
payload triggers the catch path. Also apply the identical fix to the other
occurrence that currently uses the same optional-decoding pattern (the block
around lines 114-117).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 38dabc10-0a69-4d31-abc7-312aa6aa357d

📥 Commits

Reviewing files that changed from the base of the PR and between 1930fcb and 102a8b8.

📒 Files selected for processing (10)
  • .github/workflows/ci.yml
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalOutputChunkDeliveryTests.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputChunk.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+Scroll.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileLocalScrollEngine.swift
  • ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift

Comment on lines +28 to +29
for await chunk in store.terminalOutputStream(surfaceID: surfaceID) {
self?.lines.append(String(data: chunk.bytes, encoding: .utf8) ?? "")

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 | 🟡 Minor | ⚡ Quick win

Ignore metadata-only chunks when collecting output lines.

At Line 29, empty-byte chunks are appended as "". Since metadata-only frames are valid, this can add synthetic entries and make collector.lines.count-based waits/assertions pass on non-text events.

Suggested patch
-            for await chunk in store.terminalOutputStream(surfaceID: surfaceID) {
-                self?.lines.append(String(data: chunk.bytes, encoding: .utf8) ?? "")
-            }
+            for await chunk in store.terminalOutputStream(surfaceID: surfaceID) {
+                guard !chunk.bytes.isEmpty else { continue }
+                self?.lines.append(String(decoding: chunk.bytes, as: UTF8.self))
+            }
🤖 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 `@ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift` around lines
28 - 29, The loop consuming store.terminalOutputStream(surfaceID:) is appending
empty strings for metadata-only chunks (chunk.bytes is empty), which pollutes
collector.lines; change the consumer in cmuxFeatureTests.swift (the for await
chunk in store.terminalOutputStream(...) body where self?.lines.append(...) is
called) to skip metadata-only frames by guarding that String(data: chunk.bytes,
encoding: .utf8) produces a non-empty string before appending — e.g., continue
when chunk.bytes.isEmpty or when the decoded string is nil/empty — so
collector.lines only receives real text lines for accurate
collector.lines.count-based waits/assertions.

Comment on lines +91 to +95
if let meta = chunk.meta {
view.setActiveScreen(isAlternate: meta.isAlternateScreen)
if meta.isFullSnapshot {
view.setHeldScrollbackRows(meta.scrollbackRows)
}

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

Only feed primary-screen full snapshots into noteFullSnapshot.

MobileLocalScrollEngine.noteFullSnapshot(scrollbackRows:) is documented as a primary snapshot input, but this loop invokes it for every full snapshot. If a replay/self-heal lands while meta.isAlternateScreen is true, the engine overwrites heldScrollbackRows plus its fetch/restore latches with alt-screen state, so when the session returns to primary it can immediately think local history is empty or fully loaded and mis-handle deeper-fetch restore/retry.

Suggested fix
                     if let meta = chunk.meta {
                         view.setActiveScreen(isAlternate: meta.isAlternateScreen)
-                        if meta.isFullSnapshot {
+                        if meta.isFullSnapshot, !meta.isAlternateScreen {
                             view.setHeldScrollbackRows(meta.scrollbackRows)
                         }
                     }

As per coding guidelines, replay/local-history paths must not silently drop or misapply history when cross-mode or stale state is possible.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let meta = chunk.meta {
view.setActiveScreen(isAlternate: meta.isAlternateScreen)
if meta.isFullSnapshot {
view.setHeldScrollbackRows(meta.scrollbackRows)
}
if let meta = chunk.meta {
view.setActiveScreen(isAlternate: meta.isAlternateScreen)
if meta.isFullSnapshot, !meta.isAlternateScreen {
view.setHeldScrollbackRows(meta.scrollbackRows)
}
}
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift`
around lines 91 - 95, The loop currently feeds every full snapshot into
MobileLocalScrollEngine.noteFullSnapshot(scrollbackRows:), which lets alt-screen
snapshots overwrite primary-screen latches; change the logic so that after
view.setActiveScreen(isAlternate: meta.isAlternateScreen) and
view.setHeldScrollbackRows(meta.scrollbackRows) you only call
MobileLocalScrollEngine.noteFullSnapshot(scrollbackRows:) when
meta.isAlternateScreen is false (i.e., for primary screen snapshots only).
Locate the invocation of noteFullSnapshot and wrap it with a guard checking
!meta.isAlternateScreen (keep view.setHeldScrollbackRows as-is so UI state still
receives the rows).

Source: Coding guidelines

lawrencecchen added a commit that referenced this pull request Jun 12, 2026
…roll from the Mac, #5628)

Composite drops the old inline requestTerminalReplay in favor of the
branch's MobileShellComposite+TerminalReplay extension (deeper-
scrollback fetch). Representable takes the branch's chunk/meta consume
loop with dog's DEBUG stream-cancel diagnostic re-added. Surface view
unions dog's composer/paste delegate methods with the branch's
didReachLocalHistoryTop hook.
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
…#5596/#5625/#5628) over current main

Beta queue (#5876/#5872/#5869/#5875/#5927/#5912/#5726/#5776/#5916) is now on
main; conflicts resolved by taking main as authoritative for the merged
workspace-list/notifications/read-state/close surface, while preserving the
carry-set: terminal.paste capability (#5572), hidden-input strings (#5596),
smooth-scroll/scroll-to-bottom (#5628), and the live notifications feed
(notificationsStore + mobile.notifications.list/mark_read dispatch). Dropped
the superseded mute design. Capability flags unified onto main's computed
supportedHostCapabilities set (added computed supportsTerminalPaste +
DEBUG supportsDogfoodChecklist). xcstrings merged (HEAD-precedence union,
mute keys dropped). pbxproj took HEAD consistently; budget regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
…d observer + replay scrollback budget)

Taking HEAD's pbxproj in the step-1 fold dropped the dog-only file
references for Sources/Mobile/MobileNotificationListObserver.swift
(#5726 notifications.updated feed) and MobileReplayScrollbackBudget.swift
(#5628 smooth-scroll). Both are tracked on disk and referenced by
AppDelegate but were unwired, breaking the macOS compile. Re-added the
PBXBuildFile/PBXFileReference/group/sources entries; macOS Debug builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Superseded by #6067 (Smooth iOS terminal scrollback with local prefetch, merged 06-14) which lands the same local-scrollback decoupling with a more complete implementation. Closing this stage-1 PR to avoid duplicate work; the next dog/beta fold takes main's #6067 version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant