Skip to content

Fix iOS terminal typing render-grid drift - #7175

Merged
austinywang merged 38 commits into
mainfrom
issue-7164-ios-typing-echo-drift
Jul 2, 2026
Merged

austinywang merged 38 commits into
mainfrom
issue-7164-ios-typing-echo-drift

Conversation

@austinywang

@austinywang austinywang commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #7164

Summary

  • add deterministic regressions for fast input ACKs outrunning render-grid delivery and for full snapshot replay presenting an empty reset frame
  • keep render-grid input ACKs as a pending target sequence instead of forcing a replay on consecutive typing ACKs
  • drop render-grid frames/replays below the pending input target so the phone does not paint an older cursor frame before the true cursor frame arrives
  • replace full snapshot RIS (ESC c) with a synchronized soft reset + clear so replay does not flash a blank surface before content lands
  • bound replay retries after failures and stop live-event replay requests once retries are exhausted
  • fix the new tests' own harness bugs: the replay presentation probe now treats the CRLF flow separator as CR + LF (Swift clusters "\r\n" into one Character), and frames carrying long marker texts are built 40 columns wide so frame validation no longer rejects them
  • merge origin/main and resolve the .github/swift-file-length-budget.tsv conflict with post-merge actual counts (MobileShellComposite.swift 7813, WorkspaceUnitTests.swift 7404)
  • structured-review fixes for RIS-replacement parity and retry bounding:
    • count no-progress replay responses (empty, bytes without a sequence, stale sequence, failed non-barrier request) against the replay retry budget so live deltas cannot re-arm mobile.terminal.replay forever
    • emit OSC 133;D per screen so replayed cells do not inherit stale semantic prompt/input state
    • overwrite Ghostty's XTSAVE saved-mode bank with defaults (two CSI ? … s batches under the 24-param cap) so a later XTRESTORE cannot resurrect stale modes
    • lead the per-screen reset bundle with DECSCUSR 0 so a stale primary cursor shape cannot reappear when a replayed TUI exits the alternate screen
    • reset each screen's saved cursor at home/default in the preamble and stop saving the snapshot cursor after paint, so a later bare DECRC/?1048l lands on the RIS baseline
    • clear stale DECCOLM: ?3l after ?40l (Ghostty clears the stored value without resizing when mode 40 is off) plus mode 3 in the saved-bank overwrite
    • reset the shared replay retry budget when a new pending-input catch-up episode starts, so an earlier barrier episode that burned retries cannot suppress the repair replay
    • arm a bounded replay when a hybrid alternate-exit frame is dropped behind pending input, so raw-byte suppression cannot wedge the surface on stale TUI content
  • second origin/main merge: integrate Fix iOS render-grid load garble (#7159) #7171's replay-lifecycle extraction and full-replacement staleness with this PR's pending-input catch-up (marker/counter clears woven into MobileShellComposite+TerminalReplayLifecycle.swift, tests migrated to the shared renderGridEventFrame fixtures and enqueueReplayRenderGridFrames)

Test Plan

  • swift test in Packages/Shared/CMUXMobileCore: 128 tests pass
  • swift test in Packages/iOS/CmuxMobileShell: 335 tests pass
  • xcodebuild -scheme CmuxMobileShell -destination 'generic/platform=iOS Simulator' build: succeeds (covers the MobileDebugLog compile error CI hit at an older head, fixed in 661fee5)
  • python3 scripts/swift_file_length_budget.py: budget respected
  • structured autoreview (codex, branch vs origin/main): clean — no accepted/actionable findings across the final run; cmux policy check clean. The one remaining reviewer note is out-of-scope vendor content (bonsplit pinned-tab width vs focus, already merged on bonsplit main)

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


Summary by cubic

Fixes #7164. Stabilizes iOS terminal typing echo and removes blank flashes by dropping stale render‑grid behind input, bounding replays, and using a synchronized full‑snapshot restore (no RIS) with correct mode, color, and cursor resets, including defaulting ?1048 save‑cursor state.

  • Bug Fixes
    • Typing catch‑up: track the latest input ACK as the target; drop event/replay frames older than it; coalesce subscription refreshes; reset the replay‑retry budget on a new episode; in hybrid, if a dropped event shows primary while the tracked screen is still alternate, request a bounded replay.
    • Replays and retries: require replay when non‑full deltas arrive behind the target; keep the dropped‑frame marker until replay lands; treat delivered replay grids without a payload sequence as progress via the frame’s stateSeq; count no‑progress responses (empty, bytes without seq, stale seq, failed non‑barrier) against a bounded retry budget; retry stale replays (barrier and non‑barrier); stop live‑event replay requests after exhaustion and clear the barrier.
    • Full snapshot restore: run one synchronized reset that clears both screens and scrollback, ends active OSC 8, resets OSC 133 prompt state per screen, restores grapheme mode (?2027) before paint, resets dynamic colors (OSC 10/11/12 and fallbacks 110/111/112) so clears use the frame default, resets the primary cursor shape before alternate‑screen entry, and resets each screen’s saved cursor to the RIS baseline.
    • Modes: overwrite Ghostty’s saved‑mode bank at defaults with XTSAVE (two batches under the 24‑param cap), including cursor modes ?12/?25, save‑cursor ?1048, and ?3; clear stale DECCOLM without resizing (?40l then ?3l); reset live ?1048 to its default; then reapply captured modes after a default baseline while skipping geometry, screen‑switch/save‑cursor, reporting, and sync‑output modes.

Written for commit 98101ba. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved full-snapshot and alternate-screen restoration to reduce blank/reset flashes and ensure proper screen/mode baseline handling.
    • Ensure hyperlinks are fully terminated before snapshot painting, and dynamic RGB default colors reset correctly.
    • Refined render-grid liveness so frames are skipped or replayed appropriately when behind pending terminal input.
    • Reworked replay retry and barrier behavior to prevent stuck or excessive replay attempts after no progress.
  • Tests

    • Added snapshot replay probe tests for ordering/state (including hyperlink and clear styling) and expanded input catch-up and retry exhaustion coverage.

@vercel

vercel Bot commented Jul 2, 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 Jul 2, 2026 8:40pm
cmux-staging Building Building Preview, Comment Jul 2, 2026 8:40pm

@coderabbitai

coderabbitai Bot commented Jul 2, 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
🚥 Pre-merge checks | ❌ 20

❌ Failed checks (20 inconclusive)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Blocking Runtime ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Browser Automation Off-Main ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Expensive Synchronous Load ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Cache Substitution Correctness ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux No Hacky Sleeps ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Algorithmic Complexity ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Concurrency ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift @Concurrent ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift File And Package Boundaries ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swiftpm Lockfiles ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Logging ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux User-Facing Error Privacy ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Full Internationalization ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swiftui State Layout ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Architecture Rethink ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Auxiliary Window Close Shortcuts ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Source Artifacts ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux No Test Or Debug Seam In Production Source ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux No Ambient Global State ❓ Inconclusive Custom check execution failed before a final verdict was produced. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-7164-ios-typing-echo-drift

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.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes iOS terminal drift caused by fast input ACKs outrunning render-grid delivery. It replaces the previous approach of forcing a full replay on consecutive typing ACKs with a pending-target tracking scheme that drops frames below the latest input ACK, defers replay until the target is met, and bounds retry attempts.

  • Pending-input drop tracking: pendingTerminalByteEndSeqBySurfaceID now stores a target sequence updated monotonically by input ACKs. shouldDropRenderGridBehindPendingInput gates all delivery paths and uses a pendingTerminalInputDroppedRenderGridSurfaceIDs marker to hold non-full frames until a full replay clears the marker.
  • Full-snapshot reset without RIS: fullSnapshotBytes() now opens ?2026h first so all resets happen inside synchronized output, preventing the blank-frame flash structurally.
  • Deterministic regression tests covering fast-ACK coalescing, stale-frame suppression, replay-after-drop, retry exhaustion, hyperlink state, and default-background clear style.

Confidence Score: 5/5

Safe to merge. The pending-input drop state machine is internally consistent, all three state variables are cleared in matched pairs across every code path, and retry budgets correctly bound the repair-replay loop.

The two main behavioral changes are well-scoped and covered by deterministic regression tests. State machine invariants are cleared together in every exit path. The retry exhaustion path correctly falls back from barrier retries to non-barrier retries before giving up. The full-snapshot reset opens synchronized output before any clearing, preventing the blank-frame flash at the architecture level.

No files require special attention. The most complex logic is in MobileShellComposite.swift around the replay response handler, but the retry and drop paths are well-commented and tested.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Core orchestration of the pending-input drop mechanism. Adds pendingTerminalInputDroppedRenderGridSurfaceIDs, shouldDropRenderGridBehindPendingInput, and the replay-response retry block. Logic is internally consistent; state is always cleared in matched pairs with pendingTerminalByteEndSeqBySurfaceID.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayRetry.swift New extension adding non-barrier retry accounting and no-progress budget consumption. Guards are correct: prepareNonBarrierTerminalReplayFailureRetry checks the shared counter, and consumeTerminalReplayFailureRetryAfterNoProgress is gated on the dropped-surface marker.
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift Replaces ESC c (RIS) with a synchronized soft reset that enables ?2026h first. Adds per-screen hyperlink/semantic-prompt resets and calls appendDefaultModeBaseline a second time before mode reapplication to cover older frames without a modes array.
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+ModeReset.swift New extension factoring out structural screen reset, default mode baseline, XTSAVE saved-bank overwrite, pre-paint mode restores, and excluded-mode predicate.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift Adds the shouldDropRenderGridBehindPendingInput gate at the top of the delivery path and clears pendingTerminalInputDroppedRenderGridSurfaceIDs in clearTerminalViewportAndOutputSinkState.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift Adds matched removals of pendingTerminalInputDroppedRenderGridSurfaceIDs and terminalReplayFailureRetryCountsBySurfaceID in markTerminalBytesDelivered and beginTerminalReplayBarrier, preventing any state desync.
Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift New 660-line regression suite covering ACK coalescing, stale-frame suppression, delta-after-drop replay gating, hybrid alt-screen wedge detection, and retry+exhaustion paths.
Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayRetryExhaustionTests.swift New test exercising the full retry-exhaustion path, consistent with maxTerminalReplayFailureRetries = 2.
Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift New unit tests verifying no blank frame before content, hyperlinks closed pre-paint, and clears using the frame default background.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Input ACK received] --> B{previousPendingSeq set?}
    B -- No --> C[Reset retry counter, store targetSeq]
    B -- Yes --> D{targetSeq > previousPendingSeq?}
    D -- No --> E{droppedSurfaceIDs contains surface?}
    E -- Yes --> F[requestTerminalReplayAfterDroppedRenderGrid]
    E -- No --> G[return no-op]
    D -- Yes --> H[Update pendingSeq, refresh subscription]
    I[Render-grid frame arrives] --> J[shouldDropRenderGridBehindPendingInput]
    J --> K{pendingSeq AND stateSeq < pendingSeq?}
    K -- Yes --> L[Insert into droppedSurfaceIDs, return DROP]
    K -- No --> M{droppedSurfaceIDs AND non-full AND non-patch?}
    M -- Yes --> N{source == event?}
    N -- Yes --> O[requestReplay, return DROP]
    N -- No --> P[return DROP, replay handler retries]
    M -- No --> Q[return ACCEPT, deliver frame]
    Q --> R[markTerminalBytesDelivered]
    R --> S{endSeq >= pendingSeq?}
    S -- Yes --> T[Clear pendingSeq, droppedSurfaceIDs, retryCounter]
    P --> U{prepareTerminalReplayFailureRetry < max?}
    U -- Yes --> V[counter+1, request barrier retry]
    U -- No --> W{prepareNonBarrierRetry < max?}
    W -- Yes --> X[counter+1, request non-barrier retry]
    W -- No --> Y[clearBarrier: pending_input_exhausted]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Input ACK received] --> B{previousPendingSeq set?}
    B -- No --> C[Reset retry counter, store targetSeq]
    B -- Yes --> D{targetSeq > previousPendingSeq?}
    D -- No --> E{droppedSurfaceIDs contains surface?}
    E -- Yes --> F[requestTerminalReplayAfterDroppedRenderGrid]
    E -- No --> G[return no-op]
    D -- Yes --> H[Update pendingSeq, refresh subscription]
    I[Render-grid frame arrives] --> J[shouldDropRenderGridBehindPendingInput]
    J --> K{pendingSeq AND stateSeq < pendingSeq?}
    K -- Yes --> L[Insert into droppedSurfaceIDs, return DROP]
    K -- No --> M{droppedSurfaceIDs AND non-full AND non-patch?}
    M -- Yes --> N{source == event?}
    N -- Yes --> O[requestReplay, return DROP]
    N -- No --> P[return DROP, replay handler retries]
    M -- No --> Q[return ACCEPT, deliver frame]
    Q --> R[markTerminalBytesDelivered]
    R --> S{endSeq >= pendingSeq?}
    S -- Yes --> T[Clear pendingSeq, droppedSurfaceIDs, retryCounter]
    P --> U{prepareTerminalReplayFailureRetry < max?}
    U -- Yes --> V[counter+1, request barrier retry]
    U -- No --> W{prepareNonBarrierRetry < max?}
    W -- Yes --> X[counter+1, request non-barrier retry]
    W -- No --> Y[clearBarrier: pending_input_exhausted]
Loading

Reviews (20): Last reviewed commit: "Reset ?1048 save-cursor mode state in th..." | Re-trigger Greptile

@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

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

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

6628-6638: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Coalesce refreshes instead of calling subscribe on every ACK. terminalSubscriptionRefreshTask only suppresses overlapping refreshes; requestTerminalEventSubscription still performs a full mobile.events.subscribe RPC, and this branch can queue that work on every input ACK while the stream lags. Guard it so targetSeq only advances the refresh when it actually moves past the previous pending value, which avoids reissuing subscribe requests during a typing burst.

🤖 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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 6628 - 6638, The input-ACK path in MobileShellComposite’s
input_seq_wait branch is triggering refreshTerminalEventSubscription too often,
causing repeated mobile.events.subscribe work while the stream lags. Update the
logic around pendingTerminalByteEndSeqBySurfaceID and
refreshTerminalEventSubscription so the refresh is only requested when targetSeq
actually increases beyond the previous pending value, and skip the call when the
ACK does not advance the pending sequence. Keep the change localized to the
canRenderGridAdvancePendingSeq / terminalEventListenerTask check so the
coalescing behavior prevents duplicate subscription refreshes during typing
bursts.
🤖 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.

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 6628-6638: The input-ACK path in MobileShellComposite’s
input_seq_wait branch is triggering refreshTerminalEventSubscription too often,
causing repeated mobile.events.subscribe work while the stream lags. Update the
logic around pendingTerminalByteEndSeqBySurfaceID and
refreshTerminalEventSubscription so the refresh is only requested when targetSeq
actually increases beyond the previous pending value, and skip the call when the
ACK does not advance the pending sequence. Keep the change localized to the
canRenderGridAdvancePendingSeq / terminalEventListenerTask check so the
coalescing behavior prevents duplicate subscription refreshes during typing
bursts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5b332d0-f7c4-4f59-bd7d-5ee2ba9af2e6

📥 Commits

Reviewing files that changed from the base of the PR and between 9374b13 and 1d2b505.

📒 Files selected for processing (5)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// update so the client never shows a partially-restored screen.
bytes.append(Data("\u{1B}c".utf8))
bytes.append(Data("\u{1B}[?2026h".utf8))
let screenStateReset = "\u{1B}[1\"q\u{1B}[0\"q\u{1B}[999<u\u{1B}[0;1=u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kitty keyboard resets in screenStateReset are silently dropped — private marker must lead the parameter string.

\u{1B}[999<u and \u{1B}[0;1=u put the private marker (<, =) after the digits. In Ghostty's VT parser, bytes 0x3C–0x3F are only collected from csi_entry; once the parser is in csi_param (after any digit), they transition to csi_ignore and the whole sequence is discarded:

  • ghostty/src/terminal/parse_table.zig — csi_entry: range(&result, 0x3C, 0x3F, source, .csi_param, .collect); csi_param: range(&result, 0x3C, 0x3F, source, .csi_ignore, .none)
  • ghostty/src/terminal/stream.zig:1827 dispatches kitty push/pop/set off input.intermediates[0] being >/</=, which only happens with the leading form.

So the emitted bytes are no-ops: the kitty key-flag stack is not reset (RIS used to reset it), and the comment above ("key/input flags") overclaims. Fix is to lead with the marker:

"\u{1B}[<999u\u{1B}[=0;1u"

(CSI < 999 u pops the whole kitty stack; CSI = 0;1 u then forces current flags to 0 — matching what ESC c used to guarantee.)

@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

Caution

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

⚠️ Outside diff range comments (1)
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift (1)

87-105: 🩺 Stability & Availability | 🔵 Trivial

Synchronized-update prologue design is sound.

Wrapping the entire reset/clear sequence inside ?2026h ... ?2026l (instead of leading with ESC c) correctly defers presentation until the full snapshot lands, matching the PR's stated goal. The manual reset subset (DECSASD, key-modifier reset, margins, DECLRMM, DECST8C, kitty keyboard-flag stack pop, DECSCA, G0–G3 charset) plus the documented rationale in the comment gives good confidence this is a deliberate, tested replacement for RIS.

This kind of VT-protocol-level change is inherently hard to fully validate with byte-exact unit tests alone (they confirm the bytes emitted, not the actual rendered result). Given the PR's test plan notes no local build/reload was run, consider a manual on-device pass (mirrored terminal reconnect/catch-up scenario) before merge to catch any visual artifacts the string-comparison tests can't detect.

🤖 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/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift`
around lines 87 - 105, The synchronized-update prologue in fullSnapshotBytes()
looks correct, but it still needs real-world validation because byte-for-byte
tests won’t catch rendering artifacts. Before merging, verify the new
?2026h/?2026l reset flow on-device in the mirrored reconnect/catch-up path and
confirm the full snapshot renders cleanly with no empty-frame flash or visual
corruption; if you find issues, adjust the reset sequence in fullSnapshotBytes()
accordingly.
🤖 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/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift`:
- Around line 79-102: The fallback path in consumeEscape only skips simple ESC
intermediates, so OSC sequences introduced by ESC ] are being treated as
printable text and leaking into the grid. Update consumeEscape in
MobileTerminalRenderGridSnapshotReplayTests to explicitly recognize OSC and
consume everything through the OSC terminator (ESC \) before returning, while
keeping the existing ESC c and CSI handling intact. Use the existing
consumeEscape, consumeCSI, and isESCIntermediateByte helpers as the main entry
points when locating the fix.

---

Outside diff comments:
In
`@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift`:
- Around line 87-105: The synchronized-update prologue in fullSnapshotBytes()
looks correct, but it still needs real-world validation because byte-for-byte
tests won’t catch rendering artifacts. Before merging, verify the new
?2026h/?2026l reset flow on-device in the mirrored reconnect/catch-up path and
confirm the full snapshot renders cleanly with no empty-frame flash or visual
corruption; if you find issues, adjust the reset sequence in fullSnapshotBytes()
accordingly.
🪄 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: f1c937af-2d4a-46a3-9786-ba9ac99f3f87

📥 Commits

Reviewing files that changed from the base of the PR and between 1d2b505 and 91c39dd.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (7)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift">

<violation number="1" location="Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift:202">
P2: In `retryTerminalReplayAfterAckReset`, `pendingTerminalInputDroppedRenderGridSurfaceIDs` is cleared unconditionally before `prepareTerminalReplayFailureRetry` confirms a retry replay can proceed. If retries are exhausted and the function returns after preserving the barrier, the dropped-frame marker is lost even though no replay ever landed, breaking the PR invariant to keep the marker until a corresponding replay arrives. Consider moving the removal after the retry succeeds, or restoring the marker when the retry preparation fails.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

terminalOutputStreamTokensBySurfaceID[surfaceID] = UUID()
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalInputDroppedRenderGridSurfaceIDs.remove(surfaceID)

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: In retryTerminalReplayAfterAckReset, pendingTerminalInputDroppedRenderGridSurfaceIDs is cleared unconditionally before prepareTerminalReplayFailureRetry confirms a retry replay can proceed. If retries are exhausted and the function returns after preserving the barrier, the dropped-frame marker is lost even though no replay ever landed, breaking the PR invariant to keep the marker until a corresponding replay arrives. Consider moving the removal after the retry succeeds, or restoring the marker when the retry preparation fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift, line 202:

<comment>In `retryTerminalReplayAfterAckReset`, `pendingTerminalInputDroppedRenderGridSurfaceIDs` is cleared unconditionally before `prepareTerminalReplayFailureRetry` confirms a retry replay can proceed. If retries are exhausted and the function returns after preserving the barrier, the dropped-frame marker is lost even though no replay ever landed, breaking the PR invariant to keep the marker until a corresponding replay arrives. Consider moving the removal after the retry succeeds, or restoring the marker when the retry preparation fails.</comment>

<file context>
@@ -199,6 +199,7 @@ extension MobileShellComposite {
         terminalOutputStreamTokensBySurfaceID[surfaceID] = UUID()
         deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
         pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
+        pendingTerminalInputDroppedRenderGridSurfaceIDs.remove(surfaceID)
         terminalReplayBarrierAckStreamTokensBySurfaceID.removeValue(forKey: surfaceID)
         terminalReplayBarrierAckCoveredDroppedOutputCountsBySurfaceID.removeValue(forKey: surfaceID)
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional: retryTerminalReplayAfterAckReset abandons the whole input catch-up epoch — deliveredTerminalByteEndSeq and pendingTerminalByteEndSeq are removed in the same block, so a kept marker would reference a dead epoch under a rotated stream token. After an exhausted retry the preserved replay barrier is the gate that keeps stale output from delivering (tested by terminalReplayBarrierStaysActiveAfterRetryExhaustionWithoutDroppedOutput); the marker adds nothing behind it, and any later successful replay clears all of this state via markTerminalBytesDelivered.

— Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The marker point is fair to call out, but it’s wrong for this path: retryTerminalReplayAfterAckReset drops the whole catch-up epoch, so keeping pendingTerminalInputDroppedRenderGridSurfaceIDs would point at dead state after the stream token rotates. The barrier is the thing that stays active after retry exhaustion and prevents stale output from delivering, so the parent comment doesn’t apply here.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift">

<violation number="1" location="Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift:202">
P2: In `retryTerminalReplayAfterAckReset`, `pendingTerminalInputDroppedRenderGridSurfaceIDs` is cleared unconditionally before `prepareTerminalReplayFailureRetry` confirms a retry replay can proceed. If retries are exhausted and the function returns after preserving the barrier, the dropped-frame marker is lost even though no replay ever landed, breaking the PR invariant to keep the marker until a corresponding replay arrives. Consider moving the removal after the retry succeeds, or restoring the marker when the retry preparation fails.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

@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.

♻️ Duplicate comments (1)
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift (1)

91-91: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Kitty keyboard reset in screenStateReset is still a silent no-op (marker after digits).

\u{1B}[999<u and \u{1B}[0;1=u place the private marker (<, =) after the parameter digits. Per the previous review on this exact sequence, Ghostty's CSI parser only collects 0x3C-0x3F markers while in csi_entry; once a digit is seen the parser transitions to csi_param, where those bytes send it to csi_ignore and the whole sequence is discarded. So the kitty key-flag stack is never actually reset here, contradicting the "key/input flags" claim in the comment above line 97-100. This string is now load-bearing for the new synchronized restore path (referenced at lines 103, 115, and 138), so the gap persists into this rework.

🐛 Proposed fix
-        let screenStateReset = "\u{1B}[1\"q\u{1B}[0\"q\u{1B}[999<u\u{1B}[0;1=u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B"
+        let screenStateReset = "\u{1B}[1\"q\u{1B}[0\"q\u{1B}[<999u\u{1B}[=0;1u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B"

Also applies to: 103-103, 115-115, 138-138

🤖 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/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift`
at line 91, The kitty reset sequence in screenStateReset is malformed because
the private markers are placed after the numeric parameters, so Ghostty discards
it and the key/input flags are never reset. Update the reset string in
MobileTerminalRenderGridReplay so the kitty CSI markers are emitted in the
correct order before any digits, and make sure the synchronized restore path
that uses this constant still references the corrected sequence consistently in
the surrounding restore logic.
🤖 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.

Duplicate comments:
In
`@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift`:
- Line 91: The kitty reset sequence in screenStateReset is malformed because the
private markers are placed after the numeric parameters, so Ghostty discards it
and the key/input flags are never reset. Update the reset string in
MobileTerminalRenderGridReplay so the kitty CSI markers are emitted in the
correct order before any digits, and make sure the synchronized restore path
that uses this constant still references the corrected sequence consistently in
the surrounding restore logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 95b4e425-21b2-4531-996f-89a7b50e04ad

📥 Commits

Reviewing files that changed from the base of the PR and between 91c39dd and ff7b0c1.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift

austinywang and others added 2 commits July 2, 2026 03:58
…-echo-drift

# Conflicts:
#	.github/swift-file-length-budget.tsv
#	Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
#	Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
The replay failure retry counter is shared with barrier replay
failures, and a successful barrier ack clear leaves it at its
high-water mark. A surface whose cold-attach replay succeeded only
after burning the budget would enter the next typing catch-up already
exhausted, so the first dropped render-grid delta could never request
the repair replay and non-full deltas stayed dropped until an unrelated
full replacement arrived. Clear the counter when a pending input target
transitions from none to set, so each catch-up episode gets the full
bounded budget.

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

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift">

<violation number="1" location="Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift:202">
P2: In `retryTerminalReplayAfterAckReset`, `pendingTerminalInputDroppedRenderGridSurfaceIDs` is cleared unconditionally before `prepareTerminalReplayFailureRetry` confirms a retry replay can proceed. If retries are exhausted and the function returns after preserving the barrier, the dropped-frame marker is lost even though no replay ever landed, breaking the PR invariant to keep the marker until a corresponding replay arrives. Consider moving the removal after the retry succeeds, or restoring the marker when the retry preparation fails.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

austinywang and others added 4 commits July 2, 2026 04:19
The pending-input filter runs before the hybrid screen-transition
advisory, so a primary render-grid frame that raced the input ACK and
landed behind the pending sequence was silently dropped. That frame can
be the only signal that the host left the alternate screen, and hybrid
transport keeps suppressing raw primary bytes while the tracked screen
stays alternate, wedging the surface on stale TUI content until an
unrelated frame arrived. Request a replay (bounded by the retry budget)
when an event frame dropped behind pending input reports primary while
the tracked screen is still alternate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The XTSAVE bank overwrite skipped ?12 (cursor blinking) and ?25 (cursor
visibility), so a stale XTSAVE'd ?25l or ?12h on a reused surface
survived the full replay and a later XTRESTORE could hide or mis-blink
the cursor despite the frame's cursor restore. Force both modes to
their Ghostty defaults (?12l, ?25h) right before the bank overwrite so
their saved slots are deterministic; the paint sequence and final
cursor restore adjust the live values afterwards without touching the
bank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The barrier-episode comment could be misread as describing the general
catch-up counter clear; spell out that markTerminalBytesDelivered leaves
the counter alone here because no pending input target is set. Use
maxTerminalReplayFailureRetries for the scripted failure count instead
of a hardcoded 2 so the test tracks the production budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The synchronized reset saved the replayed snapshot cursor with a
trailing DECSC, so a later bare DECRC or ?1048l restore jumped to the
snapshot cursor instead of the default RIS left behind, and a stale
DECSC from the reused surface survived on whichever screen the replay
did not end on. Save at home with the default pen once per screen
inside the reset preamble and stop saving after the cursor restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 2 commits July 2, 2026 12:53
RIS reset DEC private mode 3, but the manual baseline skipped it
entirely, so a reused surface with DECCOLM set or XTSAVE'd kept that
geometry mode across a full replay and a later CSI ?3 h/l/r could
resize the terminal away from the authoritative remote grid. Emit ?3l
right after ?40l — with mode 40 off Ghostty clears the stored value
without resizing — and include mode 3 in the saved-bank overwrite so
both the live and saved slots return to the RIS baseline. The captured
frame's own ?3 stays excluded from replay.

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

# Conflicts:
#	.github/swift-file-length-budget.tsv
#	Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
RIS cleared the whole mode state, but the manual baseline never touched
DEC mode 1048, so a reused surface kept its live/XTSAVE'd save-cursor
mode value across a full replay where DECRQM could report it and
XTRESTORE could act on it. Force ?1048l with the other cursor-mode
defaults (its restore action is neutral there: the preamble re-homes
and re-saves the cursor immediately after) and add 1048 to the
saved-bank overwrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@austinywang
austinywang merged commit 410429c into main Jul 2, 2026
40 of 42 checks passed
@austinywang
austinywang deleted the issue-7164-ios-typing-echo-drift branch July 2, 2026 21:04
austinywang added a commit that referenced this pull request Jul 2, 2026
…erlap-artifacts

Adopt main's typing render-grid drift fix (#7175): the full-snapshot
replay's synchronized non-RIS reset prelude and mode baseline supersede
this branch's screen-switch-only mode filter (isReplayExcludedMode
keeps DECOM and autowrap restored on fulls), and the relocated
alternate-screen mode-replay test replaces this branch's copy. Take
main's pending-input render-grid gate wholesale. Regenerate the file
length budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang added a commit that referenced this pull request Jul 3, 2026
…grid fixes)

Re-merge to pick up #7149/#7155/#7175/#7176 and the iOS auth-composition rework.
Only .github/swift-file-length-budget.tsv conflicted (regenerated). AppCompositionRoot/
CMUXMobileRootScene auth changes auto-merged cleanly alongside the keyboard-correction
preference injection; init + both call sites verified consistent. Core fixes intact
(subscription streamID capture, deinit clientHandedOff, TerminalInputDebugLog struct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jul 3, 2026
…m gap)

OpenCode still showed a bottom letterbox even on latest main (with #7150/#7175/
#7172): the capture set the font PER-AGENT right before each screenshot, and
OpenCode doesn't repaint the newly-added bottom rows after that late resize (its
pure-black bg exposes the terminal-default gray in the unpainted rows; claude/
codex/pi hide it because their bg matches the default). Set the font ONCE before
opening any terminal so each surface opens at its final grid with no
resize-while-shown, and give it a longer settle to fully paint.
lawrencecchen added a commit that referenced this pull request Jul 4, 2026
CI's release-build job (twice, deterministically) failed with "the
compiler is unable to type-check this expression in reasonable time" on
the 7-operand chained string-literal concatenation in
appendDefaultModeBaseline, code that landed on main in
#7175 and was never exercised by
a completed main CI run since (every run was superseded/cancelled). The
chain becomes += statements with identical content, which type-check
trivially; CMUXMobileCore release build and its 146 package tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jul 4, 2026
…le scrolled up (#7196)

* Add failing test: iOS terminal input while scrolled up must snap to bottom

The iOS Ghostty surface is a display-only mirror: typed bytes go to the
Mac and the echo returns in the output stream. When the user scrolls up
into local scrollback and then types, the Mac updates at the prompt but
the phone stays on old scrollback, so the terminal reads as frozen.

In-simulator behavior test mounting a real GhosttySurfaceView +
libghostty surface: seed 300 lines, scroll up, simulate typed input via
the input proxy, expect the viewport back at the bottom. FAILS on this
commit; the fix lands in the next commit. A companion test locks the
opposite invariant: passive output must not force the viewport down
while the user reads scrollback.

Test hooks only in production code: a DEBUG-only
debugSkipRenderDispatchForTesting flag skips render dispatch (the
scene-less xctest host can never complete a Metal present, which trips
the render-stall recovery), mirroring the same hook on the
task-ios-terminal-vspace branch. TerminalInputDebugLog moves to its own
file to keep GhosttySurfaceView.swift inside the file-length budget.

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

* iOS terminal: optimistically scroll to bottom on user input

All user-produced input on the surface (typing, backspace, escape
sequences from arrows/ctrl/hardware keys, paste) converges on the four
inputProxy closures. Each now routes through one shared
handleUserProducedInput(): reset the cursor blink (as before) and
enqueue Ghostty's scroll_to_bottom binding action, so the mirror's
viewport follows the user's keystrokes to the prompt where the Mac's
echo lands. Passive output still never moves the viewport.

The binding action runs on the serial outputQueue (never inline on
main) because ghostty_surface_binding_action takes the same internal
surface lock as process_output/render_now; enqueuing behind pending
process_output also preserves ordering. The enqueue is extracted as
enqueueScrollToBottom(), now shared by the initial-output scroll and
the DEBUG bottom-scroll stress harness instead of three copies.

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

* Address review: cancel in-flight scroll on input, isolate test hook, lazy debug log

Codex P2: a flick still decelerating fought the input snap — coalesced
deltas in pendingScrollLines flush on the display-link frame after the
scroll_to_bottom enqueue, and UIScrollView momentum kept producing more,
so flick-up-then-type could land back in old scrollback.
handleUserProducedInput() now drops pending deltas and freezes the
scroll mechanics at the current offset before enqueueing the snap.

Test-seam policy (Greptile/CodeRabbit/cubic): the
debugSkipRenderDispatchForTesting flag moves out of the production class
body into the DEBUG-only Debug/ folder as a static member (instance
stored properties cannot live in extensions), matching the package's
existing debug-isolation pattern and no longer shipping in Release.

Input debug logger: message parameter is now an autoclosure so
dataSummary/interpolation never run on the typing hot path unless
CMUX_INPUT_DEBUG=1, and the body compiles to a no-op in Release so
typed user content cannot reach the unified log there.

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

* Coalesce scroll-to-bottom enqueues on the typing path

Codex review P2: every keystroke, backspace repeat, and escape sequence
enqueued its own lock-taking scroll_to_bottom onto the serial surface
queue, so key-repeat during an output/render stall could grow an
unbounded backlog of idempotent snaps behind the work the user was
waiting on. enqueueScrollToBottom() now sets an in-flight flag and skips
while one snap is queued or running; one pending snap is enough because
it executes after everything already queued. The flag clears on the
completion hop back to main and on the render-pipeline reset path (the
queue-regeneration site), so a wedged surface cannot permanently disable
the snap.

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

* Scope the render-skip test hook to the suite

Codex review P2: makeHarness() set the process-wide
debugSkipRenderDispatchForTesting flag and never restored it, so later
suites in the same test process would silently stop exercising the real
render path; the flag reference also broke non-Debug test builds. Each
test now restores the flag in its teardown alongside dismantle, and the
suite is gated to DEBUG test configurations (the hook it drives only
exists there).

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

* Generation-guard the scroll-to-bottom completion

Codex review P2: a scroll_to_bottom completion from pre-recovery queued
work could clear scrollToBottomInFlight after a new-generation input had
set it, momentarily defeating the coalescing. The completion hop now
checks surfaceGeneration like the processOutput completion does, and the
reset path (which bumps the generation) remains the owner of clearing
the flag across recoveries.

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

* Document TerminalInputAccessoryAction public symbols

Aziz documentation policy: the budget extraction moved these public
package symbols into a new file, so each case and the output accessor
now carry Swift-DocC comments.

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

* Add type-level DocC to TerminalInputAccessoryAction

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

* Fix release-build type-check timeout in mode-baseline literal

CI's release-build job (twice, deterministically) failed with "the
compiler is unable to type-check this expression in reasonable time" on
the 7-operand chained string-literal concatenation in
appendDefaultModeBaseline, code that landed on main in
#7175 and was never exercised by
a completed main CI run since (every run was superseded/cancelled). The
chain becomes += statements with identical content, which type-check
trivially; CMUXMobileCore release build and its 146 package tests pass.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jul 14, 2026
… screenshots (#6697)

* iOS: public App Store lane (com.cmux.app), privacy manifest, fastlane screenshots

Prep cmux iOS for a public App Store release alongside the existing
dev.cmux.app.beta dogfood channel.

- PrivacyInfo.xcprivacy wired into the app target: NSPrivacyTracking=false,
  UserDefaults (CA92.1) + file-timestamp (DDA9.1) reasons, product-interaction
  analytics label. No Sentry/IDFA in iOS.
- upload-testflight.sh + cloud-testflight.sh: new appstore lane
  (com.cmux.app, on-device name "cmux", cmux Distribution profile), sharing the
  existing release entitlements and cmux-ios URL scheme.
- web/services/apns/routePolicy.ts: route com.cmux.app to production APNs (+ test).
- MobileBuildType: document/test com.cmux.app as a prod bundle id.
- ios/fastlane: snapshot config (en-US + ja; iPhone 6.9" + iPad 13") driving the
  CMUX_UITEST_MOCK_DATA DEBUG state via a SnapshotUITests case.
- .github/workflows/ios-screenshots.yml: capture screenshots in CI on a DEBUG
  build (no signing), resolving the required iPhone/iPad classes at runtime;
  optional upload to App Store Connect on workflow_dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: read SNAPSHOT_DEVICES from GITHUB_ENV (set -u fix)

The resolve step exported SNAPSHOT_DEVICES to $GITHUB_ENV (for later steps) but
then echoed $SNAPSHOT_DEVICES, which is unset in the current shell, so set -u
aborted the step. Confirm by grepping the env file instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: pick devices from available simulators, skip RAM variants

Resolver picked the device TYPE 'iPad Pro 13-inch (M5) (16GB)', which has no
pre-created simulator, so fastlane errored 'not in list of available
simulators'. Resolve against available simulator DEVICES and exclude RAM-variant
(GB) names; prefer iPhone NN Pro Max + iPad Pro/Air 13-inch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: drive devices/languages from Fastfile, not Snapfile

The Snapfile's devices([...]) overrode the action's devices param, so CI's
runtime-resolved simulators were ignored and fastlane looked for the stale
'iPhone 16 Pro Max'. Move devices+languages to the Fastfile (env-overridable,
SNAPSHOT_DEVICES/SNAPSHOT_LANGUAGES) as the single source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: capture via standalone preview screens (real images)

CMUX_UITEST_MOCK_DATA alone lands on the add-device screen, so snapshots were
empty (0 images). Use the standalone preview hooks that render real UI with no
sign-in/pairing: WORKSPACE_LIST_PREVIEW + TERMINAL_PREVIEW (+ fake keyboard),
settling on window/foreground instead of identifiers the preview views do not
expose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: populated terminal, clean status bar, dismiss banner

Make the captured screenshots presentable for the App Store:
- TerminalLayoutPreviewView feeds a sample ANSI agent-session transcript when
  CMUX_UITEST_TERMINAL_PREVIEW_CONTENT=1, so the terminal shot shows real
  content instead of a blank surface (blank layout preview unchanged).
- SnapshotUITests enables that flag, drops the debug zoom overlay, and swipes
  away the one-time 'Ready for Apple Intelligence' notification banner.
- Fastfile capture uses override_status_bar for a clean 9:41 status bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: feed terminal sample content on didResize

updateUIView never re-ran with a non-zero size, so the sample transcript was
never fed and the terminal shot came out blank. Feed it from the surface's
first didResize (grid sized = can render). Also trigger the screenshots
workflow on preview-view changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* iOS: localize app into 12 more languages (machine translation)

Add zh-Hans, zh-Hant, ko, de, fr, es, pt-BR, it, ru, nl, tr, pl to all iOS
xcstrings (app, agent chat UI, InfoPlist permission strings) and the project
knownRegions, alongside the existing en + ja.

Translations are a machine-translation first pass (placeholders/format specifiers
preserved, brand/tech terms kept) and should get native review before public
release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: localized App Store listing metadata (14 locales)

Store the App Store listing copy (description, keywords, promotional text,
URLs; en-US also name/subtitle) for en-US + ja, zh-Hans, zh-Hant, ko, de-DE,
fr-FR, es-ES, pt-BR, it, ru, nl-NL, tr, pl. Applied to App Store Connect and
kept here so the listing is reproducible via fastlane deliver and the
machine-translated copy is reviewable before public release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: realistic framed shots (agents, keyboard, notifications, frameit)

Make App Store screenshots realistic and on-message:
- TerminalPreviewTranscripts: Claude Code / Codex / OpenCode / pi sample sessions,
  selected via CMUX_UITEST_TERMINAL_TRANSCRIPT.
- ScreenshotKeyboardView: drawn dark iOS keyboard overlaid in the reserved
  keyboard region (CMUX_UITEST_SCREENSHOT_KEYBOARD=1); the simulator won't render
  the system keyboard in CI. Device-aware height (iPhone vs iPad).
- ScreenshotNotificationBanner: iOS push banner over the workspace list
  (CMUX_UITEST_NOTIFICATION_BANNER=1) to show agent notifications.
- SnapshotUITests: 7 screens (workspaces, notifications, 4 agents w/ keyboard,
  full Ghostty terminal).
- frameit pipeline: tranquil gradient background, Framefile.json, localized
  title.strings (prepare_frames.py from titles.*.json), framed in the
   lane after capture. Workflow installs imagemagick; deliver uploads
  the framed images. Dynamic island comes from the frameit device frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: re-trigger screenshots workflow

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: fix MainActor setupSnapshot call; add localized frame titles

setupSnapshot is @mainactor; calling it from nonisolated setUpWithError failed
to compile, so the snapshot test never ran (0 screenshots, frameit found
nothing). Call it from the @mainactor test method. Also add titles.json
(localized screenshot captions, 13 locales) consumed by prepare_frames.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: real frames (frameit) + real recorded agent TUIs

Make everything real:
- Device frame: fastlane frameit photographic frames (font supplied via the
  Framefile fonts array; ImageMagick has no default font). iPhone 17 Pro Max
  frames natively; iPad captures are resized 2064x2752 -> 2048x2732 in
  prepare_frames.py so frameit's real 12.9 iPad Pro frame applies.
- Terminal content: replay REAL recorded sessions from the actual claude/codex/
  opencode/pi CLIs (tmux capture-pane), base64-embedded in
  TerminalPreviewTranscripts. record_sessions.sh + embed_sessions.py reproduce
  them; the screenshot CI replays the committed fixtures (no agent auth needed
  on the runner).
- Removed the drawn fake keyboard (ScreenshotKeyboardView) and the custom
  PIL compositor (compose_frames.py); terminal shots show the full real
  terminal, keyboard down.
- SnapshotUITests: 6 screens (workspaces, notifications, claude, codex,
  opencode, pi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: grid probe + font override + 39-locale titles

- Add CMUX_UITEST_TERMINAL_FONT_SIZE override and a 'probe' transcript that
  prints the live cols x rows + ruler, to measure the exact iOS terminal grid
  and re-record agent fixtures at matching width (fixes OpenCode/Pi wrap).
- titles.json localized to all 39 App Store locales; titles.en trimmed to the
  6 current screens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: auto-fit terminal width, real 76-col agent sessions, dark mode, real notif icon, landscape iPad

- Terminal auto-fits font to CMUX_UITEST_TERMINAL_TARGET_COLS=76 (via
  setLiveFontSize on first didResize), so one 76-col fixture fills the width
  edge-to-edge on both iPhone (portrait) and iPad (landscape). Measured grids:
  iPhone 93x88, iPad 205 cols @ font 8 -> fixed-width fixtures couldn't fill both.
- Re-recorded all 4 agents (claude/codex/opencode/pi) at 76 cols with richer
  prompts that fill the screen (fixes narrow + OpenCode/Pi breakage).
- Capture in dark mode (Fastfile dark_mode: true).
- Notification banner uses the real embedded cmux app icon + tighter iOS styling.
- iPad captured in landscape and composited bezel-less (latest real screen, no
  dated 2020 frame); iPhone keeps the real frameit frame. Removed grid probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: generate HTML gallery in CI (screenshots/preview/index.html)

frame lane now builds a self-contained preview gallery referencing the framed
PNGs in place; CI uploads it inside the screenshots artifact (automatable, not a
local /tmp one-off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: install Pillow in CI for the iPad compositor

compose_ipad.py needs PIL; the runner didn't have it (ModuleNotFoundError),
failing the frame step after the iPhone frames succeeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: iPad compositor via ImageMagick (fast, no Pillow)

Rewrite compose_ipad.py to use magick (the C lib frameit already requires)
instead of Pillow: faster on the large landscape iPad images and removes the
extra Python dependency from CI. Validated locally (rounded screen + soft
shadow + caption on the tranquil background).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: gallery at screenshots root with subdir+URL-encoded img refs

Browsers block file:// access to parent dirs (../) and don't auto-encode spaces
in filenames, so the preview images showed broken. Write index.html at the
screenshots root referencing <locale>/<file> (a subdir) with URL-encoded paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: premium composition (stitch real frame, large device, bold header)

Self-review: frameit shrank the tall device under the title leaving big dead
margins, and the header read weak. Replace with compose_shots.py: stitch the
screenshot into the real iPhone 17 Pro Max frame and place it LARGE (bleeding off
the bottom) under a bold 2-line header; iPad is a large bezel-less landscape
screen with a bold header. Removed frameit action + prepare_frames/compose_ipad/
Framefile. Uses the same frameit frame PNG (downloaded on demand) + ImageMagick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: SF Pro header + nature backgrounds

Per review: switch the header to Apple's SF Pro (SFNS, heavy weight; Unicode
fallback for CJK/RTL) and replace the gradient with tranquil nature photos
(mountains for iPhone portrait, lake+mountains for iPad landscape), darkened with
a top gradient so the device + header pop. Compositor picks bg by orientation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-screenshots: round capture corners before stitching (clean frame top)

The square simulator capture poked its corners past the frame's rounded screen
opening, leaving square artifacts at the top corners. Round the capture to the
screen corner radius before compositing under the device frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: opening-mask frame stitch + agent logos in headers

- compose_shots.py: mask each iPhone screenshot to the device frame's real
  screen opening (extracted from the frame alpha) so the screen follows the
  bezel's exact rounded corners (fixes the square-corner artifacts poking past
  the frame). Render headers in bold SF Pro with the agent logo (Claude/Codex/
  OpenCode/pi) smushed in before the localized title.
- logos/: agent mark PNGs used in the headers.
- Fastfile: accept the machine-store secret names (ASC_KEY_ID / ASC_ISSUER_ID /
  ASC_PRIVATE_KEY_PATH) in addition to the ASC_API_* names so upload_screenshots
  works from ~/.secrets without manual remapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: real terminal titlebar, smaller inline logos, brighter bg, pi title

- TerminalLayoutPreviewView: wrap the capture in a NavigationStack with the real
  terminal nav bar (back chevron + centered workspace name + chat/terminal icons,
  mirroring WorkspaceDetailView). The terminal no longer bleeds under the status
  bar / Dynamic Island; there is a proper cmux titlebar below the safe area, so
  the device-frame stitch reads cleanly at the top. SnapshotUITests passes a
  believable per-agent workspace name via CMUX_UITEST_TERMINAL_TITLE.
- compose_shots.py: shrink the header agent logo to ~cap height and tighten the
  gap so it sits inline before the title instead of as a large badge.
- titles: Pi screen now names pi (mirrors each locale's Claude title, Claude Code
  -> pi) instead of 'Ship from anywhere', with the pi logo inline.
- bg_portrait/bg_landscape: brighter nature photos (sunlit meadow + sky) with a
  top gradient kept for white-header legibility.
- propagate_locales.py + frame lane: capture shoots only en-US+ja (the localized
  app UI); fan those raws out to all ~39 App Store locales before framing so the
  whole pipeline is reproducible in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: unified Dynamic Island, per-screen bg variation, raw toggle

- compose_shots.py: paint a single rounded Dynamic Island over the frame's
  physical cutouts (the frame PNG draws a pill + a separate camera hole; iOS
  shows one unified black pill). Fixes the weird double-cutout header.
- Per-screen backgrounds: 6 bright, clean nature scenes (sky gradients + ocean,
  all Pexels License = free for commercial use, no attribution) under
  frame_assets/backgrounds/{p,l}; each screen (01..06) gets its own, so the
  listing has varied backdrops instead of one repeated photo. iPhone + iPad of
  the same screen share a theme.
- generate_preview.py: add a 'Show original (unframed) captures' toggle so the
  raw screenshots can be inspected next to the framed ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: terminal preview fills header with terminal color, real glass nav chrome

The screenshot preview only extended the terminal background (#272822 Monokai)
under the horizontal + bottom safe areas, so the status-bar / nav-bar region fell
back to black — which is NOT what the running app shows. WorkspaceDetailView
fills the whole window (including under the top) with the terminal color and uses
mobileTerminalNavigationChrome() (translucent Liquid Glass on iOS 26, material on
iOS 18). Mirror both here so the captured header matches the real device instead
of showing a black band.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: glass-pill nav title in terminal preview

Put the preview's nav title on a Liquid Glass pill (mobileGlassNavigationTitle),
matching WorkspaceDetailView.glassTitle, so it stays legible over terminal text
now that the bar background is cleared on iOS 26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: bigger device, smaller title, horizon-free backgrounds

- compose_iphone: enlarge the device (0.885 -> 0.95 width), raise it slightly,
  and shrink the header (120 -> 104pt, wider box) so titles like 'OpenCode, pi,
  any agent' fit on one line and the device is the prominent element.
- backgrounds 03/04/05: replace the ocean/beach photos (visible horizon band
  behind the device read as 'weird', esp. OpenCode) with clean horizon-free
  skies; keeps the bright per-screen variation without a busy seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: strip OpenCode's explicit bg so it renders uniformly

The OpenCode fixture painted its content on its own near-black (#0a0a0a) and let
bold headings reset to the terminal default bg, which on the mobile terminal is
Monokai #272822 — so heading/emphasis spans showed as olive boxes against the
dark content (the 'weird rendering'). The other agents never set an explicit bg,
so they render cleanly. Strip OpenCode's background SGR codes so it renders
uniformly on the terminal background like the others. Also add the transcripts
file to the screenshot workflow triggers so fixture changes re-capture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: OpenCode keeps its dark card, made uniform (no olive boxes)

Stripping OpenCode's background lost its distinctive near-black card. Instead,
pin the background to OpenCode's own #0a0a0a everywhere (re-assert it after every
reset / default-bg) so the card stays dark and uniform with no fallback to the
Monokai #272822 default (which caused the olive boxes). The status panel's
#1e1e1e is set explicitly and survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: render OpenCode on a matching #0a0a0a terminal background

Definitive fix for OpenCode's olive boxes. The boxes were cells that fell back to
the Monokai #272822 default (heading resets AND line-ends the agent didn't pad to
the 76-col display width); ANSI surgery on the fixture couldn't cover every case.
Instead set libghostty's default background to #0a0a0a for the OpenCode shot via
CMUX_UITEST_TERMINAL_BG (each screenshot is its own app launch, so it's scoped to
that one shot), and match the preview chrome fill to it. Now every cell — painted,
reset, or unpadded — is #0a0a0a, so OpenCode is a clean uniform dark card with no
boxes, and the original (unedited) capture fixture is restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: re-record OpenCode with a full-screen response

The previous OpenCode capture was sparse (a short answer + a large empty 'Build'
panel), so it left a big empty area at the bottom while the other agents fill the
screen. Re-recorded with a richer prompt captured after completion, so OpenCode
now shows a dense full-screen response (entry point + readability + #Preview)
matching the fill of claude/codex/pi. Renders on the #0a0a0a terminal background
added previously, so no boxes and no surgery needed.

* ios screenshots: real notification (not a drawn banner)

Replace the hand-drawn ScreenshotNotificationBanner with a REAL local
notification: in the workspace-list preview, request notification authorization
and schedule a genuine UNNotificationRequest, so the system renders the actual
banner (real blur/fonts, the app's real icon, and the 'cmux' display name —
lowercase). The snapshot UITest taps the springboard 'Allow' prompt and captures
the real NotificationShortLookView instead of swiping it away. Deletes the fake
banner view + its embedded icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios screenshots: auto-derive terminal bg from transcript (no hardcoded color)

Replace the hardcoded per-agent #0a0a0a override with auto-derivation: scan each
transcript for its dominant explicit background color and render the terminal on
it (OpenCode -> its near-black card; claude/codex/pi -> terminal default). The
preview sets CMUX_UITEST_TERMINAL_BG from the derived value before the surface is
created, so libghostty's default background matches and no reset/unpadded cell
falls back to Monokai. Removes the magic constant from the snapshot test.

* ios screenshots: capture the real notification banner at fixed timing

The foreground notification banner renders correctly (verified: real icon, 'cmux',
over the workspace list) but is a transient system overlay (~5s) that isn't
reliably queryable, so the UITest was snapshotting after it dismissed. Fire the
notification ~0.6s after the auth grant and snapshot at a fixed 2.5s (inside the
banner's visible window) instead of waiting on an element.

* ios screenshots: real Mac-streamed capture orchestration (WIP)

Adds the orchestration for capturing the live Mac-streamed agent terminals on a
paired simulator (capture-streamed.py) + the recorded agent sessions as raw .ans
files for deterministic Mac-side content. Proven end to end (live OpenCode
terminal streamed to the sim, framed, approved). Known blockers being worked:
the device's workspace list only resyncs grouped workspaces after a pairing
reconnect, and the device mirrors a workspace's own streamed surface (so the
workspace must be created with its --command), plus local machine contention
drops the tagged Mac app/pairing intermittently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: Blacksmith streamed-screenshot validation workflow (WIP, manual)

Validates the real Mac-streamed screenshot pipeline headless on Blacksmith: build
+ run the desktop Mac app, pair an iOS sim, capture the live streamed terminal.
De-risks running the desktop app + paired sim in CI (cmux streams terminal
content rendered on-device, so the Mac's GUI rendering should not matter). Gated
on dogfood account secrets (CMUX_DOGFOOD_STACK_EMAIL/PASSWORD) for sim sign-in —
not yet configured in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: trigger streamed-validate via push-to-self on the feature branch

workflow_dispatch needs the workflow on the default branch (unavailable here), so
fire the validation by pushing edits to the workflow file on the feature branch.

* ci: streamed-validate stands up dev web backend (Postgres + Next.js) on Blacksmith

Run the dev web app locally on the runner (local Postgres via db-local.sh + bun
dev, dev Stack project) so the DEBUG sim signs into localhost:3000 + the dev
project where the dedicated CI account lives — no prod, no email verification.
Dev secret bundle provided via CMUXTERM_DEV_ENV_B64 (dogfood creds swapped to the
dedicated CI account). Then build the Mac + iOS apps, pair the sim, capture one
streamed terminal.

* ci: streamed-validate uses native Postgres + next dev (no Docker)

Blacksmith macOS has no Docker, so run native Postgres (cmux:cmux@localhost:13000)
+ drizzle migrate, and run 'next dev --port 3000' directly instead of bun dev /
dev-local.sh (which call the Docker db-local.sh). Detach the server with setsid so
it survives into the pairing step.

* ci: detach web server with nohup (macOS has no setsid)

Native Postgres + drizzle migrate work; the server step failed only because
'setsid' doesn't exist on macOS. Use nohup + disown, and dump webdev.log on
failure for faster diagnosis.

* ci: run streamed-validate on GitHub-hosted macos-26 (Blacksmith queue dead)

The Blacksmith run sat queued 24h with no runner and was auto-cancelled, so use a
GitHub-hosted macOS runner (separate pool) to actually execute the validation.
macos-26 has Xcode 26 / iOS 26 sims. (Burns paid macOS minutes.)

* ios-streamed-validate: run on Blacksmith macos-26, not paid GitHub-hosted

Use the same Blacksmith iOS lane as test-ios.yml (vars.MACOS_RUNNER_IOS ||
blacksmith-6vcpu-macos-26). The desktop Mac app link failure (undefined libc
symbols) was a GitHub-hosted macos-26 toolchain quirk; Blacksmith macos-26 is
the proven fleet that builds cmux releases, and the prebuilt GhosttyKit is
SHA-pinned for the current ghostty submodule so no from-source build runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: pin Blacksmith macos-26 + Xcode 26 (not Depot/16.4)

The vars.MACOS_RUNNER_IOS override routes the iOS lane to a Depot macOS image
whose default Xcode is 16.4; cmux's iOS targets need Xcode 26's Swift toolchain
(Swift 6 actor-isolation + interpolation errors otherwise) and the iPhone 17 sim
only exists on iOS 26. Pin blacksmith-6vcpu-macos-26 directly and select the
newest Xcode 26 explicitly instead of the default symlink.

The desktop Mac app already builds clean on this lane; this unblocks the iOS sim
build + the in-CI pairing/capture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: mirror the Blacksmith fleet build recipe (fix Xcode 26 link)

The Mac build failed under Xcode 26 with undefined libc symbols
(_abort/_free/_malloc_size/...): the AArch64 GlobalISel codegen path miscompiles
under -O/wholemodule. Adopt reload-build.yml's exact fleet recipe:

- Select Xcode via scripts/select-ci-xcode.sh (ranks by macOS SDK, picks 26.x,
  aligns xcode-select) instead of the default Xcode.app symlink.
- Provision GhosttyKit via download-prebuilt-ghosttykit.sh (SHA-pinned), the
  path ci.yml/reload-build use.
- Build the Mac app with --swift-frontend-workaround (disables GlobalISel).
- Add CMUX_SKIP_ZIG_BUILD=1 + SWIFT_BACKTRACE env.

The iOS sim build forces -O/wholemodule too, so it needs the same workaround.
ios/scripts/reload.sh had no escape hatch, so add --swift-frontend-workaround
(also via CMUX_SWIFT_FRONTEND_WORKAROUND=1), mirroring scripts/reload.sh, and
apply it to both the simulator and device xcodebuild invocations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: make the streamed capture actually run (idb, sign-in, pairing)

The capture step passed only because of '|| true'; it produced no screenshot.
Three real fixes:

- idb: install fb-idb into a pinned-Python venv (/tmp/idbvenv) and put it on
  GITHUB_PATH. The runner's default python3 (3.14) is too new for fb-idb, so the
  'idb' CLI was missing (FileNotFoundError) and all device navigation failed.
- sign-in: inject the DEDICATED CI screenshot account (secrets
  CMUX_DOGFOOD_STACK_EMAIL/PASSWORD, @cmux.com, not a personal account) as env;
  dev-secrets reads CMUX_DOGFOOD_STACK_* from the environment first, so
  mobile-dev-launch signs the device in instead of erroring 'no credentials'.
- pairing order: launch the tagged Mac app and wait for its debug socket BEFORE
  mobile-dev-launch --ensure-mac, which mints the pairing ticket from the running
  Mac app; otherwise the device is signed-in-only with no workspaces to stream.

Also drop '|| true' on capture and assert a *.png exists, so the step fails
loudly if no real streamed shot is produced; use --detach so the sim launch
returns instead of blocking on --console-pty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: capture device-state diagnostics on capture failure

Sign-in + pairing + Mac workspace creation now all succeed, but the device shows
'workspace not visible' (likely the new workspace was created after the device
paired and the dev workspace list didn't resync). Before guessing the fix across
CI iterations, capture hard evidence: a device screenshot + accessibility tree
right after pairing and again post-capture, plus the Mac workspace list, into
_diag/ artifacts. Keep the top-level *.png assert (diagnostics live in _diag/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: install idb_companion (facebook/fb tap) + /usr/local/bin symlink

The device pairs and shows the workspace list fine; the capture failed only
because idb ui describe-all threw FileNotFoundError: '/usr/local/bin/idb_companion'.
Two causes: (1) 'brew install idb-companion' => 'No available formula' (it lives
in the facebook/fb tap), so the companion was never installed; (2) the python idb
client spawns it from the hardcoded /usr/local/bin path while arm64 brew installs
to /opt/homebrew/bin. Install from the tap and symlink to /usr/local/bin, and
assert idb_companion in the capture prereqs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: real claude agent content in the streamed shot

The streamed terminals run real agents on the runner, but the agents weren't
installed (zsh: command not found). Install claude/codex/opencode/pi and symlink
them into /usr/local/bin (cmux workspace shells launch from the GUI Mac app and
lack ~/.bun/bin + ~/.local/bin on PATH).

Propagate agent auth into the GUI session with launchctl setenv BEFORE
(open uses launchd env, not the step env), so workspace terminals inherit
CLAUDE_CODE_OAUTH_TOKEN (claude, native) and DEEPSEEK_API_KEY (the others, via
DeepSeek's OpenAI-compatible API).

Validate the real-agent path with claude first (strongest auth, no provider
config): launch with --permission-mode plan (clean read-only UI) and pre-seed
~/.claude.json so fresh-$HOME onboarding + folder-trust don't block. pi switched
to --provider deepseek. Capture --agents claude; opencode/codex provider config
to follow once claude proves the path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: trust claude sandbox by realpath (/private/tmp)

The claude shot proved auth propagation works (no login screen) but stalled on
the folder-trust prompt: the pre-seed keyed /tmp/cmux-stream-claude, while macOS
resolves /tmp -> /private/tmp and claude keys its trust map by the realpath it
sees (/private/tmp/cmux-stream-claude, visible in the screenshot). Seed both
spellings so the trust prompt is pre-accepted and claude answers the prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: test the DeepSeek-backed agent path (pi)

claude renders a real session now (trust cleared) but the CLAUDE_CODE_OAUTH_TOKEN
account is over its monthly spend limit, so it shows the rate-limit screen. Verify
the DeepSeek-backed path independently by capturing pi (--provider deepseek, reads
DEEPSEEK_API_KEY propagated via launchctl) before fanning out to opencode/codex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: replay recorded agent sessions (claude) instead of live

Live agents in CI hit auth/billing limits (the CLAUDE_CODE_OAUTH_TOKEN account is
over its monthly spend limit) and are nondeterministic. Instead, record a real
session locally with a funded account, commit the transcript, seed it into the
agent's on-disk session store at the matching project cwd
(/private/tmp/cmux-stream-claude), and resume it read-only in CI.

claude: recorded via 'claude -p' locally (genuine answer w/ #Preview code block,
no account/email/token fields in the jsonl), committed as
ios/fastlane/streamed-sessions/claude/<sessionId>.jsonl, copied into
~/.claude/projects/-private-tmp-cmux-stream-claude/ in CI, launched with
'claude --continue'. Resuming renders the transcript without an API call, so the
over-budget token still works. opencode/codex/pi transcripts to follow once this
proves out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: resume claude by session id, seed both cwd encodings

claude --continue returned 'No conversation found' even though the jsonl seeded
correctly: --continue relies on the ~/.claude.json history index (not seeded),
and the CI cwd may encode as -tmp- (logical) vs -private-tmp- (resolved symlink).
Switch to 'claude --resume <session-id>' (opens the jsonl by id) and seed the
transcript into both project-dir encodings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: real recorded sessions for all 4 agents (claude/codex/opencode/pi)

claude --resume <id> proved the record->resume approach renders a real session in
the streamed capture. Extend to all four:

- codex: seed rollout jsonl into ~/.codex/sessions/<date>/, resume by id.
- pi: seed cwd-keyed jsonl (both /tmp spellings), resume via --session <uuid>.
- opencode: sessions live in a sqlite DB, so import the exported JSON, resume
  via --session <id>.

All transcripts recorded locally with funded accounts (genuine answers with
#Preview code blocks), scanned clean of secrets. Capture all four in one run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: reliable back-navigation between agent captures

Only claude captured (1/4): the nav-bar back chevron has no accessibility label,
so navigate_back (which searched for '<'/'Back'/'chevron') failed and the device
stayed on claude's terminal, so codex/opencode/pi workspace rows were never
found. Tap the back chevron by its known nav-bar position with an iOS edge-swipe
pop fallback, and verify we returned to the list (>=2 'Terminal' rows) before
moving to the next agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios-streamed-validate: give codex a DeepSeek provider so resume renders (4/4)

3/4 agents rendered real resumed sessions (claude, opencode, pi). codex showed
the 'Sign in with ChatGPT' onboarding because it has no CI login, which blocks
codex resume. Write ~/.codex/config.toml with a DeepSeek (OpenAI-compatible)
provider keyed on DEEPSEEK_API_KEY so codex is authenticated and resumes the
seeded rollout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* capture-streamed: screencapture the Mac per agent (theme diagnostic)

To debug the white bands on the phone (likely the Mac streaming a light/default
theme), grab the CI Mac's cmux window right after each workspace is created
(foreground), into _diag/mac-<agent>.png, so we can view Mac vs phone side by
side for the same agent.

* streamed shots: force dark theme on CI Mac + codex press-enter nudge

White bands on the phone: the fresh CI runner boots in Light Mode, so cmux
resolves a LIGHT terminal theme and streams a white background; unpainted cells
render white on the phone. Force cmux to dark (appearanceMode=dark for the
cistream bundle + system AppleInterfaceStyle=Dark) before launch — temporary CI
screenshot setup.

codex: send one Enter after 'codex resume' to advance past the 'Press enter to
continue' welcome to the resumed session.

* streamed shots: codex uses fake OpenAI auth (real codex, no DeepSeek)

Per feedback: fake auth values are enough to pass codex's login gate, and resume
makes no API call, so seed a dummy ~/.codex/auth.json instead of a DeepSeek
provider. Codex authenticates as itself and renders the real recorded gpt-5
session with no DeepSeek label.

* streamed shots: drop DeepSeek, fake per-provider auth so each agent shows its real model

All three (codex/opencode/pi) fell back to DeepSeek because DEEPSEEK_API_KEY was
the only provider in the GUI env. Remove it entirely and seed a fake credential
for each agent's OWN recorded provider so it restores its real model with no
DeepSeek label:
- codex: fake ~/.codex/auth.json (openai apikey) -> gpt-5
- pi: fake ~/.pi/agent/auth.json (openai-codex oauth) -> gpt-5.5
- opencode: fake ~/.local/share/opencode/auth.json (zai) -> glm-5.2
Resume makes no API call, so the fake keys are never validated.

* capture-streamed: set terminal font once up front (fix OpenCode bottom gap)

OpenCode still showed a bottom letterbox even on latest main (with #7150/#7175/
#7172): the capture set the font PER-AGENT right before each screenshot, and
OpenCode doesn't repaint the newly-added bottom rows after that late resize (its
pure-black bg exposes the terminal-default gray in the unpainted rows; claude/
codex/pi hide it because their bg matches the default). Set the font ONCE before
opening any terminal so each surface opens at its final grid with no
resize-while-shown, and give it a longer settle to fully paint.

* capture-streamed: per-agent set_font + long settle (consistent size AND fills)

Setting the font once up front left each agent at whatever size its surface
opened with, so OpenCode rendered larger (scaled-up narrow grid) than the others.
Restore the per-agent focused set_font (all four at the same size) but keep the
long 6s settle so OpenCode fully repaints the resized grid and fills the height
(a short settle was the original cause of its bottom gap, not the resize itself).

* capture-streamed: log each agent's Mac PTY grid (diagnose OpenCode scaling)

OpenCode still renders ~20% larger than the others at the same set_font, which
points to its terminal grid being narrower (scaled up to fill width) rather than
a font-value difference. Log the read-screen cols x rows per agent so we can see
the actual grids and target the real cause.

* capture-streamed: smaller font for OpenCode so it matches the others' size

OpenCode's TUI negotiates a narrower grid that the phone scales up, so its glyphs
look ~1.3x larger than claude/codex/pi at the same font. Add a per-agent font
override and set OpenCode to 11 (vs 15) to compensate; grid diagnostic stays so
we can dial it in.

* streamed shots: opencode layout=stretch (full width), revert font hack

Root cause of OpenCode looking larger/narrower: its TUI defaults to layout=auto,
which caps content to a narrow readable column, so on the phone it's narrower
than claude/codex/pi (scaled up or big margins). The per-agent font hack made it
worse (clipped + margins). Revert the font override and seed
~/.config/opencode/opencode.json with layout=stretch so OpenCode uses the full
terminal width and matches the others. Grid diagnostic stays to verify.

* iOS streamed capture: claude theme dark-ansi so it renders on cmux Monokai bg

The 'dark' theme paints claude's own #1e1e1e background, overriding cmux's
Monokai (#272822) terminal default; codex/pi never paint a full-screen bg so
they already show Monokai. dark-ansi uses the terminal's ANSI palette +
background, so claude matches.

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

* iOS: add dispatch-only App Store upload workflow + --marketing-version

CI plumbing for public com.cmux.app builds, mirroring the beta lane's signing/
ASC setup but with the prod 'cmux Distribution' profile and no schedule (App
Store builds are cut deliberately, one per submission). Gated to main so only
reviewed code ships. Needs the new IOS_PROD_PROVISIONING_PROFILE_BASE64 secret
(set from ASC profile VF3CDPFLX9, app id 7WLXT3NR37.com.cmux.app, aps=production).

upload-testflight.sh gains --marketing-version <X.Y[.Z]> to stamp an explicit
version train (mutually exclusive with the beta --auto-version bump) so an
appstore build lands in the exact ASC store-version train (1.0) and is
attachable, instead of auto-bumping to 1.0.4.

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

* screenshots: drop pi from OpenCode header (pi has its own shot)

05-Opencode said 'OpenCode, pi, any agent' but pi is the very next screenshot
(06-Pi). Reworded to 'OpenCode and any agent' (and each locale's equivalent) so
the two shots don't overlap.

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

* Fix App Store lane alias guard

* Address screenshot preview policy findings

* Localize screenshot notification fixture

* Limit app-declared iOS locales

* Guard screenshot workflow secrets

* Address App Store screenshot PR feedback

* Fix streamed simulator lookup

* Handle localized notification permission in screenshots

* Fix screenshot preview task modifier

* Address remaining screenshot review feedback

* Fix terminal preview keyboard height parse

* Restrict streamed validation secrets to main

* Preserve raw App Store screenshot captures

* Parallelize App Store screenshot framing

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 98101ba5 Deployed Jul 2, 2026 by vercel[bot]
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.

iOS: typed input echoes at the wrong position in the mirrored terminal (typing/cursor render drift); terminal intermittently blanks then self-refreshes

1 participant