Skip to content

Combine iOS workspace close, terminal themes, and text capture fixes - #6743

Closed
lawrencecchen wants to merge 35 commits into
mainfrom
feat-ios-combined-6663-6664-6736
Closed

lawrencecchen wants to merge 35 commits into
mainfrom
feat-ios-combined-6663-6664-6736

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Combines and fixes:

Review fixes included:

  • Keep optimistic close source snapshots separate from rendered workspace filtering so rollback can restore stale-but-authoritative rows.
  • Require macOS workspace.close to explicitly return closed: true and report last-workspace close blocks without pretending the workspace is pinned.
  • Validate theme colors as exactly six ASCII hex digits and emit canonical #rrggbb Ghostty directives.
  • Bound the View as Text capture wait with a prompt timeout/error path that does not wait for a hung capture waiter.
  • Split helper types and docs to satisfy integrated policy review.

Checks run:

  • $autoreview --mode branch --base origin/main clean
  • swift test in Packages/iOS/CmuxMobileShell
  • swift test in Packages/Shared/CMUXMobileCore
  • swift test in Packages/iOS/CmuxMobileTerminalKit
  • swift test in Packages/macOS/CmuxControlSocket
  • git diff --check

Local note: swift test in Packages/iOS/CmuxMobileShellUI and Packages/iOS/CmuxMobileTerminal is blocked locally because GhosttyKit.xcframework is absent in this worktree; tagged app reload will compile those targets via the normal builder.


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


Note

Medium Risk
Touches mobile sync/reconciliation for workspace close and theme propagation over render-grid frames; regressions could show wrong chrome colors, flicker on theme change, or briefly wrong workspace lists, but changes are well-tested and scoped to iOS/mobile core.

Overview
Bundles three mobile-facing improvements: Mac-driven terminal theming, instant workspace close, and a more reliable View as Text flow.

Terminal themes add a shared TerminalTheme model (validated hex, Monokai default, Ghostty config emission). Full mobile render-grid frames now carry terminal_theme; VT replay applies default/selection colors and the 16-color palette. On iOS, incoming grids update per-surface theme cache and activeTerminalTheme; SwiftUI chrome moves from fixed Monokai to a scene-scoped TerminalPalette, and Ghostty config loading is split into GhosttyRuntimeConfigLoader so libghostty and UIKit accessory/composer chrome follow the active theme.

Workspace close on iOS removes the row immediately, tracks pending closes so stale workspace.list snapshots cannot resurrect them, reconciles when the Mac confirms removal, and rolls back row/selection on RPC failure or closed: false. Auth failures are classified via MobileShellAuthorizationFailureClassifier for disconnect handling.

View as Text arms capture at menu tap (before the sheet presents), tightens surface eligibility (id match, live surface, not dismantled), improves SCREEN→VIEWPORT text resolution, and adds a ~3s bounded wait with a user-visible timeout in the sheet.

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


Summary by cubic

Combines three iOS upgrades: instant workspace close, Mac‑driven terminal theming with palette‑tinted UI (SwiftUI chrome and the input bar), and a reliable “View as Text” sheet with a 3s timeout. Removes remaining Monokai fallbacks so iOS chrome fully matches the active theme.

  • New Features

    • Optimistic workspace close (iOS): remove the row immediately, track pending closes across lists/groups, reconcile with Mac, restore row/selection on failure, and classify auth failures to disconnect cleanly.
    • Terminal themes end‑to‑end: introduce TerminalTheme in CMUXMobileCore (strict hex validation); full frames carry theme + 16‑color palette; VT replay applies defaults/selection/cursor and palette; Mac attaches live theme and pushes a full frame on theme changes; iOS caches themes per surface, updates the selected terminal and scene‑scoped palette, and tints nav chrome and the input bar via a new config loader; per‑surface theming prevents cross‑scene tint leaks; removed hardcoded Monokai chrome fallbacks.
    • “View as Text”: start capture at menu tap, pick by id + live surface (exclude dismantled/invalid), fall back from empty SCREEN to VIEWPORT, and resolve with a 3s timeout and localized message.
  • Bug Fixes

    • macOS workspace.close: success returns closed: true; closing the last workspace reports blocked with reason and pinned; iOS handles both and preserves selection on rollback.
    • Theme: initialize before runtime callbacks; emit canonical #rrggbb for @GhosttyKit; preserve iOS scrollback on theme refresh; fix chrome backgrounds by driving SwiftUI/UIKit from the active palette and eliminating Monokai fallbacks.
    • Scrolling (macOS): apply the precise‑delta 2× boost only during gesture/momentum phases to avoid over‑scrolling with high‑resolution mice.

Written for commit 4312252. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a shareable, validated terminal theme model that drives SwiftUI palettes and updates terminal snapshot colors/palettes.
    • Improved “View as Text” with immediate capture and a hard timeout, including a user-facing message.
  • Bug Fixes
    • Made workspace closing feel more immediate with optimistic removal, selection-safe rollbacks, and stricter close confirmation.
    • Prevented stale/closed workspaces from briefly reappearing by reconciling optimistic closures against authoritative updates.
    • Added clearer handling and messaging for blocked and last-workspace close attempts.

cmux-lawrence and others added 20 commits June 24, 2026 03:25
Remove the workspace row from the local list immediately on close, send the
close RPC, and reconcile against the authoritative list. While the close is
unconfirmed the id is filtered out of every applied snapshot so a stale fetch
cannot resurrect the row and a real confirmation cannot double-remove it. A
failed close (offline or rejected, e.g. the last workspace) restores the row
before the re-sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The iOS terminal hardcoded the Monokai palette in two places in
GhosttyRuntime (the per-process config and the on-disk default config),
plus the SwiftUI letterbox chrome and the input accessory bar. Introduce
a shared TerminalTheme value type in CMUXMobileCore (bg, fg, cursor,
cursor-text, selection, and the 16 ANSI palette colors as hex strings,
Codable/Equatable/Sendable) with a built-in Monokai default and a
ghostty config directive generator. TerminalThemeStore holds the active
theme process-wide; GhosttyRuntime builds its config from it and exposes
setTheme(_:), and the terminal chrome reads the same store so it blends
with any theme. Invalid or incomplete themes fall back to Monokai.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two causes made the sheet show "No terminal text available" with visible
output:

1. The registry pick for the capture is visibility-scoped (window/hidden/
   alpha). It ran from the sheet's own .task, after presentation, when the
   presenter's window/alpha can transiently drop, so the one live surface
   could be filtered out and the read returned nil. Resolve and FIFO-enqueue
   the read now happen in openTextSheetFromMenu, at tap time, while the
   surface is still fully on screen; the sheet just awaits that capture.

2. surfaceText returns a non-nil empty string for a zero-byte range, so
   screen ?? viewport never fell back when SCREEN read empty-but-ok. The new
   resolvedText treats a nil OR empty SCREEN as "try VIEWPORT".

Extract both decisions into CopyableTerminalTextSelection (pure, no UIKit/
GhosttyKit) and unit-test the id-scoped pick, the no-surface/off-screen
exclusions, and the empty-SCREEN-falls-back-to-viewport rule. Add a concise
DEBUG-only anchormux diagnostic recording the requested id, every candidate's
state, and the SCREEN/VIEWPORT read outcomes so the path is self-explaining in
the dogfood log.

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

vercel Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

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

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

@coderabbitai

coderabbitai Bot commented Jun 24, 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

This PR adds a portable TerminalTheme, threads it through runtime, UI, and render-grid paths, changes workspace close handling to support blocked outcomes and optimistic iOS rollback, and rewrites “View as Text” capture with selection and timeout handling.

Changes

Terminal theming system

Layer / File(s) Summary
Theme model and parser
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TerminalTheme.swift, Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TerminalThemeHexParser.swift, Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift
TerminalTheme defines the portable color schema with validation, fallback, Ghostty directive output, and a built-in Monokai theme. TerminalThemeHexParser normalizes and parses hex strings. Tests cover decoding, validation, fallback behavior, RGB parsing, and directive formatting.
Runtime theme loading and propagation
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntimeConfigLoader.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/MobileTerminalThemeNotification.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, Sources/GhosttyTerminalView.swift
GhosttyRuntime stores and exposes the active theme, GhosttyRuntimeConfigLoader loads iOS config defaults from that theme, and theme notifications plus surface-level theme properties carry that state through the terminal runtime.
Palette-driven UI wiring
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPalette.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
TerminalPalette switches from fixed Monokai constants to a live TerminalTheme-backed instance, and the iOS shell and terminal views update their backgrounds, foregrounds, and accessory bar colors to use the palette instance.
Render-grid theme snapshotting
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift, Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift, Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift, Sources/Mobile/MobileTerminalRenderObserver.swift, Sources/TerminalController+MobileScrollPrefetch.swift, Sources/GhosttyTerminalView.swift, Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift, Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
Render-grid frames carry terminalTheme, replay emits theme-derived OSC bytes, the observer tracks theme signatures and refreshes on theme changes, and mobile delivery caches per-surface themes and updates the active theme on selection. Mac-side snapshot code now attaches the current theme to frames.

Workspace close handling

Layer / File(s) Summary
Blocked close resolution and guard
Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCloseResolution.swift, Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift, Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift, Sources/TerminalController+ControlWorkspaceContext.swift, Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift, Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift, Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceTests.swift
ControlWorkspaceCloseResolution adds blocked(windowID:pinned:reason:), ControlWorkspaceStrings adds closeBlocked, the coordinator returns blocked payloads and includes closed: true on success, and TerminalController returns .blocked when only one tab remains. The related tests and fakes are updated.
Optimistic close RPC flow
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift, Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift
MobileShellComposite adds pending-close snapshot bookkeeping, closeWorkspace(id:) applies an immediate local removal before the RPC, and helper methods restore or confirm local state based on the response payload.
Pending-close reconciliation and tests
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift, Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/OptimisticWorkspaceCloseTests.swift
Derived workspace state filters pending optimistic closes, workspace groups are pruned when their anchor disappears, and remote list handling reconciles pending closes only when the incoming list is authoritative for that Mac. The new tests cover rollback, reconciliation, group pruning, and Mac-scoped behavior.

View as Text capture flow

Layer / File(s) Summary
Candidate selection helpers and tests
Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextCandidate.swift, Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextSelection.swift, Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/CopyableTerminalTextSelectionTests.swift
CopyableTerminalTextCandidate stores surface eligibility inputs, and CopyableTerminalTextSelection provides eligibility, ordered candidate selection, and SCREEN/VIEWPORT text resolution. The tests cover eligibility filtering, deterministic selection, and fallback resolution.
Surface registry capture rewrite
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift
copyableTerminalTextCapture(surfaceID:) replaces the old async capture path with a task-returning API, builds candidate snapshots from registered views, selects the active surface through CopyableTerminalTextSelection, reads SCREEN and VIEWPORT on the output queue, and resolves the final text from those reads.
Tap-time capture and timeout UI
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextSheetView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextCaptureRaceState.swift, ios/cmux/Resources/Localizable.xcstrings
WorkspaceDetailView captures terminal text at menu tap time and passes the task into TerminalTextSheetView. The sheet races capture completion against a timeout using TerminalTextCaptureRaceState, shows an error state on timeout, and uses a new localized timeout string.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant WorkspaceDetailView
  participant GhosttySurfaceRegistry
  participant CopyableTerminalTextSelection
  participant TerminalTextSheetView
  participant TerminalTextCaptureRaceState

  User->>WorkspaceDetailView: tap "View as Text"
  WorkspaceDetailView->>GhosttySurfaceRegistry: copyableTerminalTextCapture(surfaceID:)
  GhosttySurfaceRegistry->>CopyableTerminalTextSelection: chosenIndex(from: candidates, for: surfaceID)
  CopyableTerminalTextSelection-->>GhosttySurfaceRegistry: chosen index
  GhosttySurfaceRegistry->>GhosttySurfaceRegistry: read SCREEN + VIEWPORT on outputQueue
  GhosttySurfaceRegistry->>CopyableTerminalTextSelection: resolvedText(screen:viewport:)
  GhosttySurfaceRegistry-->>WorkspaceDetailView: Task<String?, Never>
  WorkspaceDetailView->>WorkspaceDetailView: store textSheetCapture, present sheet
  TerminalTextSheetView->>TerminalTextCaptureRaceState: install continuation, set waiter + timer
  alt capture finishes before timeout
    TerminalTextCaptureRaceState-->>TerminalTextSheetView: finish(timedOut: false, text: result)
    TerminalTextSheetView->>User: show terminal text snapshot
  else timeout fires first
    TerminalTextCaptureRaceState-->>TerminalTextSheetView: finish(timedOut: true, text: nil)
    TerminalTextSheetView->>User: show timeout errorMessage
  end
Loading
sequenceDiagram
  participant iOSMobileShellComposite
  participant GhosttyRuntime
  participant TerminalPalette
  participant MacRPC

  iOSMobileShellComposite->>GhosttyRuntime: setTheme(_:)
  GhosttyRuntime->>GhosttyRuntime: init(theme: configuredTheme)
  GhosttyRuntime-->>TerminalPalette: currentTheme
  TerminalPalette->>TerminalPalette: derive colors from theme
  iOSMobileShellComposite->>MacRPC: workspace.close
  alt closed: true
    MacRPC-->>iOSMobileShellComposite: confirmed
    iOSMobileShellComposite->>iOSMobileShellComposite: retire pending close snapshot
  else blocked or rejected
    MacRPC-->>iOSMobileShellComposite: error / unconfirmed
    iOSMobileShellComposite->>iOSMobileShellComposite: rollbackOptimisticWorkspaceClose(id:)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • manaflow-ai/cmux#5079: The new TerminalTheme/terminalTheme plumbing into render-grid serialization and replay overlaps with this render-state restoration path.
  • manaflow-ai/cmux#5875: Both PRs change the iOS “View as Text” flow and the capture path used by TerminalTextSheetView.
  • manaflow-ai/cmux#6142: Both PRs touch iOS workspace close handling in MobileShellComposite.closeWorkspace(id:).

Suggested reviewers

  • austinywang

Poem

🐰 Hexes glow and workspaces sigh,
Themes hop through every view nearby.
One tab blocks, one task times out,
Yet bunny code still leaps about.
A palette hums, a capture sleeps,
Then safely wakes with rabbit peeps.


Important

Pre-merge checks failed

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

❌ Failed checks (5 errors, 2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error TerminalTextSheetView adds a 3s production timeout using ContinuousClock().sleep plus detached timer task; that timing-based sync is forbidden. Replace the sleep-based timeout with a real capture-completion signal or a cancellable timer/async sequence abstraction; keep it out of shipped runtime code.
Cmux Algorithmic Complexity ❌ Error MobileShellComposite.swift:3413-3422 adds derived.removeAll with values.contains over pending closes per workspace, making recompute O(workspaces×pendingCloses). Precompute a Set/dictionary of pending close keys and do O(1) membership checks during recompute, or move filtering into a single-pass indexed plan.
Cmux Swift Logging ❌ Error Adds mobileShellLog.error(...) in an @MainActor Swift file, but the logger is file-scoped private let instead of nonisolated private let. Make the logger nonisolated private let (or move the log into a nonisolated helper) so the MainActor file uses an actor-safe Logger.
Cmux Full Internationalization ❌ Error GhosttyRuntime adds two user-facing LocalizedError strings, but terminal.runtime.init_failed and terminal.runtime.app_creation_failed are missing from Resources/Localizable.xcstrings. Add both terminal.runtime.* entries to Resources/Localizable.xcstrings with translations for every locale already supported by that catalog.
Cmux Architecture Rethink ❌ Error TerminalTextSheetView adds a 3s sleep-based timer actor to race capture completion, creating a new timing side-channel to mask a hung capture waiter. Move the bound/cancel logic into the capture owner (GhosttySurfaceRegistry) or return one bounded cancellable async primitive; the sheet should only await/cancel it.
Docstring Coverage ⚠️ Warning Docstring coverage is 24.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The PR description is detailed, but it does not follow the required template and is missing the Testing, Demo Video, Review Trigger, and Checklist sections. Add the missing template sections, including explicit testing/manual verification, demo video or a note if none, the review trigger block, and the checklist items.
Cmux Swift @Concurrent ❓ Inconclusive The accessible diff only shows a submodule pointer update; the summarized Swift changes aren’t present here, so @concurrent compliance can’t be verified. Provide the actual Swift file diff or exact commit range containing those changes so the async/actor annotations can be checked.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main bundled change set.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New value models are plain structs, and the new UI/runtime state is explicitly MainActor-confined or hops to MainActor; no new shared mutable Sendable leakage found.
Cmux Browser Automation Off-Main ✅ Passed PASS: The patch doesn’t touch the rule-scoped browser automation routing files, and the diff has no browser.* wait/callback routing changes or policy-test gaps.
Cmux Expensive Synchronous Load ✅ Passed No PR-touched Swift path adds agent-history loads on main/interactive paths; the only history lookup found is already off-main via SharedLiveAgentIndex/Task.detached.
Cmux Cache Substitution Correctness ✅ Passed The new snapshot/cache paths have cold fallbacks and event-driven refreshes; optimistic close and theme caches are reconciled against authoritative updates.
Cmux No Hacky Sleeps ✅ Passed The commit only touches Swift/Zig/header files; there are no TS/JS/shell/build runtime diffs, and no sleep/timer/polling hack appears in the touched runtime file.
Cmux Swift Concurrency ✅ Passed The new Tasks/queues stay at allowed UI/OS/third-party boundaries, and the detached tasks are awaited or stored/cancelled rather than fire-and-forget.
Cmux Swift File And Package Boundaries ✅ Passed PASS: New logic was split into small package targets (TerminalTheme, CopyableTerminalTextSelection); oversized files only received small focused glue additions, not large mixed expansions.
Cmux Swiftpm Lockfiles ✅ Passed No Package.resolved, Package.swift, or .gitignore lockfile changes; the Xcode project diff has no SwiftPM package-reference edits.
Cmux User-Facing Error Privacy ✅ Passed New user-facing copy is generic; no banned vendor/provider names, secrets, raw upstream messages, or payload dumps appear in the changed error paths.
Cmux Swiftui State Layout ✅ Passed PASS: The SwiftUI additions use @Observable + @State/bridge views, with no new ObservableObject/@published, no GeometryReader layout hack, and no render-time state writes in changed views.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: The actual diff only changes ghostty C/Zig submodule files; no Swift NSWindow/WindowGroup code or cmuxAuxiliaryWindowIdentifiers changes were introduced.
Cmux Source Artifacts ✅ Passed All 40 changed paths are intentional source/tests/docs/localization files; none fall under artifact, cache, build, temp, or dependency-checkout directories.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No added production Swift lines matched test/debug seam patterns; the diff is feature code, with existing ForTesting/debug helpers untouched.
Cmux No Ambient Global State ✅ Passed No new top-level globals or static-only namespaces; new helpers are instance-based, and the C trampoline is a valid bridge-only file-scope function.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-combined-6663-6664-6736

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 Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR bundles three independent iOS features: optimistic workspace close (instant row removal with rollback on RPC failure or closed: false), Mac-driven terminal theming (a new TerminalTheme value type shared across the wire, VT replay, SwiftUI chrome, and UIKit accessory bar), and a more reliable "View as Text" sheet (capture armed at tap time, surface selection relaxed to exclude only dismantled/invalid views, 3-second timeout with localized error). macOS workspace.close now returns closed: true on success and uses a new blocked resolution for the last-workspace guard.

  • Optimistic close is well-modeled with three companion dictionaries and a crisp rollback path; the reconcileOptimisticClosures helper correctly retires entries the Mac confirms are gone and keeps the filter active for still-pending ids.
  • Terminal theming wires TerminalTheme end-to-end: hex validation and Ghostty directive emission live in the shared module, scene-local TerminalPalette prevents cross-scene taint, and MobileTerminalRenderObserver triggers a full scrollback-included refresh when the Mac's appearance config changes.
  • The TerminalTextCaptureRaceState actor correctly handles all interleavings (early completion before continuation install, cancellation before waiter/timer are registered) thanks to the completedOutcome guard pattern.

Confidence Score: 5/5

Safe to merge; the three features each have dedicated unit tests and the core state machines (optimistic close rollback, capture race, theme signature delta) are straightforward to reason about.

All three feature areas are well-contained with explicit rollback paths and error UI. The workspace close reconciliation correctly filters pending ids from derived lists and restores selection on failure. The capture timeout actor handles every interleaving correctly. The only findings are a duplicated luminance formula between two modules that could silently diverge if adjusted, and a public top-level notification user-info key constant that belongs on a namespaced type — neither affects runtime correctness.

No files require special attention. GhosttyRuntime.swift carries a pre-existing static-var isolation debt noted in a prior review cycle, unchanged by this PR.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift Implements optimistic workspace close: removes the row immediately, records the snapshot, rolls back on transport failure or explicit closed: false, and routes auth errors to the classifier. Logic is clean with explicit guard/rollback paths.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceListReconciliation.swift New file factoring out the reconciliation of optimistically-closed workspace IDs against authoritative Mac snapshots; well-isolated and covered by unit tests.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextCaptureRaceState.swift Actor-based race state correctly handles all interleavings: early completion before continuation install, early cancel via onCancel, and cleanup of both waiter and timer tasks on finish.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextSheetView.swift Adds 3-second capture deadline via awaitCapture; prefers the tap-time capture task over a late registry re-resolve; shows a localized error on timeout. The inner fire-and-forget Task {} for actor state setup is safe because TerminalTextCaptureRaceState.install handles the late-continuation case.
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView+ThemeChrome.swift Adds per-theme UIKit accessory bar coloring; duplicates the WCAG luminance formula already present in TerminalPalette.isLightBackground — both use the 0.55 threshold and would diverge independently if adjusted.
Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextSelection.swift Extracts the View as Text eligibility and fallback logic into a pure struct testable without UIKit; correctly relaxes the visibility predicate to a tie-breaker since the capture is now armed before sheet presentation.
Sources/Mobile/MobileTerminalRenderObserver.swift Adds theme-change observation via ghosttyDefaultBackgroundDidChange, caches the current theme/signature, and triggers a full-frame refresh (with scrollback) on theme change; delta suppression is correctly bypassed when the theme signature changes.
Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift Adds closed: true to success responses and routes the new .blocked resolution to protected error with pinned/reason metadata; existing .protected (pin guard) is preserved as before.
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/MobileTerminalThemeNotification.swift Declares the theme-change notification name and user-info key; the key is a public let at file scope rather than a static let on a namespaced type (flagged).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User (iOS)
    participant WDV as WorkspaceDetailView
    participant MSC as MobileShellComposite
    participant RPC as MobileCoreRPCClient
    participant Mac as macOS ControlSocket

    Note over U,Mac: Optimistic Workspace Close
    U->>WDV: Tap Close Workspace
    WDV->>MSC: closeWorkspace(id:)
    MSC->>MSC: applyOptimisticWorkspaceClose(id:)
    Note right of MSC: Row removed, snapshot saved, filtered from derived lists
    MSC->>RPC: sendRequest(workspace.close)
    RPC->>Mac: RPC over socket

    alt "Success closed=true"
        Mac-->>RPC: ok closed true
        RPC-->>MSC: response data
        MSC->>MSC: workspaceCloseResponseConfirmsClosed true
        Note right of MSC: Optimistic close confirmed
    else Blocked last workspace
        Mac-->>RPC: err code protected reason pinned
        RPC-->>MSC: throws rpcError
        MSC->>MSC: rollbackOptimisticWorkspaceClose(id:)
        Note right of MSC: Row restored, selection restored
    else Legacy Mac no closed field
        Mac-->>RPC: ok window_id
        RPC-->>MSC: response data
        MSC->>MSC: workspaceCloseResponseConfirmsClosed true default
    end
    MSC->>MSC: refreshAfterWorkspaceMutation

    Note over U,Mac: Terminal Theme Propagation
    Mac->>Mac: ghosttyDefaultBackgroundDidChange
    Mac->>Mac: MobileTerminalRenderObserver.refreshCachedTerminalTheme
    Mac->>Mac: enqueueTerminalThemeRefresh full frame with scrollback
    Mac-->>MSC: MobileTerminalRenderGridFrame terminalTheme set
    MSC->>MSC: deliverTerminalRenderGrid cache theme per surfaceID
    MSC->>WDV: activeTerminalTheme changed
    WDV->>WDV: applyTerminalTheme
    WDV->>WDV: terminalPalette.setTheme SwiftUI chrome
    WDV->>WDV: GhosttyRuntime.setTheme libghostty config UIKit chrome
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"}}}%%
sequenceDiagram
    participant U as User (iOS)
    participant WDV as WorkspaceDetailView
    participant MSC as MobileShellComposite
    participant RPC as MobileCoreRPCClient
    participant Mac as macOS ControlSocket

    Note over U,Mac: Optimistic Workspace Close
    U->>WDV: Tap Close Workspace
    WDV->>MSC: closeWorkspace(id:)
    MSC->>MSC: applyOptimisticWorkspaceClose(id:)
    Note right of MSC: Row removed, snapshot saved, filtered from derived lists
    MSC->>RPC: sendRequest(workspace.close)
    RPC->>Mac: RPC over socket

    alt "Success closed=true"
        Mac-->>RPC: ok closed true
        RPC-->>MSC: response data
        MSC->>MSC: workspaceCloseResponseConfirmsClosed true
        Note right of MSC: Optimistic close confirmed
    else Blocked last workspace
        Mac-->>RPC: err code protected reason pinned
        RPC-->>MSC: throws rpcError
        MSC->>MSC: rollbackOptimisticWorkspaceClose(id:)
        Note right of MSC: Row restored, selection restored
    else Legacy Mac no closed field
        Mac-->>RPC: ok window_id
        RPC-->>MSC: response data
        MSC->>MSC: workspaceCloseResponseConfirmsClosed true default
    end
    MSC->>MSC: refreshAfterWorkspaceMutation

    Note over U,Mac: Terminal Theme Propagation
    Mac->>Mac: ghosttyDefaultBackgroundDidChange
    Mac->>Mac: MobileTerminalRenderObserver.refreshCachedTerminalTheme
    Mac->>Mac: enqueueTerminalThemeRefresh full frame with scrollback
    Mac-->>MSC: MobileTerminalRenderGridFrame terminalTheme set
    MSC->>MSC: deliverTerminalRenderGrid cache theme per surfaceID
    MSC->>WDV: activeTerminalTheme changed
    WDV->>WDV: applyTerminalTheme
    WDV->>WDV: terminalPalette.setTheme SwiftUI chrome
    WDV->>WDV: GhosttyRuntime.setTheme libghostty config UIKit chrome
Loading

Reviews (11): Last reviewed commit: "Remove remaining iOS Monokai chrome fall..." | Re-trigger Greptile

/// live terminal under any theme instead of flashing a hardcoded color. They
/// fall back to Monokai when no theme has been supplied.
@MainActor
let terminalPalette = TerminalPalette()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Module-level ambient global replacing the old static-namespace type

terminalPalette is a file-scope let with internal visibility, making it ambient API across every view in CmuxMobileShellUI. The old TerminalPalette was a static-only struct (equally ambient), so this is a shape change rather than a net worsening, and because GhosttyRuntime.setTheme is gated to pre-init the value never changes after startup — so there is no staleness risk in practice. The idiomatic SwiftUI shape here would be an EnvironmentKey/EnvironmentValues extension injected at the app seam so views receive the palette through the environment rather than reaching for it as a global name.

Rule Used: Flag new ambient global state in production Swift:... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@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: 5

Caution

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

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

282-339: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Snapshot terminalPalette.foreground once per render.

Each read goes back through TerminalTheme.rgbComponents in TerminalPalette, and this view now hits the same foreground color several times in the composer/typing path. Capture a local let foreground = terminalPalette.foreground (and derived opacities if helpful) so one body pass does not redo the same parse work.

Also applies to: 388-388, 793-820

🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`
around lines 282 - 339, The composer view is repeatedly reading
terminalPalette.foreground during a single render, which re-runs the same color
parsing work in TerminalPalette.rgbComponents. In TerminalComposerView, snapshot
the foreground color once per body pass with a local value (and reuse derived
opacity variants if needed) so the toolbar, field, and send button all share the
cached result instead of recomputing it on every access.
🤖 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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 3407-3416: The filtering in MobileShellComposite.swift is doing a
nested scan: derived.removeAll currently checks every workspace against all
values in optimisticallyClosedWorkspaces via pendingOptimisticClose, which makes
recomputation scale poorly. Refactor this block to precompute an O(1)-lookup
structure keyed by the relevant Mac/remote IDs from
optimisticallyClosedWorkspaces, then use that membership check inside
derived.removeAll instead of iterating the pending snapshots for each workspace.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextSheetView.swift`:
- Line 19: Remove the fixed timeout-based capture fallback in
TerminalTextSheetView and drive the sheet state from the capture pipeline’s real
completion or failure signal instead. Update the capture path around
captureTimeout and the related logic in TerminalTextSheetView so it waits for
authoritative success/failure instead of racing a 3-second deadline, and keep
the UI update flow aligned with the capture task outcome rather than timing out.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`:
- Around line 453-454: The sheet dismissal flow in WorkspaceDetailView currently
clears textSheetCapture without stopping the underlying capture task, which can
leave the stalled capture running and allow duplicates on reopen. Update the
.sheet onDismiss handling and the surrounding textSheetCapture lifecycle so the
active capture is explicitly cancelled/stopped before the reference is nilled
out, using the existing textSheetCapture and isTextSheetPresented state in
WorkspaceDetailView.

In
`@Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntimeConfigLoader.swift`:
- Line 31: Make XDG_CONFIG_HOME authoritative in GhosttyRuntimeConfigLoader so
the loader and ensureDefaultiOSConfig use the same config root. Right now
setenv("XDG_CONFIG_HOME", appSupport.path, 0) can preserve an inherited value,
which may cause ghostty_config_load_default_files to read a different location
than where defaults were written. Update the configuration setup to always use
the resolved Application Support path for both operations, or change the setenv
call to overwrite the existing value, and keep the logic centered around
GhosttyRuntimeConfigLoader and ensureDefaultiOSConfig.

In
`@Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift`:
- Around line 69-77: The TerminalTheme.rgbComponents parser coverage is missing
an explicit rejection for 8-digit hex values with alpha, so add tests in
rgbComponentsParseHex that verify both `#RRGGBBAA` and RRGGBBAA return nil. Keep
the existing success cases for `#RRGGBB/RRGGBB` and extend the invalid-input
assertions alongside the current short, non-hex, and nil checks to lock in the
exactly-six-hex-digits contract.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`:
- Around line 282-339: The composer view is repeatedly reading
terminalPalette.foreground during a single render, which re-runs the same color
parsing work in TerminalPalette.rgbComponents. In TerminalComposerView, snapshot
the foreground color once per body pass with a local value (and reuse derived
opacity variants if needed) so the toolbar, field, and send button all share the
cached result instead of recomputing it on every access.
🪄 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: 30be074d-978f-4c67-b716-adfd3a828b40

📥 Commits

Reviewing files that changed from the base of the PR and between e4b590a and a42215f.

📒 Files selected for processing (27)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TerminalTheme.swift
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TerminalThemeHexParser.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/OptimisticWorkspaceCloseTests.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPalette.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextCaptureRaceState.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextSheetView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntimeConfigLoader.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextCandidate.swift
  • Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextSelection.swift
  • Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/CopyableTerminalTextSelectionTests.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCloseResolution.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceTests.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift
  • Sources/TerminalController+ControlWorkspaceContext.swift
  • ios/cmux/Resources/Localizable.xcstrings

/// capped to `TerminalTextSnapshot.defaultLineBudget` lines, with a banner
/// when older lines were dropped.
struct TerminalTextSheetView: View {
private static let captureTimeout: Duration = .seconds(3)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Remove the fixed timeout from the capture path.

This hard-codes a 3-second race against the capture task, so slow-but-correct reads will now surface a false error instead of eventually showing text. Repo policy treats new timing-based repair paths in shipped Swift as failures, especially when they paper over lifecycle/rendering races like this one. Please drive the sheet from an authoritative completion/failure signal from the capture pipeline instead of a sleep-based deadline. As per coding guidelines, "Flag Thread.sleep, usleep, sleep, Task.sleep, DispatchQueue.asyncAfter, timers, or polling loops in shipped app/runtime Swift code as failures by default" and "Report a failure when a diff introduces or materially expands timing or blocking repair paths ... used to paper over lifecycle, focus, rendering ... races in Swift."

Also applies to: 169-185

🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTextSheetView.swift`
at line 19, Remove the fixed timeout-based capture fallback in
TerminalTextSheetView and drive the sheet state from the capture pipeline’s real
completion or failure signal instead. Update the capture path around
captureTimeout and the related logic in TerminalTextSheetView so it waits for
authoritative success/failure instead of racing a 3-second deadline, and keep
the UI update flow aligned with the capture task outcome rather than timing out.

Source: Coding guidelines

Comment on lines +453 to +454
.sheet(isPresented: $isTextSheetPresented, onDismiss: { textSheetCapture = nil }) {
TerminalTextSheetView(surfaceID: textSheetSurfaceID, capture: textSheetCapture)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cancel textSheetCapture before clearing it.

Dropping the reference does not stop the started Task. In the stalled-capture path this PR is handling, dismissing the sheet can leave the old capture running indefinitely, and the next open starts another one.

Suggested fix
-        .sheet(isPresented: $isTextSheetPresented, onDismiss: { textSheetCapture = nil }) {
+        .sheet(isPresented: $isTextSheetPresented, onDismiss: {
+            textSheetCapture?.cancel()
+            textSheetCapture = nil
+        }) {
             TerminalTextSheetView(surfaceID: textSheetSurfaceID, capture: textSheetCapture)
         }
📝 Committable suggestion

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

Suggested change
.sheet(isPresented: $isTextSheetPresented, onDismiss: { textSheetCapture = nil }) {
TerminalTextSheetView(surfaceID: textSheetSurfaceID, capture: textSheetCapture)
.sheet(isPresented: $isTextSheetPresented, onDismiss: {
textSheetCapture?.cancel()
textSheetCapture = nil
}) {
TerminalTextSheetView(surfaceID: textSheetSurfaceID, capture: textSheetCapture)
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`
around lines 453 - 454, The sheet dismissal flow in WorkspaceDetailView
currently clears textSheetCapture without stopping the underlying capture task,
which can leave the stalled capture running and allow duplicates on reopen.
Update the .sheet onDismiss handling and the surrounding textSheetCapture
lifecycle so the active capture is explicitly cancelled/stopped before the
reference is nilled out, using the existing textSheetCapture and
isTextSheetPresented state in WorkspaceDetailView.

private func setupiOSConfigEnvironment() {
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
setenv("XDG_CONFIG_HOME", appSupport.path, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make XDG_CONFIG_HOME authoritative here.

setenv("XDG_CONFIG_HOME", appSupport.path, 0) preserves any inherited value, but ensureDefaultiOSConfig always writes under Application Support. That gives ghostty_config_load_default_files and the default-config writer different roots, so this loader can silently read the wrong config and miss the theme/defaults it just prepared. Use one resolved path for both operations, or set overwrite to 1. As per path instructions, "Ensure there is a single authoritative source for each correctness fact" and avoid non-authoritative fallback branches.

🤖 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/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntimeConfigLoader.swift`
at line 31, Make XDG_CONFIG_HOME authoritative in GhosttyRuntimeConfigLoader so
the loader and ensureDefaultiOSConfig use the same config root. Right now
setenv("XDG_CONFIG_HOME", appSupport.path, 0) can preserve an inherited value,
which may cause ghostty_config_load_default_files to read a different location
than where defaults were written. Update the configuration setup to always use
the resolved Application Support path for both operations, or change the setenv
call to overwrite the existing value, and keep the logic centered around
GhosttyRuntimeConfigLoader and ensureDefaultiOSConfig.

Source: Path instructions

Comment on lines +69 to +77
@Test func rgbComponentsParseHex() {
#expect(TerminalTheme.rgbComponents("#ff8000")! == (255, 128, 0))
#expect(TerminalTheme.rgbComponents("ff8000")! == (255, 128, 0))
#expect(TerminalTheme.rgbComponents("#fff") == nil)
#expect(TerminalTheme.rgbComponents("zzzzzz") == nil)
#expect(TerminalTheme.rgbComponents("#+fffff") == nil)
#expect(TerminalTheme.rgbComponents("-00001") == nil)
#expect(TerminalTheme.rgbComponents(nil) == nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the no-alpha regression to the parser coverage.

The new validation contract is “exactly six hex digits”, but this test only locks in short and non-hex failures. Please add an explicit #RRGGBBAA / RRGGBBAA rejection so a future parser change cannot silently re-allow alpha. Based on learnings, this repo only accepts and uses 6-digit hex colors in the form #RRGGBB with no alpha.

Proposed test addition
     `@Test` func rgbComponentsParseHex() {
         `#expect`(TerminalTheme.rgbComponents("`#ff8000`")! == (255, 128, 0))
         `#expect`(TerminalTheme.rgbComponents("ff8000")! == (255, 128, 0))
+        `#expect`(TerminalTheme.rgbComponents("`#11223344`") == nil)
+        `#expect`(TerminalTheme.rgbComponents("11223344") == nil)
         `#expect`(TerminalTheme.rgbComponents("`#fff`") == nil)
         `#expect`(TerminalTheme.rgbComponents("zzzzzz") == nil)
         `#expect`(TerminalTheme.rgbComponents("#+fffff") == nil)
📝 Committable suggestion

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

Suggested change
@Test func rgbComponentsParseHex() {
#expect(TerminalTheme.rgbComponents("#ff8000")! == (255, 128, 0))
#expect(TerminalTheme.rgbComponents("ff8000")! == (255, 128, 0))
#expect(TerminalTheme.rgbComponents("#fff") == nil)
#expect(TerminalTheme.rgbComponents("zzzzzz") == nil)
#expect(TerminalTheme.rgbComponents("#+fffff") == nil)
#expect(TerminalTheme.rgbComponents("-00001") == nil)
#expect(TerminalTheme.rgbComponents(nil) == nil)
}
`@Test` func rgbComponentsParseHex() {
`#expect`(TerminalTheme.rgbComponents("`#ff8000`")! == (255, 128, 0))
`#expect`(TerminalTheme.rgbComponents("ff8000")! == (255, 128, 0))
`#expect`(TerminalTheme.rgbComponents("`#11223344`") == nil)
`#expect`(TerminalTheme.rgbComponents("11223344") == nil)
`#expect`(TerminalTheme.rgbComponents("`#fff`") == nil)
`#expect`(TerminalTheme.rgbComponents("zzzzzz") == nil)
`#expect`(TerminalTheme.rgbComponents("#+fffff") == nil)
`#expect`(TerminalTheme.rgbComponents("-00001") == nil)
`#expect`(TerminalTheme.rgbComponents(nil) == nil)
}
🤖 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/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift`
around lines 69 - 77, The TerminalTheme.rgbComponents parser coverage is missing
an explicit rejection for 8-digit hex values with alpha, so add tests in
rgbComponentsParseHex that verify both `#RRGGBBAA` and RRGGBBAA return nil. Keep
the existing success cases for `#RRGGBB/RRGGBB` and extend the invalid-input
assertions alongside the current short, non-hex, and nil checks to lock in the
exactly-six-hex-digits contract.

Source: Learnings

@lawrencecchen
lawrencecchen force-pushed the feat-ios-combined-6663-6664-6736 branch from efab424 to 452344c Compare June 25, 2026 00:12

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+WorkspaceActions.swift:
- Around line 179-181: The `workspaceCloseResponseConfirmsClosed(_:)` check is
too permissive because it defaults to true when `closed` is missing, invalid, or
legacy-shaped. Update this method to fail closed by returning true only when the
parsed JSON in `object["closed"]` is explicitly a Bool true, and return false
for anything else so `WorkspaceActions` does not treat uncertain responses as
confirmed closes.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`:
- Line 40: The scene-specific terminal palette is still being synchronized with
the process-global runtime, causing theme changes in one scene to overwrite
other scenes. Update WorkspaceDetailView’s terminal theme flow so
`terminalPalette` stays fully local and `applyTerminalTheme` no longer pushes
every change into `GhosttyRuntime.setTheme`; if runtime-backed theming is still
needed, scope it per surface instead of using view lifecycle callbacks. Also
ensure `TerminalPalette` initialization does not seed scene state from
`GhosttyRuntime.currentTheme`, so each scene starts from its own local palette
state consistently.

In `@Sources/Mobile/MobileTerminalRenderObserver.swift`:
- Around line 28-32: The theme cache in MobileTerminalRenderObserver is
incorrectly shared across all surfaces, while render-grid emission is
surface-specific. Refactor the cachedTerminalTheme and
cachedTerminalThemeSignature usage so theme/palette resolution is scoped per
surfaceID inside the render state path that emits the grid, likely in the emit
logic around renderGridStatesBySurfaceID rather than as global observer state.
Update the code paths that build the emitted state to read from the target
surface’s terminal config each time, and ensure any refresh detection compares
against the per-surface cached signature instead of a single observer-wide
cache.

In `@Sources/TerminalController`+MobileScrollPrefetch.swift:
- Around line 24-29: The mobile scroll prefetch/render-grid snapshot is still
pulling theme data from app-wide defaults instead of the target surface’s
configuration, which can flatten surface-specific overrides. Update the snapshot
path in TerminalController+MobileScrollPrefetch, including the logic that builds
the frame and the helper that constructs the theme snapshot, so it reads the
terminalPanel’s surface/config-scoped theme rather than GhosttyApp defaults.
Keep GhosttyApp.reloadSurfaceConfiguration(_:soft:source:) surface-scoped and
only use app-level config in app-level helpers, and make the same fix in the
related snapshot code around the MobileScrollPrefetch helper.
🪄 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: 28d60743-5bfa-43dd-9452-d20f6a8e3daa

📥 Commits

Reviewing files that changed from the base of the PR and between cb2838b and efab424.

📒 Files selected for processing (23)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/OptimisticWorkspaceCloseTests.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPalette.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/MobileTerminalThemeNotification.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextSelection.swift
  • Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/CopyableTerminalTextSelectionTests.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Mobile/MobileTerminalRenderObserver.swift
  • Sources/TerminalController+MobileScrollPrefetch.swift

Comment on lines +179 to +181
func workspaceCloseResponseConfirmsClosed(_ data: Data) -> Bool {
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
return object?["closed"] as? Bool ?? true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail closed when closed is missing or malformed.

Line 181 treats empty, invalid, or legacy-shaped data as confirmed. If the Mac did not actually close the workspace, the pending close remains filtered after refresh and the row can stay hidden. Require explicit closed: true; only rollback-blocking responses should be unmistakable.

Proposed fix
 func workspaceCloseResponseConfirmsClosed(_ data: Data) -> Bool {
     let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
-    return object?["closed"] as? Bool ?? true
+    return object?["closed"] as? Bool == true
 }

As per path instructions, correctness-critical state should fail closed when the reliable signal is missing.

📝 Committable suggestion

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

Suggested change
func workspaceCloseResponseConfirmsClosed(_ data: Data) -> Bool {
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
return object?["closed"] as? Bool ?? true
func workspaceCloseResponseConfirmsClosed(_ data: Data) -> Bool {
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
return object?["closed"] as? Bool == true
🤖 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`+WorkspaceActions.swift
around lines 179 - 181, The `workspaceCloseResponseConfirmsClosed(_:)` check is
too permissive because it defaults to true when `closed` is missing, invalid, or
legacy-shaped. Update this method to fail closed by returning true only when the
parsed JSON in `object["closed"]` is explicitly a Bool true, and return false
for anything else so `WorkspaceActions` does not treat uncertain responses as
confirmed closes.

Source: Path instructions

Comment on lines +28 to +32
private var hasPendingThemeRefresh = false
private var isEmitFlushScheduled = false
private var renderGridStatesBySurfaceID: [UUID: RenderGridState] = [:]
private var cachedTerminalTheme: TerminalTheme?
private var cachedTerminalThemeSignature: String?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Theme caching is global, but the emitted render-grid state is surface-scoped.

Lines 28-32 keep a single cached theme/signature for the whole observer, and Lines 230-277 reuse it for every emitted surface. If two surfaces can carry different terminal configs, a change on one surface will overwrite the cache used for the other surface and can send the wrong theme/palette to iOS while also forcing false full-frame refreshes.

As per path instructions, .github/review-bot-rules/reliability-single-source-of-truth.md requires correctness-critical state to come from one authoritative typed source, and based on learnings surface config reloads must remain scoped to the target surface rather than being promoted into shared app state.

Also applies to: 230-277

🤖 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 `@Sources/Mobile/MobileTerminalRenderObserver.swift` around lines 28 - 32, The
theme cache in MobileTerminalRenderObserver is incorrectly shared across all
surfaces, while render-grid emission is surface-specific. Refactor the
cachedTerminalTheme and cachedTerminalThemeSignature usage so theme/palette
resolution is scoped per surfaceID inside the render state path that emits the
grid, likely in the emit logic around renderGridStatesBySurfaceID rather than as
global observer state. Update the code paths that build the emitted state to
read from the target surface’s terminal config each time, and ensure any refresh
detection compares against the per-surface cached signature instead of a single
observer-wide cache.

Sources: Path instructions, Learnings

Comment on lines +24 to +29
var frame = terminalPanel.surface.mobileRenderGridFrame(
stateSeq: seq,
scrollbackLines: scrollbackLines
)?.frame
frame?.terminalTheme = TerminalTheme.currentMacTerminalThemeSnapshot()
return frame

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Surface snapshots are still sourced from app-global theme state.

Line 73 builds the snapshot from GhosttyApp.default*, and Lines 24-29 attach that same value to a specific terminalPanel frame. That means any surface-scoped Ghostty override gets flattened into the app default, so scroll-prefetch/render-grid payloads can advertise the wrong palette for terminals that do not share the global config. Please plumb the theme from the target surface/config snapshot instead of GhosttyApp’s app-wide defaults.

Based on learnings, GhosttyApp.reloadSurfaceConfiguration(_:soft:source:) must stay surface-scoped and app-level helpers should keep reading app-level config until a full reload path.

Also applies to: 71-84

🤖 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 `@Sources/TerminalController`+MobileScrollPrefetch.swift around lines 24 - 29,
The mobile scroll prefetch/render-grid snapshot is still pulling theme data from
app-wide defaults instead of the target surface’s configuration, which can
flatten surface-specific overrides. Update the snapshot path in
TerminalController+MobileScrollPrefetch, including the logic that builds the
frame and the helper that constructs the theme snapshot, so it reads the
terminalPanel’s surface/config-scoped theme rather than GhosttyApp defaults.
Keep GhosttyApp.reloadSurfaceConfiguration(_:soft:source:) surface-scoped and
only use app-level config in app-level helpers, and make the same fix in the
related snapshot code around the MobileScrollPrefetch helper.

Source: Learnings

@lawrencecchen
lawrencecchen force-pushed the feat-ios-combined-6663-6664-6736 branch 2 times, most recently from 8a6f944 to 0f17f22 Compare June 25, 2026 00:24
Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment on lines +48 to +79
private static var configuredTheme: TerminalTheme = .monokai
private static var clipboardReader: @MainActor () -> String? = { UIPasteboard.general.string }
private static var clipboardWriter: @MainActor (String?) -> Void = { UIPasteboard.general.string = $0 }

/// Replaces the theme used when the runtime builds its config and the theme
/// the surrounding terminal chrome blends with. Pass `nil`, an invalid theme,
/// or an incomplete palette to fall back to Monokai. Calls after
/// ``shared()`` update the live theme value for SwiftUI/UIKit chrome; the
/// terminal grid itself is repainted by the Mac render-grid frame's VT
/// snapshot bytes.
public static func setTheme(_ theme: TerminalTheme?) {
let resolvedTheme = theme?.validatedOrDefault() ?? .monokai
guard currentTheme != resolvedTheme else { return }
configuredTheme = resolvedTheme
if case .success(let runtime) = sharedResult {
runtime.theme = resolvedTheme
}
NotificationCenter.default.post(
name: .cmuxMobileTerminalThemeDidChange,
object: nil,
userInfo: [cmuxMobileTerminalThemeNotificationThemeKey: resolvedTheme]
)
}

/// The effective theme for the live runtime, or the theme that will be used
/// when the runtime is first initialized.
public static var currentTheme: TerminalTheme {
if case .success(let runtime) = sharedResult {
return runtime.theme
}
return configuredTheme
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 New static mutable var without actor isolation

configuredTheme is a new private static var on a non-@MainActor class, read and written by the unannotated setTheme and currentTheme static members. In Swift 6 strict concurrency mode all three accesses are data races. The existing backendInitialized/sharedResult statics carry the same debt, but configuredTheme is new. Since every real call site is @MainActor (WorkspaceDetailView.applyTerminalTheme, TerminalController+MobileScrollPrefetch), annotating setTheme and currentTheme with @MainActor (or marking configuredTheme as nonisolated(unsafe) with a comment matching the existing app/config precedent) would close the gap without changing observable behavior.

Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

@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 (2)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift (1)

414-416: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Pass palette values into AttachmentChip instead of reading the observable environment in the row.

AttachmentChip is below a ForEach, so @Environment(TerminalPalette.self) puts an observable store reference inside the row subtree. Snapshot the needed colors in the parent and pass them as values.

Proposed refactor
-                    AttachmentChip(thumbnail: thumbnailCache.image(for: attachment.id)) {
+                    AttachmentChip(
+                        thumbnail: thumbnailCache.image(for: attachment.id),
+                        foreground: terminalPalette.foreground
+                    ) {
                         store.removePendingAttachment(id: attachment.id, forTerminalID: terminalID)
                         thumbnailCache.remove(attachment.id)
                         requestHeightRemeasure()
                     }
 private struct AttachmentChip: View {
     let thumbnail: UIImage?
+    let foreground: Color
     let onRemove: () -> Void
-    `@Environment`(TerminalPalette.self) private var terminalPalette
 
     private let side: CGFloat = 56
-                        .strokeBorder(terminalPalette.foreground.opacity(0.15), lineWidth: 1)
+                        .strokeBorder(foreground.opacity(0.15), lineWidth: 1)
-                .fill(terminalPalette.foreground.opacity(0.12))
+                .fill(foreground.opacity(0.12))
                 .overlay(
                     Image(systemName: "photo")
-                        .foregroundStyle(terminalPalette.foreground.opacity(0.5))
+                        .foregroundStyle(foreground.opacity(0.5))
                 )

As per coding guidelines, “Do not use store references (@ObservedObject, @EnvironmentObject, @StateObject, @Bindable, or plain store properties) in LazyVStack, LazyHStack, List, or ForEach row subtrees; use immutable snapshots plus action closures instead.”

Also applies to: 781-824

🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`
around lines 414 - 416, Snapshot the palette values in the parent view and pass
them into AttachmentChip as plain values instead of letting the row subtree read
`@Environment`(TerminalPalette.self). Update the ForEach in TerminalComposerView
so each attachment row receives immutable color values, and refactor
AttachmentChip to accept those palette parameters while keeping the
removePendingAttachment action closure unchanged. Also apply the same
snapshot-and-pass approach anywhere else in the affected attachment/row subtree
range (including the later referenced block) to avoid store references inside
ForEach rows.

Source: Coding guidelines

Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift (1)

14-27: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use frame.surfaceID as the single authoritative key here.

This path now keys terminalThemesBySurfaceID, selected-theme updates, and the downstream delivery off the method parameter even though the frame already carries its own surfaceID. If those two values ever diverge, the queued render grid and activeTerminalTheme can be applied to different surfaces. Use frame.surfaceID throughout (or assert equality once, then reuse that value).

Suggested change
 func deliverTerminalRenderGrid(_ frame: MobileTerminalRenderGridFrame, surfaceID: String) {
-    if let theme = frame.terminalTheme?.validatedOrDefault() {
-        terminalThemesBySurfaceID[surfaceID] = theme
-        if selectedTerminalID?.rawValue == surfaceID, activeTerminalTheme != theme {
+    assert(frame.surfaceID == surfaceID)
+    let authoritativeSurfaceID = frame.surfaceID
+    if let theme = frame.terminalTheme?.validatedOrDefault() {
+        terminalThemesBySurfaceID[authoritativeSurfaceID] = theme
+        if selectedTerminalID?.rawValue == authoritativeSurfaceID, activeTerminalTheme != theme {
             activeTerminalTheme = theme
         }
     }
     deliverTerminalOutput(
         TerminalOutputDelivery(
             renderGrid: frame,
             replaceable: frame.isReplaceableViewportPatchForMobileDelivery
         ),
-        surfaceID: surfaceID
+        surfaceID: authoritativeSurfaceID
     )
 }

As per path instructions, .github/review-bot-rules/reliability-single-source-of-truth.md says correctness-critical identity must have a single authoritative source and forbids disagreeing sources of truth.

🤖 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`+TerminalOutputDelivery.swift
around lines 14 - 27, Use a single authoritative surface identifier in
deliverTerminalRenderGrid(_:surfaceID:) by switching all theme storage,
selected-terminal comparison, and deliverTerminalOutput delivery to
frame.surfaceID. If the method parameter is still needed, assert it matches
frame.surfaceID once and then reuse the frame value everywhere in
MobileShellComposite+TerminalOutputDelivery to avoid split state between
terminalThemesBySurfaceID, activeTerminalTheme, and the downstream render-grid
delivery.

Source: Path instructions

♻️ Duplicate comments (2)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift (2)

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

Keep the terminal theme source of truth scene-local.

Line 41 seeds terminalPalette from GhosttyRuntime.currentTheme, and Lines 434-438 still mirror each scene’s theme back into the process-global runtime. In a multi-window flow, the last scene to appear wins, so another scene can render its initial chrome/background with the wrong palette until it reapplies its own theme. Seed the palette from the workspace/store theme and stop using GhosttyRuntime as shared scene state. As per path instructions, “require a single authoritative source … and forbid … more than one disagreeing source of truth.”

Also applies to: 433-439

🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`
at line 41, Keep the terminal theme state scene-local and remove the shared
runtime source of truth. In WorkspaceDetailView, initialize and drive
terminalPalette from the workspace/store theme instead of
GhosttyRuntime.currentTheme, and update the scene-specific theme path so each
window uses its own palette consistently. In the code around WorkspaceDetailView
and the theme sync logic near the scene update methods, stop writing each
scene’s theme back into GhosttyRuntime and ensure any theme changes flow only
through the local view/store state.

Source: Path instructions


464-465: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cancel the in-flight text capture on dismiss.

Clearing the Task reference does not stop the capture. If the waiter stalls, dismissing the sheet leaves the old read running indefinitely and reopening can start a second capture against the same surface.

Suggested fix
-        .sheet(isPresented: $isTextSheetPresented, onDismiss: { textSheetCapture = nil }) {
+        .sheet(isPresented: $isTextSheetPresented, onDismiss: {
+            textSheetCapture?.cancel()
+            textSheetCapture = nil
+        }) {
             TerminalTextSheetView(surfaceID: textSheetSurfaceID, capture: textSheetCapture)
         }
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`
around lines 464 - 465, The sheet dismiss path in WorkspaceDetailView is only
clearing the Task reference, so the in-flight text capture keeps running. Update
the .sheet onDismiss handler to actively cancel the existing capture task before
niling state, using the textSheetCapture reference (and any capture-start/stop
helpers involved) so dismissing the sheet stops the waiter and prevents a second
capture from starting on reopen.
🤖 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`+TerminalOutputDelivery.swift:
- Around line 14-27: Use a single authoritative surface identifier in
deliverTerminalRenderGrid(_:surfaceID:) by switching all theme storage,
selected-terminal comparison, and deliverTerminalOutput delivery to
frame.surfaceID. If the method parameter is still needed, assert it matches
frame.surfaceID once and then reuse the frame value everywhere in
MobileShellComposite+TerminalOutputDelivery to avoid split state between
terminalThemesBySurfaceID, activeTerminalTheme, and the downstream render-grid
delivery.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`:
- Around line 414-416: Snapshot the palette values in the parent view and pass
them into AttachmentChip as plain values instead of letting the row subtree read
`@Environment`(TerminalPalette.self). Update the ForEach in TerminalComposerView
so each attachment row receives immutable color values, and refactor
AttachmentChip to accept those palette parameters while keeping the
removePendingAttachment action closure unchanged. Also apply the same
snapshot-and-pass approach anywhere else in the affected attachment/row subtree
range (including the later referenced block) to avoid store references inside
ForEach rows.

---

Duplicate comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift`:
- Line 41: Keep the terminal theme state scene-local and remove the shared
runtime source of truth. In WorkspaceDetailView, initialize and drive
terminalPalette from the workspace/store theme instead of
GhosttyRuntime.currentTheme, and update the scene-specific theme path so each
window uses its own palette consistently. In the code around WorkspaceDetailView
and the theme sync logic near the scene update methods, stop writing each
scene’s theme back into GhosttyRuntime and ensure any theme changes flow only
through the local view/store state.
- Around line 464-465: The sheet dismiss path in WorkspaceDetailView is only
clearing the Task reference, so the in-flight text capture keeps running. Update
the .sheet onDismiss handler to actively cancel the existing capture task before
niling state, using the textSheetCapture reference (and any capture-start/stop
helpers involved) so dismissing the sheet stops the waiter and prevents a second
capture from starting on reopen.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 258595ba-8b55-4184-8e91-85f6319be72c

📥 Commits

Reviewing files that changed from the base of the PR and between efab424 and 8a6f944.

📒 Files selected for processing (23)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/OptimisticWorkspaceCloseTests.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPalette.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/MobileTerminalThemeNotification.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CopyableTerminalTextSelection.swift
  • Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/CopyableTerminalTextSelectionTests.swift
  • Sources/GhosttyTerminalScrollBoost.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Mobile/MobileTerminalRenderObserver.swift
  • Sources/TerminalController+MobileScrollPrefetch.swift
  • cmuxTests/GhosttyScrollPreciseBoostTests.swift

@lawrencecchen
lawrencecchen force-pushed the feat-ios-combined-6663-6664-6736 branch from 0f17f22 to 4147c28 Compare June 25, 2026 00:36

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit fb23a7a. Configure here.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 43122521 Deployed Jun 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants