Skip to content

GhosttyTerminalView decomposition: terminal surface model (stack D)#6024

Merged
azooz2003-bit merged 63 commits into
mainfrom
feat-terminal-surface
Jun 14, 2026
Merged

GhosttyTerminalView decomposition: terminal surface model (stack D)#6024
azooz2003-bit merged 63 commits into
mainfrom
feat-terminal-surface

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Stack D of the modular refactor: decomposing Sources/GhosttyTerminalView.swift per GhosttyTerminalView.plan.md, stacked on the TerminalEngine slice (#5929). One large PR; tranches land as separate commits.

Tranche 1 (landed): TerminalSurface model into CmuxTerminal

GhosttyTerminalView.swift 15,221 -> 12,226 lines.

New Packages/CmuxTerminal (Wave 3 domain package, depends on CmuxTerminalCore + CmuxGhosttyKit): TerminalSurface + SearchState lifted faithfully (still ObservableObject/@Published; modernization is a separate phase), split by role under Surface/, Spawn/, Lifecycle/, Hosting/, Runtime/, Events/.

Seam inversions (all legacy reach-ups now injected through TerminalSurfaceRuntimeDependencies):

  • GhosttyApp.shared -> TerminalEngineHosting
  • view construction -> TerminalSurfaceViewProviding (app injects the GhosttyNSView/GhosttySurfaceScrollView factory)
  • settings/control-socket reads at spawn -> TerminalSurfaceSpawnPolicyProviding
  • MobileTerminalByteTee.shared + Unmanaged tee context -> TerminalByteTeeBinding with lease objects
  • RendererRealizationController.shared -> TerminalRendererRealizationScheduling
  • recordAgentHibernationTerminalInput free function + AgentHibernationController.shared -> AgentHibernationRecording
  • TerminalSurfaceRuntimeTeardownCoordinator.shared actor singleton -> injected instance; its two enqueue free functions folded into one member with defaulted freeSurface

App-side residue: Sources/TerminalSurfaceRuntimeWiring.swift holds the conformances/bridges plus a convenience init that keeps every legacy TerminalSurface(...) call site byte-identical. CmuxSurfaceConfigTemplate, the surface runtime probes, the Claude command shim, and the cmux context environment lifted to CmuxTerminalCore; WorkspaceSurfaceConfig.swift keeps forwarding shims. App-side tests naming the moved type get import CmuxTerminal hoisted above the canImport(cmux_DEV) block.

Behavior invariants: Defaults keys, environment keys (CMUX_*), and notification raw strings byte-identical (cmux.terminalSurfaceDidBecomeReady moved, same string). Adversarial normalize-and-diff of createSurface and the teardown path vs pre-tranche HEAD (8bbb21b) shows only the seam substitutions.

Two deliberate compiler-enforced isolation upgrades of legacy main-thread-only contracts: TerminalSurface.searchState is @MainActor (its didSet always ran on main), so startOrFocusTerminalSearch is now @MainActor (both callers already were); GhosttyNSView's copy-mode indicator accessors went fileprivate -> internal as protocol witnesses.

Gates: swift_file_length_budget (cutover budget write), lint-ios-package-conventions, pbxproj normalize/check + ID-collision sweep, swift build+swift test on CmuxTerminal/Core/Engine/Services (Core 66, Engine 14, Services 15, Terminal 2 incl. new teardown-coordinator behavior tests), full app xcodebuild build green.

e2e: FindSelectionShortcutUITests PASSED at ea8d799 (exercises full app launch + terminal + the moved searchState path: manaflow-ai/cmux-dev-artifacts#2939). AutomationSocketUITests failed with app-activation timeouts (manaflow-ai/cmux-dev-artifacts#2942), but that class is red on main too (manaflow-ai/cmux-dev-artifacts#2528, manaflow-ai/cmux-dev-artifacts#2479), pre-existing runner flake class, not introduced by this branch.

Leg 2 status: remaining slices are blocked on prerequisite domain packaging

Leg 1 (TerminalSurface MODEL → CmuxTerminal) succeeded because the model's dependencies had already been seam-inverted into CmuxTerminalCore/Engine (TerminalSurfaceRuntimeDependencies, TerminalSurfaceViewProviding, etc.). The model package never names a view type or an app singleton.

Leg 2 surveyed the rest of the god file (now 12,226 lines) to continue the lift. Every remaining slice is deeply entangled with app-domain types that have not yet been extracted into packages, so none can be lifted faithfully and latency-neutrally in isolation right now:

  • View layer (GhosttyNSView 3865–7923, GhosttySurfaceScrollView 8027–11245, the NSTextInputClient IME extension 11246–11751, the GhosttyTerminalView representable 11752–end; ~7,400 lines) references 51 distinct app-defined types across the Panels, Appearance, Find, Settings-shortcuts, Workspace, image-transfer, and portal domains, plus the GhosttyApp / AppDelegate / Workspace singletons. ~28 of those are reached only transiently inside method bodies (seamable to primitives), but ~23 appear in stored-property or function-signature positions and would each need either co-moving (they are shared with 1–8 other app files, so co-moving breaks their other callers) or a behavioral seam with an app-side conformer. Reach-up neutrality is favorable — every GhosttyApp.shared (11 members) and AppDelegate.shared (8 members) reference is on a COLD path (background/appearance/theme-sync/focus/config/debug), none in the per-keystroke or per-frame inner loop; the only hot-path app symbol is CmuxTypingTiming telemetry. So a faithful lift is possible but is a large multi-seam effort that must be dogfooded on the typing-critical paths between steps (repo CLAUDE.md flags TerminalSurface.forceRefresh(), WindowTerminalHostView.hitTest(), and the SurfaceSearchOverlay-must-mount-from-GhosttySurfaceScrollView portal-layering contract as fragile).

  • Engine wrapper (GhosttyApp 362–3855, a static let shared @MainActor singleton; ~3,500 lines) references 45 distinct app types (incl. GhosttyConfig, MobileTerminalByteTee, RendererRealizationController, WindowBackdropController, settings types) with 17 AppDelegate.shared + 12 Workspace.shared reach-ups. Its target package CmuxTerminalEngine currently depends only on CmuxTerminalCore.

  • Even the plan's "pure Sendable engine value DTOs" (ScrollbarVisibility, AppearanceSynchronizationPlan, RuntimeColorSchemeSynchronizationDecision, DefaultBackgroundValues, the font/appearance summaries) cannot be promoted into CmuxTerminalEngine in isolation: they are nested in GhosttyApp, carry app types (GhosttyConfig.ColorSchemePreference, GhosttyBackgroundBlur, NSColor), and are produced by GhosttyApp static/instance methods that call other GhosttyApp appearance-engine helpers. They only separate cleanly as part of the full GhosttyApp lift, which itself needs GhosttyConfig packaged first.

Prerequisite (the unblocker): package GhosttyConfig and the shared value/styling types the view+engine layers depend on (the Panels styling enums, WindowAppearanceSnapshot, image-transfer DTOs, the Settings shortcut/scrollbar value types) — work owned by the parallel AppDelegate/TabManager/ContentView stacks. Once those land, the seam surface for the view layer drops to behavioral-only and the engine value DTOs become liftable.

Corrected sequencing for the next leg (once GhosttyConfig + shared value types are packaged):

  1. GhosttyAppGhosttyAppService in CmuxTerminalEngine, lifting the engine value DTOs to top-level Sendable types; invert its AppDelegate/Workspace reach-ups via WorkspaceResolving/tab-routing seams. This removes the heaviest view-layer reach-up family (GhosttyApp.shared).
  2. View layer → CmuxTerminalSurface, with the GhosttyApp/AppDelegate/Workspace/portal reach-ups injected as the plan's TerminalSurfaceBackgroundProviding / TerminalPortalBinding / TerminalPortalGeometryReporting seams (all cold-path, so latency-neutral), the IME/scroll/overlay transient state staying in the moved view types. Preserve the SurfaceSearchOverlay portal-mount contract.
  3. Window chrome → CmuxWorkspaceWindow.
  4. Final god-file deletion + composition-root wiring.

Leg 2 leaves the worktree at the verified-green leg-1 head (file-length budget + package-conventions lint pass; CmuxTerminal package swift build green; full app xcodebuild build BUILD SUCCEEDED) rather than forcing a high-risk partial lift of the typing-latency-critical view in a state that cannot be dogfooded between steps.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added mobile terminal viewport limiting plus new mobile scroll/click and grid-frame export capabilities.
    • Introduced hex-based color helpers and a dedicated terminal light/dark color-scheme preference model.
    • Improved Ghostty config discovery/loading and added safe editing support for sidebar and surface tab bar font sizes (with validation and clamping).
  • Improvements

    • Refreshed terminal surface focus, renderer/portal lifecycle, and input/copy-mode handling for more reliable behavior.
    • Standardized window glass/transparent background decisions and compositor blur resets for more consistent visuals.

azooz2003-bit and others added 30 commits June 10, 2026 12:13
Extract the window RPC domain (window.list/current/focus/create/close/displays/
display) out of TerminalController into a new @mainactor @observable
ControlCommandCoordinator in CmuxControlSocket, behind the read-only
ControlCommandContext seam (app target conforms; package never imports the app
target). The coordinator owns the kind:N ControlHandleRegistry (RPC selection
state per the decomposition plan); TerminalController delegates its ensureRef/
resolveRef/removeRef to it so refs stay consistent across moved and not-yet-
moved domains.

Faithful lift: the window bodies build ControlCallResult/JSONValue payloads
whose Foundation object is identical to the legacy [String: Any] dictionaries,
so the encoded wire bytes match. Dispatch runs on the main actor inside the
existing withSocketCommandPolicy scope, so the per-read v2MainSync hops the
legacy bodies used become plain in-isolation calls and disappear. window.current
preserves both distinct legacy errors (unavailable vs not_found) via
ControlCurrentWindowResolution.

TerminalController.swift 22074 -> 21921 (budget ratcheted). 17 new package
tests (128 total) drive every window method through a fake context, asserting
byte-identical payloads, ref minting, routing-selector parsing, and the two
window.current failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure the seam into a per-domain protocol umbrella (ControlCommandContext:
ControlWindowContext, ...) so each domain can be built in its own files, and
port the shared TerminalControllerV2ParamParsingSupport pure helpers + ref
minting (workspaceRefs/tabRef/workspacePaneAndSurfaceRefs) into the coordinator
as JSONValue twins. Foundation for moving the remaining RPC domains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the app-focus (app.focus_override.set, app.simulate_active), main-actor feed
(feed.jump, feed.list), and notification (create/create_for_surface/create_for_target/
list/dismiss/mark_read/open/jump_to_unread/clear) domains into the coordinator
behind their per-domain seams (ControlAppFocusContext/ControlFeedContext/
ControlNotificationContext), composed into the ControlCommandContext umbrella. The
core handle(_:) now chains per-domain handleX dispatchers.

Worker-lane methods stay app-side: feed.push/permission.reply/question.reply/
exit_plan.reply, and notification.create_for_caller (its own resolver).

Faithfulness: byte-identical payloads/errors (live socket sweep on ctl3c1 confirms
every result + error shape). Notification localized strings are resolved in the app
conformance (app bundle) and passed through ControlNotificationStrings, because
String(localized:) inside the package would bind to the package bundle and silently
drop the Japanese translations — a wire change for non-English locales.

Test fakes get benign defaults for non-window seams via ControlCommandContextTestStubs
so each fake implements only the domain it exercises (128 package tests still green).
TerminalController.swift 21952 -> 21522 (budget ratcheted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move workspace.group.* (17 methods), pane.* (9 methods), and mobile.host.status/
mobile.workspace.list/mobile.terminal.* (+terminal.* aliases) into the coordinator
behind ControlWorkspaceGroupContext/ControlPaneContext/ControlMobileHostContext,
composed into the umbrella; core handle(_:) chains the new handlers.

Workspace Groups + Pane are full lifts (bodies deleted, payloads rebuilt as JSONValue,
localized group strings routed app-side via ControlWorkspaceGroupStrings). Mobile Host
is a faithful pass-through: its 8 bodies are SHARED with the mobile data-plane
(mobileHostHandleRPC) so they stay in TerminalController (relaxed private->internal);
the coordinator decouples via the seam and the conformance bridges V2CallResult.

Pane folds the resize support helpers (kept app-side: Bonsplit-coupled); v2SurfaceMove
relaxed private->internal for pane.join forwarding. Live socket sweep on ctl3c1 confirms
faithful payloads + errors (group create/list, pane list/create split, mobile host status).

TerminalController.swift 21522 -> 20296. 128 package tests green. Two new Pane files
>500 lines get budget entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression found by the no-regression code review of the moved domains: the
ported int() did Int(value) on a JSON double, which TRAPS (crashes) on overflow/
NaN — reachable via pane.resize amount or workspace.group.move to_index with e.g.
1e30 — whereas legacy v2Int went through (params[key] as? NSNumber).intValue,
which clamps. Also int()/double() didn't coerce a JSON boolean to a number the
way the legacy as? NSNumber path did.

Both now route doubles/bools through NSNumber.intValue/.doubleValue, matching
v2Int/v2Double exactly (truncate-toward-zero, clamp out-of-range, bool->1/0).
5 regression tests cover truncation, overflow/NaN no-trap, and bool coercion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Workspace (21 methods incl. remote.*) and Surface (25 methods + debug.terminals)
move into ControlCommandCoordinator behind ControlWorkspaceContext/
ControlSurfaceContext. ~2640 lines deleted from TerminalController.swift
(20296 -> ~17650). Worker-lane workspace.remote.pty_* stay app-side.

Two shared bodies the drafting agents wrongly flagged for deletion were RESTORED
(internal/private): v2WorkspaceCreate(params:tabManager:) is still driven by the
mobile data-plane v2MobileWorkspaceCreate; workspaceCloseProtectedMessage() by the
v1 close path. surface.move + debug.terminals forward to the still-shared
v2SurfaceMove/v2DebugTerminals (relaxed internal), like pane.join. Relaxed to
internal for the conformances: tabManager, socketFastPathState, orderedPanels,
readTerminalTextRawSnapshot.

Live socket sweep on ctl3c1 confirms faithful payloads + errors across both
domains (workspace list/current/create/rename/select/next/close, surface list/
current/health/send_text+read_text round-trip/resume.get, error shapes).
133 package tests green.

KNOWN FOLLOW-UPS: workspace.create logic is duplicated (conformance reimplements +
restored shared body) — dedupe by forwarding; the 2 Workspace files >500 lines
(budget entries added) should be split; adversarial code-review verification of
these 2 domains still pending (8 prior domains verified clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coordinator/ had grown to 105 files. Move each domain's coordinator extension,
seam protocol, and value/resolution/snapshot types into a per-domain subfolder
(Window/AppFocus/Feed/Notification/Pane/Surface/Workspace/WorkspaceGroup/
MobileHost). The 4 shared core files stay at the Coordinator/ root:
ControlCommandContext (umbrella), ControlCommandCoordinator (core dispatch +
handle registry), ControlCommandCoordinator+Params (shared param/ref helpers),
ControlRoutingSelectors. SwiftPM globs sources recursively, so this is purely
organizational — no Package.swift/import changes. Budget paths updated for the
moved Pane/Workspace coordinator files; TC.swift budget corrected to 17680
(the two restored shared bodies grew it after the last bump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Workspace lift had reimplemented workspace.create logic in the conformance
while the original v2WorkspaceCreate(params:tabManager:) was restored for the
mobile data-plane caller -- two copies that could diverge. Replace the typed
reimplementation with a passthrough that forwards to the single shared
v2WorkspaceCreate (relaxed private->internal) and bridges its Foundation result,
exactly like surface.move/debug.terminals/mobile. Deletes the now-unused
ControlWorkspaceCreateInputs/ControlWorkspaceCreateResolution. One source of
truth, byte-identical wire output.

Comprehensive socket sweep on ctl3c1 (all 10 domains, 38 ok + 13 expected
validation errors, zero crashes) confirms no regression: workspace.create happy
path + its cwd/layout validation errors preserved; pane.resize amount=1e30 now
clamps (invalid_state) instead of trapping (the int/double NSNumber fix). 133
package tests green.

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

Surface (4): surface.clear_history with a present-but-invalid surface_id silently
cleared the FOCUSED surface instead of returning not_found (wrong-target side
effect; hasSurfaceIDParam now crosses the seam like send_text); surface.split
with an unrecognized direction returned unavailable instead of invalid_params
'Missing or invalid direction (left|right|up|down)' (coordinator now validates
the parseSplitDirection token set + a drift-safe .invalidDirection case);
surface.split error precedence restored (direction -> agent-session -> divider;
the agent-session token check moved before divider parsing); surface.resume.*
explicit target restored to surface_id ?? tab_id ONLY (terminal_id is a general
routing alias but was never a resume target) and the window branch now requires
a RESOLVABLE window_id like origin.

Workspace (4): select/close/rename get the routing precheck so unresolvable
routing returns unavailable before param validation (legacy TabManager-first
order, matching reorder); workspace.current with a stale selectedTabId returns
.ok with workspace:null again instead of not_found. Dead code removed
(JSONValue.isControlNull, surfaceIDForInput).

All confirmed by live socket sweep on the rebuilt ctl3c1 (each previously-wrong
response now byte-matches origin). 133 package tests green.

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

# Conflicts:
#	.github/swift-file-length-budget.tsv
…n, path/link/copy-mode, surface DTOs)

Faithful lift of the Wave-2 terminal core out of Sources/GhosttyTerminalView.swift
into Packages/CmuxTerminalCore:

- Interop/GhosttyRuntimeCInterop: the @_silgen_name ghostty_surface_clear_selection
  shim as the one sanctioned header-less FFI seam
- KeyEvents/GhosttyKeyEventTranslation: flagsChanged press/release resolution;
  TerminalKeyboardCopyModeModifiers+NSEvent adapter for CmuxTerminalCopyMode
- PathResolution/TerminalPathResolver: quicklook/open-url path heuristics,
  shell token unquote/unescape, trailing-punctuation trimming, visible-line
  tokenization (legacy file-scope helpers and constant Sets folded in)
- LinkRouting/TerminalLinkRouter + TerminalOpenURLTarget behind the
  BrowserHostNormalizing seam (app conforms via BrowserInsecureHTTPSettings)
- SurfaceCallbacks/GhosttySurfaceCallbackContext behind
  TerminalSurfaceControlling/TerminalSurfaceHosting (TerminalSurface and
  GhosttyNSView conform in the app)
- SurfaceValues: PendingKeyEvent, PendingSocketInput, ParsedSocketInput,
  NamedKeySendResult, InputSendResult, PortalLifecycleState, PortalHostLease
- Scrollbar/GhosttyScrollbar; DebugSupport/TerminalChildExitProbe +
  String.unicodeScalarHexList (DEBUG-only probe scaffolding)

Tests: 66 package tests in 15 suites (swift test green); the 3 relocated
XCTest classes (34 tests) move out of cmuxTests into the package as
Swift Testing suites with added coverage.

Net line delta: Sources/GhosttyTerminalView.swift 16539 -> 15937 (-602),
cmuxTests/TerminalAndGhosttyTests.swift -333, package +1787 (sources+tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the new package to the Swift package unit-test loop in ci.yml so its
suites run in CI rather than only compiling. Its GhosttyKit binaryTarget
resolves against the xcframework the tests job already downloads, and the
test runner links the GhosttyRuntimeTestStubs C stub instead of the archive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- GhosttyTerminalView.swift: the teardown free() captured the non-Sendable
  Unmanaged<GhosttySurfaceCallbackContext> in MainActor.run's @sendable
  closure (newly diagnosed now that the context is a Swift 6 package type).
  Release through the captured @unchecked Sendable teardown request instead;
  same release, same main-actor hop. +3 lines, budget entry updated to 15940.
- TerminalController+ControlWorkspaceContext.swift: drop the extraneous
  duplicate 'sessionID sessionID:' parameter name inherited from the base
  branch; label and behavior unchanged.

Tagged app rebuild green with zero new warnings.

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

The tests-build-and-lag job failed solely on the Swift WARNING budget: the
Workspace conformance's controlWorkspaceRemotePTYAttachEnd declared
'sessionID sessionID: String' (extraneous duplicate). Behavior identical; the
job's build and lag phases were green.

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

Five domains drafted by the orchestrator's agents (handed off), repaired
(browserNavContext accessor, allocateElementRef state call, v1 handlers
unhooked from the v2 chain), wired into the umbrella + dispatch, with test
stubs completed. 140 package tests green. App-side surgery follows.

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

A fresh 'swift test' for CmuxTerminalCore runs all 66 tests green but exits 1:
SwiftPM emits an error-severity diagnostic while planning the GhosttyKit
binaryTarget ("unexpected binary name"/"unexpected binary framework", the
xcframework's ghostty-internal.a is not lib-prefixed) and that poisons the
process exit code even though build and tests succeed. Both CI attempts of the
tests job failed exactly here; reproduced locally from a clean .build (cached
.build exits 0, which is how it slipped past local verification).

Scope a documented tolerance to this one package: a nonzero exit passes only
when the all-tests-passed summary is present, no XCTest failures are reported,
and the only error lines are that known diagnostic. Compile errors, test
failures, and crashes still fail the job (verified against the real CI output
plus simulated failure shapes). All other packages keep the strict exit-code
gate. Net +27 lines in ci.yml, no app code changes; package tests stay 66 in
15 suites.

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

TerminalController.swift 18,033 -> 10,748 (-7,285). The five remaining domains
now dispatch through ControlCommandCoordinator: System (identify/tree/auth.login/
session.restore/settings.open/feedback.open/extension snapshot/workspace.action/
tab.action/drag_to_split/split_off), Project (project.* + markdown.open +
file.open), Debug (39 debug.* methods), Sidebar v1 (44 verbs via a new
handleSidebarV1 hook ahead of the v1 switch), Browser panel v1 (8 verbs), and
all 89 main-actor browser.* methods.

Browser per-surface state moved off the controller: ControlBrowserAutomationState
(package) + dialog responders keyed by dialogID app-side (the Sendable
V2BrowserPendingDialog redesign); cleanupSurfaceState purges the new state,
faithfully mirroring the legacy eviction. Two conformances the drafts never
included (ControlBrowserContext, ControlBrowserPanelContext) were authored
byte-faithfully from the legacy bodies. Shared bodies kept + relaxed to internal
(v2Identify, v2WorkspaceAction, v2SurfaceSplitOff, v2FileOpen, the 18 v1-debug
impls, the JS pump, the worker-lane browser.download.wait cluster).

Deliberate deltas (documented): controlFeedbackOpen drops the deprecated
.activateIgnoringOtherApps activation option (documented no-op on macOS 14+,
the project floor; keeping it fails the new-file warning budget); a sequence id
bridges Int64->Int (lossless on arm64).

Gates: package swift build + 140/140 tests; tagged app build BUILD SUCCEEDED;
live socket sweep green across all domains (system.tree, auth.login parity,
browser.open_split -> get.title returns the real page title end-to-end,
project validation errors, v1 set-status via the new hook, debug.terminals,
plus regression of the ten prior domains); zero new warnings; both budgets pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The System+Project adversarial review found file.open had been reimplemented in
the coordinator/conformance while the original v2FileOpen stayed behind (it is
driven directly by FilePreviewReviewFeedbackTests and MarkdownPanelTests) - two
copies that could drift, and a stale dispatcher comment claiming forwarding.
file.open now forwards to the single shared body and bridges its result, like
workspace.create; the reimplementation and its now-unused
ControlFileOpenResolution/ControlFileOpenSurface types are deleted.

Review verdicts so far: System+Project all faithful (this was the only finding,
not a behavior bug); Debug (39 verbs) + Sidebar v1 (44) + Browser-panel v1 (8)
all faithful, zero divergences, #if DEBUG gating verified end-to-end.

140 package tests green; app build green; live probe of file.open through the
shared body (happy path + both error shapes) byte-faithful.

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

The Browser adversarial review (87/89 faithful) found its only two divergences
share one root cause: focus_mode.set and zoom.set validated mode/direction
BEFORE the TabManager/handle guards (legacy order: guards first). The shared
browserFocusedAction helper gains a post-guard validate step; both methods'
validation moves there. Live-verified: double-fault now returns
unavailable/'TabManager not available', single-fault the mode/direction error.

Residue: socketFastPathState drops its 'nonisolated' (after the cutover its
only callers are the @mainactor sidebar/surface conformances; the worker-thread
fast path retired with the legacy dispatcher). ServerEventTarget's @unchecked
Sendable and the V2CallResult/V2SocketRequest twins stay deliberately: they
serve the worker-lane and kept-shared bodies, which move in a later wave (the
target itself dissolves with TerminalControlComposition in Wave 5).

Verification totals for the five stacked domains: 143 methods/verbs reviewed
per-method vs the pre-deletion originals; 141 faithful as-lifted, 2 fixed here.
140 package tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stage 3c (stacked): final five domains — System/Project/Debug/Sidebar/Browser
…est, unused imports)

- @ObservationIgnored on the coordinator's handles registry: it is a struct
  mutated by ref() on nearly every response, so tracking it would invalidate
  any observer on every socket command (greptile).
- windowCloseOkAndNotFound now also asserts the not_found branch (coderabbit).
- Drop unused Foundation imports from ControlAppFocusContext and
  ControlMobileHostContext (coderabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the protocol seams the terminal engine and services slices conform to:
TerminalClipboardReading/TerminalClipboardWriting/TerminalImagePasteWriting
(+ the two image-materialization result values), RenderDemandGating/
RenderDemandRetention and TerminalRenderedFrameReceiving, and
TerminalSurfaceRegistering/TerminalSurfacing/MainWindowRouteRetiring with
TerminalSurfaceFocusPlacement lifted from the god file. Package.swift also
re-vends the GhosttyKit binaryTarget as the CmuxGhosttyKit product so sibling
terminal packages can speak ghostty C types without declaring a duplicate
binary target.

Seams only, no behavior: every protocol is documented with the isolation
rationale (synchronous Sendable on purpose: deinit unregistration, renderer-
thread reads, C callbacks that cannot await).

Tests: covered by the conforming packages' suites (15 + 14) in the follow-up
commits; net +274 lines across 11 new seam files.

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

Faithful lift of the god file's GhosttyPasteboardHelper namespace enum into a
constructed, injectable service conforming to the CmuxTerminalCore clipboard
seams. Pasteboard flavor priority (UTF-8 before lossy traditional-mac text,
image-only HTML guard, plain-vs-rich fidelity via CMUXPasteboardFidelity),
shell escaping, the 10 MB image cap, TIFF-to-PNG normalization, temp-file
ownership accounting, and the one-shot standard-clipboard write capture all
keep their exact legacy logic and formats.

Documented deltas from the legacy enum, none observable by callers:
- statics become one process-wide instance (constructed at the transitional
  composition point in the next commit); the temp directory is injected for
  tests and defaults to the legacy FileManager temporary directory
- escapeForShell becomes String.terminalShellEscaped (same character set and
  newline single-quoting)
- cmuxDebugLog becomes the underlying CMUXDebugLog.logDebugEvent it wraps
- the materialization results become top-level Sendable enums in
  CmuxTerminalCore with identical cases

Isolation essay on the type: callers are synchronous on several threads
(ghostty write-clipboard callback, main-actor paste paths, background upload
completions), so the service is nonisolated Sendable with two lock-guarded
values, the sanctioned shape for state shared with synchronous callbacks.

Tests: 15 new behavior tests in 4 suites (flavor priority, write capture,
materialization/ownership, escaping). Net +901 lines package, god-file
deletion lands with the app rewiring commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts the engine plumbing cluster out of the god file:

- TerminalSurfaceRegistry drops 'static let shared'; the AppDelegate.shared
  reach-up on unregister is inverted through the MainWindowRouteRetiring seam
  (attached at composition). Registration, placement bookkeeping, runtime-
  pointer ownership guards, and the sorted-by-id iteration keep their exact
  legacy logic; the route-retire sweep hops to the main actor exactly as the
  legacy Task did.
- RenderDemandCounter replaces the GhosttyRenderedFrameNotificationDemand and
  GhosttyTickNotificationDemand namespace enums (static NSLock counters) with
  injectable instances behind RenderDemandGating. Delta: release is idempotent
  per retention where the legacy closure decremented on every call; no caller
  releases twice, so this only removes a latent double-release hazard.
- GhosttyMetalLayer reports vended drawables through the injected
  TerminalRenderedFrameReceiving seam instead of holding GhosttyNSView, and
  hops via Task { @mainactor } instead of DispatchQueue.main.async (same
  executor, and the receiver coalesces bursts, so ordering is unobservable).

Isolation essays inline: the blueprint sketched actors, but the hot reader
(nextDrawable on the renderer thread) and the deinit unregistration path are
synchronous and cannot await, so the state stays behind locks, the sanctioned
shape for tiny values shared with synchronous off-isolation callers.

Tests: 14 new behavior tests in 2 suites (registry register/resolve/evict,
shared-id placement survival, runtime-pointer owner guards, route-retire
main-actor hop, demand counts and idempotent release). Net +606 lines.

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

Deletes GhosttyPasteboardHelper (740 lines incl. the DEBUG free-function test
bridges), both render-demand namespace enums, GhosttyMetalLayer,
TerminalSurfaceRegistry, and TerminalSurfaceFocusPlacement from
GhosttyTerminalView.swift, and rewires every caller to the packages:

- Transitional composition statics on GhosttyApp (documented inline):
  terminalSurfaceRegistry, renderedFrameNotificationDemand,
  tickNotificationDemand, terminalPasteboard. Every remaining caller is a
  god file that cannot take constructor injection until its own slice lands;
  these dissolve when GhosttyAppService replaces GhosttyApp.
- TerminalSurfaceRegistry.shared dies: AppDelegate conforms to
  MainWindowRouteRetiring (witness already existed) and attaches itself in
  init, inverting the registry's reach-up. App callers go through the
  registry instance; concrete-typed conveniences (terminalSurface(id:),
  allTerminalSurfaces()) cover the two callers that need TerminalSurface.
- GhosttyNSView conforms to TerminalRenderedFrameReceiving; the Metal layer
  gets the demand gate injected at creation. retain* helpers keep their
  () -> Void shape so the two demand consumers are untouched.
- TerminalImageTransferPlanner.escapeForShell forwards to
  String.terminalShellEscaped (single source of truth).
- cmuxTests rewire to the composition statics; the pasteboard fidelity suite
  now calls the service directly instead of the deleted DEBUG bridges.
- ci.yml package-test gate runs CmuxTerminalEngine + CmuxTerminalServices,
  extending the known cosmetic GhosttyKit binaryTarget tolerance to exactly
  the GhosttyKit-referencing packages.

GhosttyTerminalView.swift shrinks 15940 -> 15086 (-854); budget entries
updated (a few callers grew by import lines only). Tagged app build
termeng: BUILD SUCCEEDED; package suites 15 + 14 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greptile review: nextDrawable() took the layer lock three times per frame
(count update, demand read, receiver read). Merge them into one acquisition;
the legacy code also took multiple locks here, so this is a strict hot-path
improvement with no behavior change. Engine suite still 14/14.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ert the coordinator's v2 browser.* domain

PR 5778 moved the JS-evaluating browser.* methods onto a nonisolated
socket-worker lane while this branch had lifted the (pre-5778) browser
domain into the @mainactor coordinator. The two designs are incompatible
and main's is the behavioral reference, so this merge takes main's
browser implementation wholesale and deletes the coordinator's browser-v2
domain (package files, app conformances, umbrella members, tests). The
v1 browser-panel and sidebar handlers and the other 13 coordinator
domains are untouched by main and stay. mobile.terminal.paste (new in
PR 5876) dispatches from the legacy v2 switch. The browser domain gets
re-lifted in a follow-up against the worker-lane architecture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
unregisterNotifiesRouteRetirerOnMainActor raced: the async let waiter could
register after the registry's retire task already fired, losing the signal
and suspending forever. Swift Testing has no per-test timeout, so the hung
test silently ate the tests job until the 75-minute job timeout cancelled
the step (51 minutes of dead air after CmuxTerminalCore's suite passed).
The recorder now exposes awaitFirstRetire(), which returns immediately when
a retire was already recorded (check and continuation-append are atomic on
the main actor), and the test awaits after unregister instead of racing an
async let. 14/14 locally, exit honest from a warm build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit and others added 3 commits June 13, 2026 00:25
…ce (stack D, tranche A sub-5a)

Second blocker for moving GhosttyConfig into a package: GhosttyConfig still
reached up into the app-target AppearanceSettings type. Its signature referenced
AppearanceSettings.SystemAppearance, a pure system-interface-style value with no
app-appearance-mode dependency.

Lifted that value to CmuxTerminalCore as TerminalSystemAppearance (public,
Equatable, Sendable). The body is byte-identical to the original (prefersDark
caseInsensitiveCompare; current(defaults:) direct + global-domain fallback read
of the frozen AppleInterfaceStyle key). Expected deltas only: type rename for
the package namespace, internal->public + an explicit public init (memberwise
init is non-public across a module boundary), and the two interface-style
constants becoming static members on the struct.

App-side AppearanceSettings keeps a `typealias SystemAppearance =
TerminalSystemAppearance` so every call site (including the test that constructs
SystemAppearance(interfaceStyle:)) stays byte-identical. GhosttyConfig now types
its systemAppearance parameter as TerminalSystemAppearance directly, removing one
of its two AppearanceSettings references.

Gates: CmuxTerminalCore swift build + 69 tests pass (3 new TerminalSystemAppearance
tests); lint clean; app xcodebuild Debug BUILD SUCCEEDED; budget refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mePreference (stack D, tranche A sub-5b)

GhosttyConfig.ColorSchemePreference is a terminal-domain value (it keys
libghostty theme selection) referenced at ~40 sites across the terminal
view/engine code. Lifted the two-case enum to CmuxTerminalCore as
TerminalColorSchemePreference (Hashable, Sendable). Faithful: same two cases
(.light/.dark), same Hashable conformance; only adds Sendable (trivially true
for a value enum) and the package namespace.

GhosttyConfig keeps a nested `typealias ColorSchemePreference =
TerminalColorSchemePreference`, so every `GhosttyConfig.ColorSchemePreference`
call site (AppDelegate, GhosttyTerminalView stored properties, AppearanceSettings
return types, tests) stays byte-identical. This moves the type out of the app
target ahead of the eventual GhosttyConfig move, without touching any call site.

Gates: CmuxTerminalCore swift build + 69 tests pass; lint clean; app xcodebuild
Debug BUILD SUCCEEDED; budget refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onfig now AppearanceSettings-free (stack D, tranche A sub-5c)

Final blocker removed: GhosttyConfig.currentColorSchemePreference delegated to
AppearanceSettings.terminalColorSchemePreference (app-appearance domain),
keeping GhosttyConfig tied to the app target.

Lifted the resolution into TerminalColorSchemePreference.resolve(appearanceMode
RawValue:systemAppearance:) in CmuxTerminalCore. It is byte-faithful to the
legacy logic: an explicit "light"/"dark" mode short-circuits, everything else
(system/auto/unset/unknown) follows the system interface style. This matches
AppearanceSettings.mode(for:) collapsing auto->system and unknown->system before
only .light/.dark short-circuited.

GhosttyConfig.currentColorSchemePreference now calls
TerminalColorSchemePreference.current (reads the frozen "appearanceMode" key
itself). App-side AppearanceSettings.terminalColorSchemePreference forwards its
normalized mode rawValue into the same package resolver, so both surfaces share
one source of truth and stay byte-identical. The app-domain AppearanceMode enum
(localized, used across UI/keyboard-shortcut/pairing files) stays app-side.

GhosttyConfig.swift now has ZERO AppearanceSettings references and ZERO GhosttyApp
references: its only remaining app-target reach is the DEBUG-gated
GhosttyStartupAppearancePreviewState (next sub-tranche). Both config<->app
recursions are broken.

Gates: CmuxTerminalCore swift build + 74 tests pass (5 new resolution tests);
lint clean; app xcodebuild Debug BUILD SUCCEEDED; budget refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit and others added 2 commits June 13, 2026 00:34
… tranche A sub-5d)

GhosttyConfig's sole remaining app-target reach was the DEBUG-gated
GhosttyStartupAppearancePreviewState.profile read in loadFromDisk. Inverted it
via a CmuxTerminalCore seam, TerminalStartupAppearancePreviewOverride: a
DEBUG-only Sendable value carrying loadsRealUserConfig + a previewConfigContents
closure, with a process-wide installed hook the app target is the sole writer of.

GhosttyConfig.loadFromDisk now consults the seam (installed?.loadsRealUserConfig
?? true), so no override installed == the real-user-config path, matching the
prior default profile (.realUserConfig). Control flow is byte-identical: the
loadConfigFiles / applyCmuxDefaultAppearance / parse bodies are unchanged; only
the gate source and the default-to-real coalescing changed.

App-side GhosttyStartupAppearancePreviewState.profile becomes a computed property
(DEBUG) that installs the override on write, keeping every call site
(StartupAppearanceDebugView, applyPreview/reset, GhosttyConfigTests) byte-identical
while making the app the single writer. The app-target enum keeps its localized
display strings.

GhosttyConfig.swift now has ZERO app-target type references (no GhosttyApp,
AppearanceSettings, AppDelegate, Workspace, or GhosttyStartupAppearance*). Its
dependencies are Foundation/AppKit + CmuxFoundation + CmuxTerminalCore symbols
only, so the type is now fully liftable into CmuxTerminalCore (the file move +
6.4k-line test retarget is the remaining Tranche A step).

Gates: CmuxTerminalCore swift build + 74 tests pass; lint clean (justified
DEBUG-only nonisolated(unsafe) hook); app xcodebuild Debug BUILD SUCCEEDED;
budget refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the sRGB hex-encoding extension from the app target (ContentView.swift)
to CmuxFoundation's NSColor+Hex.swift, alongside its inverse init?(hex:). This
removes GhosttyConfig's last app-target reach-up (applySidebarAppearanceToUser
Defaults() calls color.hexString()), unblocking the GhosttyConfig file-move into
CmuxTerminalCore in the next sub-tranche.

Adds DocC, makes it public, and imports CmuxFoundation in the four app files
that used the symbol without already importing the module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit and others added 2 commits June 13, 2026 00:51
Completes the GhosttyConfig lift: the 1,163-line config value type drains out
of the app target into CmuxTerminalCore/Config/GhosttyConfig.swift. The app
target keeps a one-line `typealias GhosttyConfig = CmuxTerminalCore.GhosttyConfig`
so every call site (including `GhosttyConfig.ColorSchemePreference` and
`GhosttyConfig.UserAppearanceConfigSummary` member lookups) stays byte-identical.

Adds the CmuxFoundation dependency to CmuxTerminalCore (no cycle: CmuxFoundation
is a zero-dependency leaf). All ~50 members made public + DocC; explicit
public init() since the memberwise init is not public across a module boundary.

Two Swift-6 strict-concurrency boundaries the app target tolerated needed
faithful, behavior-preserving fixes (not logic changes):
- cachedConfigsByColorScheme: nonisolated(unsafe) static var with justification
  that loadCacheLock (NSLock) serializes every access — the lock contract is
  unchanged from the app-target original.
- loadFromDisk: @usableFromInline internal (was private) so it can back the
  public `load` default-argument value; the body is not inlinable.

The frozen wire format (directive keys, theme resolution, NSColor hex codecs,
appearance-summary scanning) is preserved byte-identical; a machine-diff vs the
pre-move body showed only public/DocC/import/explicit-init deltas.

CmuxTerminalCore: swift build + swift test green (74 tests). App: xcodebuild
Debug BUILD SUCCEEDED, 0 errors. GhosttyConfigTests stays in the app-host test
target unchanged: it reaches GhosttyConfig only through its public API (no
@testable-private access), so the typealias keeps every assertion byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erminalCore (stack D, tranche A sub-6c)

Moves the two self-contained Swift Testing @suite structs that test the lifted
GhosttyConfig — SidebarFontSizeConfigTests and SurfaceTabBarFontSizeConfigTests
— out of the app-host cmuxTests grab-bag and into the CmuxTerminalCore package
test target, where the type they exercise now lives. They were already pure
public-API tests (no @testable-private access) using @Suite/@Test/#expect, so
the move is a faithful copy plus package imports; assertions are byte-identical.

Adds CmuxFoundation to the package test target (for CmuxGhosttyConfigSettingEditor
in the surface-tab-bar editor round-trip tests).

The larger GhosttyConfigTests class and SidebarBackgroundConfigTests stay in the
app-host target: GhosttyConfigTests is an inseparable grab-bag (it mixes genuine
GhosttyConfig theme/parse/load tests with app-target Telemetry/Kiro/Claude
integration and bundled-CLI tests in one class), and both reach app-target-only
helpers. Splitting that class would risk weakening coverage for no boundary
benefit; they keep working against the lifted type through the app typealias,
which exposes only GhosttyConfig's public API (exactly what these tests use).

CmuxTerminalCore: swift test green, 92 tests in 19 suites (the 18 moved tests
included). App: xcodebuild Debug BUILD SUCCEEDED, 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hhsw2015 pushed a commit to hhsw2015/cmux that referenced this pull request Jun 13, 2026
…nder welcome

PRs included:
- iOS notifications cross-device dismiss-sync + authoritative unread count
- iOS workspace row actions (manaflow-ai#6022)
- Sidebar perf: kill LazyVStack layout livelock (manaflow-ai#6019 + manaflow-ai#6024)
- iOS sign-out local-first offline-safe revocation
- iOS workspace list groups, unread dots, last-activity previews
- Refresh notification fallback sound DnD
- Email Founder's Edition customers welcome via Resend webhook
- Misc swift-file-length-budget refresh + extract NotificationSoundSettings
- cloud-testflight --marketing-version override

Fork-side adjustments:
- Drop fork's mobileAllowsWorkspaceAction static helper from TerminalController:
  upstream put it in Sources/TerminalController+MobileNotificationSync.swift
- Restore fork's renameTopLevelLayoutTabContaining and
  closeTopLevelLayoutTabContaining methods on Workspace (TerminalController
  v2WorkspaceTopTabRename/Close call sites depended on them)
- pbxproj keep-both for upstream new files + fork P41 entries
- Budget tsv merged via max-per-path script
@azooz2003-bit azooz2003-bit changed the base branch from feat-terminal-engine to main June 14, 2026 01:28
azooz2003-bit and others added 2 commits June 13, 2026 18:41
…9+5894)

5929 (CmuxTerminalEngine/Services) and 5894 (CmuxTerminalCore leaf) are now
in main, so this PR's diff vs main is just its own slice: the new CmuxTerminal
package (TerminalSurface model + lifecycle/teardown), the CmuxWorkspaceWindow
package (window-background policy + compositor blur), and the GhosttyConfig move
into CmuxTerminalCore.

Conflict resolution:
- Engine/Services test stubs (5929-owned): took main (no quicklook_font stub).
- Core test stub: kept HEAD's quicklook_font stub (6024's GhosttySurfaceRuntimeProbe
  in CmuxTerminalCore calls it).
- CmuxTerminalCore/Package.swift: kept HEAD's CmuxFoundation dep (GhosttyConfig move).
- GhosttyTerminalView.swift: took HEAD throughout (TerminalSurface class, teardown
  coordinator methods, window-background free functions all moved to packages;
  composition statics kept). Dropped main's now-duplicated inline conformances.
- AppDelegate.swift: took main's AccessibilityWindowCache (CmuxWindowing) over the
  branch's orphaned in-file CmuxApplicationAccessibilityHierarchyCache; dropped the
  re-added dead runMultiWindowRouteCLI (lifted to CmuxIPCService on main).
- Import-only conflicts (ContentView/Workspace/TerminalController/cmuxApp/BrowserPanel/
  MainWindowFocusController + 3 test files): unioned both sides.
- pbxproj: unioned all 6 package-wiring regions; normalize + check + xcodebuild -list pass.
- Budget tsv: regenerated from merged tree (GhosttyTerminalView 15182->12066).

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

Two failures surfaced only on CI's clean cmuxTests compile + warning-budget gate
(neither is exercised by a local app xcodebuild build):

- tests (cmux-unit): cmuxTests/GhosttyDECCKMArrowKeyTests.swift used the
  CmuxTerminal package type `TerminalSurface` without `import CmuxTerminal`
  ("Cannot find type 'TerminalSurface' in scope"). TerminalSurface has no
  app-side typealias bridge (unlike GhosttyConfig), so test files must import
  the package directly. Hoisted the import above the canImport(cmux_DEV) block,
  matching sibling terminal test files.

- tests-build-and-lag (swift_warning_budget per-bucket): the merge surfaced 4
  Swift-6 main-actor-isolation warnings (budget 0). Cleared them rather than
  budgeting:
  - GhosttyTerminalView.swift x3: @mainactor `setFocus`/`searchState` accessed
    from NotificationCenter observer closures that are registered with
    `queue: .main`; wrapped the bodies in `MainActor.assumeIsolated` (they were
    already main-isolated at runtime).
  - WindowBackgroundComposition.swift: `UserDefaults` stored in a Sendable-
    conforming struct; `nonisolated(unsafe) let` (UserDefaults accessors are
    documented thread-safe).

Verified: clean app xcodebuild BUILD SUCCEEDED; swift_warning_budget respected
against the clean build log (4 buckets cleared, no bucket over main's budget).

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

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

⚠️ Outside diff range comments (1)
cmux.xcodeproj/project.pbxproj (1)

1700-1710: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add CmuxTerminal and CmuxWorkspaceWindow to cmux target package dependencies.

Lines 1700 and 1710 add these products to the Frameworks phase, but PBXNativeTarget "cmux" packageProductDependencies (around Line 2637) does not include C750500000000000000000A2 or C750400000000000000000D2. This leaves SwiftPM target wiring incomplete.

Suggested pbxproj fix
 			packageProductDependencies = (
 				A5001231 /* Sparkle */,
 				A5001251 /* Sentry */,
@@
 				C750300000000000000000A2 /* CmuxTerminalEngine */,
 				C750400000000000000000A2 /* CmuxTerminalServices */,
+				C750500000000000000000A2 /* CmuxTerminal */,
+				C750400000000000000000D2 /* CmuxWorkspaceWindow */,
 				A8BD195031FC4B82B4354297 /* StackAuth */,
 				EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */,
 			);

As per coding guidelines, cmux.xcodeproj/** changes must keep project wiring coherent for app/runtime code paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux.xcodeproj/project.pbxproj` around lines 1700 - 1710, The diff adds
CmuxTerminal and CmuxWorkspaceWindow to the Frameworks section (lines 1700 and
1710 with IDs C750500000000000000000A3 and C750400000000000000000D3), but the
corresponding product dependency IDs C750500000000000000000A2 and
C750400000000000000000D2 are missing from the packageProductDependencies array
of the PBXNativeTarget "cmux" (around line 2637). Add both missing product
dependency IDs to the packageProductDependencies array to complete the SwiftPM
target wiring and ensure consistency between framework dependencies and package
product declarations.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxFoundation/Sources/CmuxFoundation/Color/NSColor`+Hex.swift:
- Around line 7-22: The hex parser has two validation issues that accept
malformed input. First, the global replacingOccurrences(of:"#", with:"") call
removes all # characters instead of just a leading prefix, allowing input like
"1#23456" to be accepted. Second, Scanner.scanHexInt64(_:) only validates a
valid hex prefix and does not require consuming the entire string, so input like
"12ABXX" passes the length check but is parsed incorrectly. Replace the hex
string sanitization and validation logic by first removing only a leading # if
present, then validating that the remaining string is exactly 6 characters long
and contains only valid hexadecimal characters (0-9, A-F, a-f) before attempting
to parse it with the Scanner, rejecting any input that fails these strict
checks.

In
`@Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift`:
- Around line 76-82: The nextRequestForWorker() method uses Array.removeFirst()
to dequeue items from queuedRequests, which performs O(n) array shifts per
dequeue operation, resulting in O(n²) complexity when draining large queues.
Replace the queuedRequests Array with a Deque (or implement a head-index FIFO
pattern with manual index tracking) to achieve O(1) dequeue operations. Update
the isEmpty check and the removeFirst() call in nextRequestForWorker() to use
the new data structure's dequeue method.

In `@Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/NSScreen`+DisplayID.swift:
- Around line 10-11: The code casts Int and NSNumber values directly to UInt32
without checking if they are non-negative, which can cause negative values to
wrap around and produce incorrect display IDs. Add guard conditions before the
UInt32 conversions to ensure both the Int value and the NSNumber value are
non-negative (greater than or equal to zero). If a value is negative, skip that
conversion and continue to the next check or return a default value. This
applies to both the Int casting line and the NSNumber casting line in this
function.

In
`@Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+Mobile.swift:
- Around line 15-27: The mobileScroll function does not validate that col and
row parameters are non-negative before calculating positions and sending them to
ghostty_surface_mouse_pos, unlike mobileClick which guards against negative
values. Add a guard statement early in the mobileScroll function to ensure both
col >= 0 and row >= 0, returning early if either condition fails, to prevent
negative coordinates from being forwarded to the ghostty surface mouse position
handler.

In
`@Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+RuntimeLifecycle.swift:
- Around line 13-919: The TerminalSurface+RuntimeLifecycle.swift extension file
exceeds 900 lines and violates the repository's coding guidelines by combining
multiple distinct responsibilities: bootstrap-window management, liveness
quarantine, teardown/hibernation, view attachment, Claude shim installation, and
runtime/environment construction. Split this file into separate, focused
extensions following the repository's guidance that Swift production files
should not exceed 800 lines. At minimum, move the createSurface method and all
related environment assembly logic (setManagedEnvironmentValue, environment
variable setup, command/working directory resolution) into one new extension
file (e.g., TerminalSurface+SurfaceCreation.swift), and move the
teardown-related methods (teardownSurface,
suspendRuntimeSurfaceForAgentHibernation, closeHeadlessStartupWindowIfNeeded)
and attachment logic (attachToView, attachedView management) into another
extension file (e.g., TerminalSurface+Teardown.swift or
TerminalSurface+Attachment.swift). Ensure each resulting file has a clear,
single primary responsibility and remains under 800 lines.
- Around line 409-419: The code is calling ghostty_surface_set_display_id
directly on the raw surface pointer instead of routing through the liveness
guard liveSurfaceForGhosttyAccess(reason:). Replace the direct surface pointer
usage with a call to liveSurfaceForGhosttyAccess to ensure the pointer is
validated before dereferencing, preventing crashes from dangling pointers if
Ghostty has already freed the surface out-of-band. This pattern appears in
multiple locations throughout the reattach logic and should be fixed
consistently everywhere ghostty_surface_set_display_id is called on a
potentially-invalidated surface reference.

In
`@Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+Sizing.swift:
- Around line 312-315: The display ID is being set on currentSurface after a
potential surface recreation via view.forceRefreshSurface(), but when the
surface is invalidated and recreated, the reacquired surface never receives the
display ID topology fix. Apply the display ID setting to the newly reacquired
surface immediately after it is obtained, before any subsequent operations like
ghostty_surface_refresh. This fix needs to be applied at both locations where
surfaces are reacquired in TerminalSurface+Sizing.swift (at lines 312-315 and
lines 323-326).
- Around line 272-285: Remove the DEBUG block containing string construction and
the logDebugEvent call from the forceRefresh method because this method is a
per-keystroke hot path and adding string formatting and logging violates the
typing-latency coding guidelines. Delete the entire if DEBUG section from
forceRefresh and apply the same fix to any other similar debug logging blocks in
this file that have been added to hot-path methods.

In
`@Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift`:
- Around line 246-251: When rawSidebarBackground is nil in the guard statement
at line 246-251 of GhosttyConfig.swift, stale sidebarTintHex* keys remain in
UserDefaults and continue to affect sidebar rendering. In the early-return
branch where rawSidebarBackground is nil, add code to remove the stale sidebar
tint defaults (the sidebarTintHex* keys) from UserDefaults in addition to the
existing sidebarTintOpacity handling. This same cleanup logic should also be
applied at the corresponding location mentioned around lines 255-269 to ensure
consistency across all cases where sidebar-background is removed or cleared.
- Around line 207-220: The cmuxConfigPaths function currently uses
fileManager.urls(...).first which only retrieves a single application support
directory, ignoring CFFIXED_USER_HOME overrides and fallback candidates that are
standardized in CmuxApplicationSupportDirectories.userDirectories(...). Replace
the guard statement that retrieves appSupport using fileManager.urls with a call
to CmuxApplicationSupportDirectories.userDirectories(...), then update the
CmuxGhosttyConfigPathResolver.loadConfigURLs call to iterate through all
resolved directories instead of just a single appSupport directory, ensuring
proper handling of sandbox and test override scenarios.

In
`@Packages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Blur/CompositorBlurController.swift`:
- Around line 40-45: The resetBackgroundBlur(windowNumber:) function accepts an
Int parameter and directly converts it to UInt without validation, which will
cause a fatal runtime error if a negative value is passed. Add a guard statement
at the beginning of the function to verify that windowNumber is non-negative
(greater than or equal to 0), and either return early or throw an error if the
check fails. This ensures the API enforces its contract at the boundary rather
than relying on callers to only pass valid positive window numbers.

---

Outside diff comments:
In `@cmux.xcodeproj/project.pbxproj`:
- Around line 1700-1710: The diff adds CmuxTerminal and CmuxWorkspaceWindow to
the Frameworks section (lines 1700 and 1710 with IDs C750500000000000000000A3
and C750400000000000000000D3), but the corresponding product dependency IDs
C750500000000000000000A2 and C750400000000000000000D2 are missing from the
packageProductDependencies array of the PBXNativeTarget "cmux" (around line
2637). Add both missing product dependency IDs to the packageProductDependencies
array to complete the SwiftPM target wiring and ensure consistency between
framework dependencies and package product declarations.
🪄 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: 962aeada-9b8e-4d11-92e6-e517acca0c56

📥 Commits

Reviewing files that changed from the base of the PR and between 02a041f and 3f8e336.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (133)
  • CLI/CMUXCLI+Config.swift
  • CLI/CMUXCLI+ThemeSupport.swift
  • CLI/CMUXCLI+Themes.swift
  • Packages/CmuxFoundation/Sources/CmuxFoundation/Color/NSColor+Hex.swift
  • Packages/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxApplicationSupportDirectories.swift
  • Packages/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigPathResolver.swift
  • Packages/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigSettingEditor.swift
  • Packages/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/GhosttyBackgroundBlur.swift
  • Packages/CmuxTerminal/Package.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Events/TerminalSurfaceNotifications.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceViewProviding.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Runtime/AgentHibernationRecording.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalEngineHosting.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererRealizationScheduling.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicy.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicyProviding.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/NSScreen+DisplayID.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+CopyMode.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PortalLease.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift
  • Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
  • Packages/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift
  • Packages/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c
  • Packages/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h
  • Packages/CmuxTerminalCore/Package.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/ColorScheme/TerminalColorSchemePreference.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/ColorScheme/TerminalStartupAppearancePreviewOverride.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/ColorScheme/TerminalSystemAppearance.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRuntimeProbe.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceClaudeCommandShim.swift
  • Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCmuxContextEnvironment.swift
  • Packages/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SidebarFontSizeConfigTests.swift
  • Packages/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceTabBarFontSizeConfigTests.swift
  • Packages/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalSystemAppearanceTests.swift
  • Packages/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c
  • Packages/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h
  • Packages/CmuxWorkspaceWindow/Package.swift
  • Packages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Blur/CompositorBlurController.swift
  • Packages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Policy/WindowBackgroundPolicy.swift
  • Packages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Settings/WindowBackgroundSettingsReading.swift
  • Packages/CmuxWorkspaceWindow/Tests/CmuxWorkspaceWindowTests/WindowBackgroundPolicyTests.swift
  • Sources/App/RendererRealizationController.swift
  • Sources/App/ShortcutRoutingSupport.swift
  • Sources/App/TerminalFindEscapeRouting.swift
  • Sources/AppDelegate.swift
  • Sources/AppearanceSettings.swift
  • Sources/BackgroundWorkspacePrimeCoordinator.swift
  • Sources/CmuxApplicationSupportDirectories.swift
  • Sources/ContentView.swift
  • Sources/DockPanelView.swift
  • Sources/DragOverlayRoutingPolicy.swift
  • Sources/Feed/FeedButtonStyleDebugWindowController.swift
  • Sources/Find/SurfaceSearchOverlay.swift
  • Sources/GhosttyConfig.swift
  • Sources/GhosttyTerminalAppearance.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/GhosttyTerminalViewSupport.swift
  • Sources/HostSettingsActions.swift
  • Sources/MainWindowFocusController.swift
  • Sources/Mobile/MobileTerminalByteTee.swift
  • Sources/Panels/AgentSessionWebTheme.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/MarkdownPanelView.swift
  • Sources/Panels/MarkdownWebSupport.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/Panels/TerminalPanelView.swift
  • Sources/Settings/ConfigSource.swift
  • Sources/Sidebar/SidebarAppearanceSupport.swift
  • Sources/SidebarWorkspaceGroupHeaderView.swift
  • Sources/TabManager.swift
  • Sources/TerminalController+ControlDebugContext.swift
  • Sources/TerminalController+ControlSidebarContext3.swift
  • Sources/TerminalController.swift
  • Sources/TerminalImageTransfer.swift
  • Sources/TerminalPaneDropTargetView.swift
  • Sources/TerminalSurfaceClaudeCommandShim.swift
  • Sources/TerminalSurfaceCmuxContextEnvironment.swift
  • Sources/TerminalSurfaceRuntimeWiring.swift
  • Sources/TerminalViewportUITestRecorder.swift
  • Sources/TerminalWindowPortal.swift
  • Sources/TextBoxInput.swift
  • Sources/Windowing/WindowAppearanceSnapshot.swift
  • Sources/Windowing/WindowBackdropController.swift
  • Sources/Windowing/WindowBackgroundComposition.swift
  • Sources/Windowing/WindowGlassEffect.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceAppearanceResolution.swift
  • Sources/WorkspaceContentView.swift
  • Sources/WorkspaceSurfaceConfig.swift
  • Sources/cmuxApp.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/AgentHibernationTests.swift
  • cmuxTests/AppDelegateMoveTabToNewWorkspaceTests.swift
  • cmuxTests/AppDelegateShortcutRoutingTests.swift
  • cmuxTests/CJKIMEInputTests.swift
  • cmuxTests/CJKIMEMarkedSelectionTests.swift
  • cmuxTests/FinderFileDropRegressionTests.swift
  • cmuxTests/FishShellIntegrationTests.swift
  • cmuxTests/GhosttyCommandShiftForwardingTests.swift
  • cmuxTests/GhosttyConfigPathResolverTests.swift
  • cmuxTests/GhosttyConfigTests.swift
  • cmuxTests/GhosttyNotificationDispatcherTests.swift
  • cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift
  • cmuxTests/SessionPersistenceTests.swift
  • cmuxTests/ShellStartupMatrixTests.swift
  • cmuxTests/ShellStartupMissingBundleTests.swift
  • cmuxTests/SidebarWidthPolicyTests.swift
  • cmuxTests/TabManagerSessionSnapshotTests.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/TerminalAndGhosttyTests.swift
  • cmuxTests/TerminalControllerSocketSecurityTests.swift
  • cmuxTests/TerminalSearchOverlayMouseReleaseTests.swift
  • cmuxTests/TraditionalChineseIMENumpadRegressionTests.swift
  • cmuxTests/WindowAppearanceSnapshotTests.swift
  • cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift
  • cmuxTests/WorkspaceCloseTabsContextMenuTests.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift
  • cmuxTests/WorkspaceSplitStartupCommandTests.swift
  • cmuxTests/WorkspaceUnitTests.swift
💤 Files with no reviewable changes (3)
  • Sources/TerminalSurfaceCmuxContextEnvironment.swift
  • Sources/TerminalSurfaceClaudeCommandShim.swift
  • Sources/CmuxApplicationSupportDirectories.swift

Comment on lines +7 to +22
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")

var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
return nil
}

let r, g, b: CGFloat
if hexSanitized.count == 6 {
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
b = CGFloat(rgb & 0x0000FF) / 255.0
} else {
return 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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Swift Foundation, does Scanner.scanHexInt64(_:) succeed when only a prefix is valid hex (for example "12GG34")? Is checking scanner.isAtEnd required for strict full-string validation?

💡 Result:

In Swift Foundation, Scanner.scanHexInt64(:) does succeed when only a prefix is valid [1][2]. The method scans for a valid hexadecimal representation starting at the current position, and if found, it returns true and advances the scanner's position past the end of the scanned hexadecimal digits [1]. It does not require that the entire remainder of the string be consumed or that the scanner be at the end of the string [1]. For example, if you call scanHexInt64(:) on the string "12GG34", the scanner will successfully identify "12" as a valid hexadecimal number, store the value 0x12 (18) in the provided pointer, and move the scanner's current position to the character 'G'. Because it successfully scanned a valid prefix, it returns true [1]. Therefore, checking scanner.isAtEnd is required if you want to ensure the entire string is a valid hexadecimal number [1][3]. Without this check, the scanner will report success as long as it finds a valid numeric start, leaving the remaining invalid characters unparsed [1]. To perform strict full-string validation, you must ensure that after the scan completes, scanner.isAtEnd is true [1][4].

Citations:


🏁 Script executed:

find . -name "NSColor+Hex.swift" -type f

Repository: manaflow-ai/cmux

Length of output: 133


🏁 Script executed:

cat -n "./Packages/CmuxFoundation/Sources/CmuxFoundation/Color/NSColor+Hex.swift"

Repository: manaflow-ai/cmux

Length of output: 3537


Reject malformed hex input before conversion.

The hex parser accepts invalid input for two reasons:

  1. Line 8: replacingOccurrences(of: "#", with: "") removes all # characters globally, not just a leading prefix. Input like "1#23456" normalizes to "123456" and is accepted.

  2. Lines 10–13: Scanner.scanHexInt64(_:) succeeds on a valid hex prefix and does not consume the entire string. Input like "12ABXX" (6 chars) passes the length check, but Scanner consumes only "12AB", leaving "XX" unparsed. The resulting color is incorrect.

Replace with strict validation and parsing:

🔧 Suggested fix
-        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
-        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
-
-        var rgb: UInt64 = 0
-        guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
-            return nil
-        }
+        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+        if hexSanitized.hasPrefix("#") {
+            hexSanitized.removeFirst()
+        }
+        guard hexSanitized.count == 6,
+              hexSanitized.unicodeScalars.allSatisfy({ CharacterSet.hexDigits.contains($0) }),
+              let rgb = UInt64(hexSanitized, radix: 16) else {
+            return 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/CmuxFoundation/Sources/CmuxFoundation/Color/NSColor`+Hex.swift
around lines 7 - 22, The hex parser has two validation issues that accept
malformed input. First, the global replacingOccurrences(of:"#", with:"") call
removes all # characters instead of just a leading prefix, allowing input like
"1#23456" to be accepted. Second, Scanner.scanHexInt64(_:) only validates a
valid hex prefix and does not require consuming the entire string, so input like
"12ABXX" passes the length check but is parsed incorrectly. Replace the hex
string sanitization and validation logic by first removing only a leading # if
present, then validating that the remaining string is exactly 6 characters long
and contains only valid hexadecimal characters (0-9, A-F, a-f) before attempting
to parse it with the Scanner, rejecting any input that fails these strict
checks.

Comment on lines +76 to +82
private func nextRequestForWorker() -> TerminalSurfaceRuntimeTeardownRequest? {
guard !queuedRequests.isEmpty else {
isWorkerRunning = false
return nil
}
return queuedRequests.removeFirst()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the dequeue pattern currently uses front-removal in the worker path.
rg -n -C3 'nextRequestForWorker|removeFirst\(\)' Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift

Repository: manaflow-ai/cmux

Length of output: 785


🏁 Script executed:

cat .github/review-bot-rules/algorithmic-complexity.md

Repository: manaflow-ai/cmux

Length of output: 2617


🏁 Script executed:

# Check the overall context of TerminalSurfaceRuntimeTeardownCoordinator
wc -l Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift

Repository: manaflow-ai/cmux

Length of output: 165


🏁 Script executed:

# Get the full file to understand queue bounds and usage patterns
cat -n Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift

Repository: manaflow-ai/cmux

Length of output: 5547


Switch from Array.removeFirst() to a head-index FIFO pattern to avoid O(n²) queue drains during large close bursts.

Using Array.removeFirst() shifts the buffer for each dequeue, making the total cost for draining n queued surfaces O(n²). For workspace closes that queue 100+ surfaces at once, this wastes CPU on array shifts. The teardown coordinator is a production path expected to handle 1000+ surfaces (per algorithmic-complexity.md), so switch to a linear-time dequeue using a head index or deque.

♻️ Suggested implementation
+    private var queueHead = 0

     private func nextRequestForWorker() -> TerminalSurfaceRuntimeTeardownRequest? {
-        guard !queuedRequests.isEmpty else {
+        guard queueHead < queuedRequests.count else {
+            queuedRequests.removeAll(keepingCapacity: true)
+            queueHead = 0
             isWorkerRunning = false
             return nil
         }
-        return queuedRequests.removeFirst()
+        let request = queuedRequests[queueHead]
+        queueHead += 1
+        return request
     }
🤖 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/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift`
around lines 76 - 82, The nextRequestForWorker() method uses Array.removeFirst()
to dequeue items from queuedRequests, which performs O(n) array shifts per
dequeue operation, resulting in O(n²) complexity when draining large queues.
Replace the queuedRequests Array with a Deque (or implement a head-index FIFO
pattern with manual index tracking) to achieve O(1) dequeue operations. Update
the isEmpty check and the removeFirst() call in nextRequestForWorker() to use
the new data structure's dequeue method.

Source: Coding guidelines

Comment on lines +10 to +11
if let v = deviceDescription[key] as? Int { return UInt32(v) }
if let v = deviceDescription[key] as? NSNumber { return v.uint32Value }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard signed NSScreenNumber conversions before casting to UInt32.

Int/NSNumber can be negative; converting directly to UInt32 wraps and can produce a bogus display ID.

Suggested fix
-        if let v = deviceDescription[key] as? Int { return UInt32(v) }
-        if let v = deviceDescription[key] as? NSNumber { return v.uint32Value }
+        if let v = deviceDescription[key] as? Int,
+           v >= 0,
+           v <= Int(UInt32.max) {
+            return UInt32(v)
+        }
+        if let v = deviceDescription[key] as? NSNumber {
+            let n = v.int64Value
+            guard n >= 0, n <= Int64(UInt32.max) else { return nil }
+            return UInt32(n)
+        }
🤖 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/CmuxTerminal/Sources/CmuxTerminal/Surface/NSScreen`+DisplayID.swift
around lines 10 - 11, The code casts Int and NSNumber values directly to UInt32
without checking if they are non-negative, which can cause negative values to
wrap around and produce incorrect display IDs. Add guard conditions before the
UInt32 conversions to ensure both the Int value and the NSNumber value are
non-negative (greater than or equal to zero). If a value is negative, skip that
conversion and continue to the next check or return a default value. This
applies to both the Int casting line and the NSNumber casting line in this
function.

Comment on lines +15 to +27
public func mobileScroll(deltaLines: Double, col: Int, row: Int) {
guard deltaLines != 0,
let surface = liveSurfaceForGhosttyAccess(reason: "mobileScroll") else { return }
let size = ghostty_surface_size(surface)
// The surface is sized in backing pixels; `ghostty_surface_mouse_pos`
// wants points, so divide the cell size by the content scale.
let scale = max(Double(lastXScale), 1)
let cellWidthPt = Double(size.cell_width_px) / scale
let cellHeightPt = Double(size.cell_height_px) / scale
let posX = (Double(col) + 0.5) * cellWidthPt
let posY = (Double(row) + 0.5) * cellHeightPt
ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE)
ghostty_surface_mouse_scroll(surface, 0, deltaLines, 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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp negative mobile scroll coordinates before sending mouse position.

mobileClick already guards negative col/row, but mobileScroll forwards them directly to ghostty_surface_mouse_pos. A gesture that starts just outside the rendered grid can therefore report an invalid cell to alt-screen mouse handling and drop or misplace the wheel event.

Suggested fix
-        let posX = (Double(col) + 0.5) * cellWidthPt
-        let posY = (Double(row) + 0.5) * cellHeightPt
+        let clampedCol = max(0, col)
+        let clampedRow = max(0, row)
+        let posX = (Double(clampedCol) + 0.5) * cellWidthPt
+        let posY = (Double(clampedRow) + 0.5) * cellHeightPt
🤖 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/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+Mobile.swift
around lines 15 - 27, The mobileScroll function does not validate that col and
row parameters are non-negative before calculating positions and sending them to
ghostty_surface_mouse_pos, unlike mobileClick which guards against negative
values. Add a guard statement early in the mobileScroll function to ensure both
col >= 0 and row >= 0, returning early if either condition fails, to prevent
negative coordinates from being forwarded to the ghostty surface mouse position
handler.

Comment on lines +13 to +919
extension TerminalSurface {
@MainActor
func scheduleHeadlessRuntimeStartIfNeeded(reason: String) {
startRuntimeUsingHeadlessWindowIfNeeded(reason: reason)
}

@MainActor
private func startRuntimeUsingHeadlessWindowIfNeeded(reason: String) {
guard allowsRuntimeSurfaceCreation() else { return }
guard surface == nil else { return }
ensureHeadlessStartupWindowIfNeeded(reason: reason)
paneHost.attachSurface(self)
}

@MainActor
private func ensureHeadlessStartupWindowIfNeeded(reason: String) {
guard headlessStartupWindow == nil else { return }
guard paneHost.window == nil else { return }

let width = max(surfaceView.bounds.width, CGFloat(800))
let height = max(surfaceView.bounds.height, CGFloat(600))
let frame = NSRect(x: 0, y: 0, width: width, height: height)
let window = NSWindow(
contentRect: frame,
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.hasShadow = false
window.alphaValue = 0
window.ignoresMouseEvents = true
window.collectionBehavior = [.transient, .ignoresCycle, .stationary]
window.isExcludedFromWindowsMenu = true

let contentView = NSView(frame: frame)
paneHost.frame = contentView.bounds
paneHost.autoresizingMask = [.width, .height]
contentView.addSubview(paneHost)
window.contentView = contentView
headlessStartupWindow = window
paneHost.setVisibleInUI(false)
paneHost.setActive(false)

#if DEBUG
logDebugEvent(
"surface.headless_window.create surface=\(id.uuidString.prefix(8)) " +
"reason=\(reason) window=\(ObjectIdentifier(window))"
)
#endif
}

@MainActor
func releaseHeadlessStartupWindowIfNeeded(for view: any TerminalSurfaceNativeViewing) {
guard let window = headlessStartupWindow else { return }
guard let currentWindow = view.window, currentWindow !== window else { return }
headlessStartupWindow = nil
window.contentView = nil
window.close()
#if DEBUG
logDebugEvent(
"surface.headless_window.release surface=\(id.uuidString.prefix(8)) " +
"realWindow=\(ObjectIdentifier(currentWindow))"
)
#endif
}

@MainActor
func closeHeadlessStartupWindowIfNeeded() {
// Isolation note: the legacy helper accepted off-main callers with a
// Thread.isMainThread check + main-queue hop. Every caller
// (teardownSurface, agent-hibernation suspend) is main-actor isolated,
// so the hop was dead and the method is now @MainActor; deinit has its
// own transport-based hop.
let startupWindow = headlessStartupWindow
headlessStartupWindow = nil
guard let startupWindow else { return }
startupWindow.contentView = nil
startupWindow.close()
}

/// Reasserts the runtime display id after the view (re)enters a window.
@MainActor
public func reconcileAttachedWindowIfNeeded(for view: any TerminalSurfaceNativeViewing) {
guard attachedView === view else { return }
releaseHeadlessStartupWindowIfNeeded(for: view)
guard let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0 else { return }
guard let s = liveSurfaceForGhosttyAccess(reason: "reconcileAttachedWindow") else { return }
ghostty_surface_set_display_id(s, displayID)
}

/// Whether the surface model is attached to `view` with a live runtime
/// surface.
@MainActor
public func isAttached(to view: any TerminalSurfaceNativeViewing) -> Bool {
attachedView === view && surface != nil
}

/// Validates the runtime pointer (registry ownership + allocation
/// liveness) before handing it to a Ghostty C API; quarantines and tears
/// down a stale wrapper instead of returning a dangling pointer.
@MainActor
public func liveSurfaceForGhosttyAccess(reason: String) -> ghostty_surface_t? {
guard hasLiveSurface, let surface else { return nil }
let registeredOwnerId = registry.runtimeSurfaceOwnerId(surface)
guard registeredOwnerId == id,
GhosttySurfaceRuntimeProbe.surfacePointerAppearsLive(surface) else {
let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
let teeLease = mobileByteTeeLease
mobileByteTeeLease = nil
registry.unregisterRuntimeSurface(surface, ownerId: id)
self.surface = nil
activePortalHostLease = nil
recordTeardownRequest(reason: reason)
markPortalLifecycleClosed(reason: reason)
#if DEBUG
let registeredOwnerToken = registeredOwnerId.map { String($0.uuidString.prefix(5)) } ?? "nil"
logDebugEvent(
"surface.lifecycle.stale surface=\(id.uuidString.prefix(5)) " +
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
"registryOwner=\(registeredOwnerToken)"
)
#endif
callbackContext?.release()
teeLease?.release()
return nil
}
return surface
}

func recordTeardownRequest(reason: String) {
withDebugMetadataLock {
if teardownRequestedAt == nil {
teardownRequestedAt = Date()
}
if let existing = teardownRequestReason, !existing.isEmpty {
return
}
teardownRequestReason = reason
}
}

func recordRuntimeSurfaceCreation() {
withDebugMetadataLock {
runtimeSurfaceCreatedAt = Date()
}
}

func allowsRuntimeSurfaceCreation() -> Bool {
portalLifecycleState == .live && !runtimeSurfaceSuspendedForAgentHibernation
}

private var hasDeferredStartupWork: Bool {
let inheritedCommand = configTemplate?.command?.trimmingCharacters(in: .whitespacesAndNewlines)
let inheritedInput = configTemplate?.initialInput
return initialCommand != nil ||
tmuxStartCommand != nil ||
initialInput != nil ||
inheritedCommand?.isEmpty == false ||
inheritedInput?.isEmpty == false ||
pendingSocketInputBytes > 0
}

/// Whether this surface has startup work that justifies a background
/// runtime start.
public func hasDeferredStartupWorkForBackgroundStart() -> Bool {
hasDeferredStartupWork
}

/// Marks the portal as closing (close animation/teardown has begun).
public func beginPortalCloseLifecycle(reason: String) {
guard portalLifecycleState != .closed else { return }
guard portalLifecycleState != .closing else { return }
recordTeardownRequest(reason: reason)
portalLifecycleState = .closing
portalLifecycleGeneration &+= 1
#if DEBUG
logDebugEvent(
"surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " +
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
"generation=\(portalLifecycleGeneration)"
)
#endif
}

func markPortalLifecycleClosed(reason: String) {
guard portalLifecycleState != .closed else { return }
portalLifecycleState = .closed
portalLifecycleGeneration &+= 1
#if DEBUG
logDebugEvent(
"surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " +
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
"generation=\(portalLifecycleGeneration)"
)
#endif
}

/// Explicitly free the Ghostty runtime surface. Idempotent — safe to call
/// before deinit; deinit will skip the free if already torn down.
@MainActor
public func teardownSurface() {
recordTeardownRequest(reason: "surface.teardown")
markPortalLifecycleClosed(reason: "teardown")
closeHeadlessStartupWindowIfNeeded()

let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
let teeLease = mobileByteTeeLease
mobileByteTeeLease = nil
byteTee.dropSurface(surfaceID: id)

let surfaceToFree = surface
if let surfaceToFree {
registry.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
}
surface = nil

guard let surfaceToFree else {
callbackContext?.release()
teeLease?.release()
return
}

#if DEBUG
if runtimeSurfaceFreedOutOfBandForTesting {
runtimeSurfaceFreedOutOfBandForTesting = false
callbackContext?.release()
teeLease?.release()
return
}
#endif

#if DEBUG
if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting {
runtimeTeardown.enqueueRuntimeTeardown(
id: id,
workspaceId: tabId,
reason: "teardown",
surface: surfaceToFree,
callbackContext: callbackContext,
freeSurface: freeSurface
)
// The teardown coordinator releases callbackContext; teeLease is not
// transported through the request, so release it here.
teeLease?.release()
return
}
#endif

Task { @MainActor in
// Keep free behavior aligned with deinit: perform the runtime teardown on
// the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant.
ghostty_surface_free(surfaceToFree)
callbackContext?.release()
teeLease?.release()
}
}

/// Frees the runtime surface while keeping the model alive for an
/// agent-hibernation resume.
@MainActor
public func suspendRuntimeSurfaceForAgentHibernation(reason: String) {
runtimeSurfaceSuspendedForAgentHibernation = true
backgroundSurfaceStartQueued = false
closeHeadlessStartupWindowIfNeeded()
let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
let teeLease = mobileByteTeeLease
mobileByteTeeLease = nil
byteTee.dropSurface(surfaceID: id)

let surfaceToFree = surface
if let surfaceToFree {
registry.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
}
surface = nil
activePortalHostLease = nil
pendingSocketInputQueue.removeAll(keepingCapacity: false)
pendingSocketInputBytes = 0
desiredFocusState = false

guard let surfaceToFree else {
callbackContext?.release()
teeLease?.release()
return
}

#if DEBUG
logDebugEvent(
"surface.lifecycle.hibernate surface=\(id.uuidString.prefix(5)) " +
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason)"
)
#endif

#if DEBUG
if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting {
runtimeTeardown.enqueueRuntimeTeardown(
id: id,
workspaceId: tabId,
reason: reason,
surface: surfaceToFree,
callbackContext: callbackContext,
freeSurface: freeSurface
)
// The teardown coordinator releases callbackContext; teeLease is not
// transported through the request, so release it here.
teeLease?.release()
return
}
#endif

Task { @MainActor in
ghostty_surface_free(surfaceToFree)
callbackContext?.release()
teeLease?.release()
}
}

/// Marks the resume side of agent hibernation and primes the next runtime
/// spawn's initial input.
@MainActor
public func prepareAgentHibernationResume(initialInput: String?) {
runtimeSurfaceSuspendedForAgentHibernation = false
prepareNextRuntimeInitialInput(initialInput)
}

/// Primes the initial input for the next runtime spawn only.
public func prepareNextRuntimeInitialInput(_ input: String?) {
let trimmedInput = input?.isEmpty == false ? input : nil
nextRuntimeInitialInput = trimmedInput
}

// Socket/API operations are an explicit runtime demand: they must be able to
// start a terminal in a background workspace without selecting that workspace.
// When there is no real window yet, bootstrap Ghostty in a hidden window and
// reconcile display/window state when the terminal is later presented.
//
// Isolation note: the legacy entry accepted off-main callers with a
// Thread.isMainThread check; every caller (Workspace, AppDelegate,
// TabManager, and the surface's own send paths) runs on the main actor, so
// the method is now @MainActor and the deferral hop uses a main-actor Task
// (same executor, same next-turn semantics as the legacy
// DispatchQueue.main.async).
@MainActor
public func requestBackgroundSurfaceStartIfNeeded() {
guard allowsRuntimeSurfaceCreation() else { return }
guard surface == nil else { return }
guard !backgroundSurfaceStartQueued else { return }
backgroundSurfaceStartQueued = true

Task { @MainActor [weak self] in
guard let self else { return }
self.backgroundSurfaceStartQueued = false
guard self.allowsRuntimeSurfaceCreation() else { return }
guard self.surface == nil else { return }
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
if let view = self.attachedView, view.window != nil {
self.createSurface(for: view)
} else {
self.scheduleHeadlessRuntimeStartIfNeeded(reason: "background-input")
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
let view = self.attachedView ?? self.surfaceView
logDebugEvent(
"surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))"
)
#endif
}
}

/// Attaches the model to its inner view, creating the runtime surface
/// when the view is in a window.
@MainActor
public func attachToView(_ view: any TerminalSurfaceNativeViewing) {
#if DEBUG
logDebugEvent(
"surface.attach surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view as NSView).toOpaque()) " +
"attached=\(attachedView != nil ? 1 : 0) hasSurface=\(surface != nil ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0)"
)
#endif

// If already attached to this view, nothing to do.
// Still re-assert the display id: during split close tree restructuring, the view can be
// removed/re-added (or briefly have window/screen nil) without recreating the surface.
// Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing
// or stale, the surface can appear visually frozen until a focus/visibility change.
// SwiftUI also re-enters this path for ordinary state propagation (drag hover, active
// markers, visibility flags), so avoid forcing a geometry refresh when the attachment
// itself is unchanged.
if attachedView === view && surface != nil {
releaseHeadlessStartupWindowIfNeeded(for: view)
#if DEBUG
logDebugEvent("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view as NSView).toOpaque())")
#endif
if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0,
let s = surface {
ghostty_surface_set_display_id(s, displayID)
}
return
}

if let attachedView, attachedView !== view {
#if DEBUG
logDebugEvent(
"surface.attach.skip surface=\(id.uuidString.prefix(5)) reason=alreadyAttachedToDifferentView " +
"current=\(Unmanaged.passUnretained(attachedView as NSView).toOpaque()) new=\(Unmanaged.passUnretained(view as NSView).toOpaque())"
)
#endif
return
}

attachedView = view
releaseHeadlessStartupWindowIfNeeded(for: view)

// Ordinary portal attachment can arrive before AppKit has put the view in
// a window. Defer those. Startup and cold-input paths install the owned
// view in a hidden bootstrap window first, then come through here.
if surface == nil {
guard allowsRuntimeSurfaceCreation() else {
#if DEBUG
logDebugEvent(
"surface.attach.skip surface=\(id.uuidString.prefix(5)) " +
"reason=lifecycle.\(portalLifecycleState.rawValue)"
)
#endif
return
}
guard view.window != nil else {
#if DEBUG
logDebugEvent(
"surface.attach.defer surface=\(id.uuidString.prefix(5)) reason=noWindow " +
"bounds=\(String(format: "%.1fx%.1f", Double(view.bounds.width), Double(view.bounds.height)))"
)
#endif
return
}
#if DEBUG
logDebugEvent(
"surface.attach.create surface=\(id.uuidString.prefix(5)) " +
"inWindow=\(view.window != nil ? 1 : 0)"
)
#endif
createSurface(for: view)
#if DEBUG
logDebugEvent("surface.attach.create.done surface=\(id.uuidString.prefix(5)) hasSurface=\(surface != nil ? 1 : 0)")
#endif
} else if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0,
let s = surface {
// Surface exists but we're (re)attaching after a view hierarchy move; ensure display id.
ghostty_surface_set_display_id(s, displayID)
#if DEBUG
logDebugEvent("surface.attach.displayId surface=\(id.uuidString.prefix(5)) display=\(displayID)")
#endif
}
}

@MainActor
private func claudeCommandShimStateForSurface(view: any TerminalSurfaceNativeViewing) -> (isReady: Bool, shim: ClaudeCommandShim?) {
guard let wrapperURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux-claude-wrapper") else {
claudeCommandShimInstallCompleted = true
return (true, nil)
}

if claudeCommandShimInstallCompleted {
return (true, claudeCommandShim)
}

if claudeCommandShimInstallTask == nil {
let surfaceId = id
// Explicit captures and arguments: the region-based isolation
// checker cannot analyze the legacy closure's implicit captures
// and in-closure default-argument evaluation (same effective body).
let temporaryDirectory = FileManager.default.temporaryDirectory
let installOperation: @Sendable () async -> ClaudeCommandShim? = { [wrapperURL, surfaceId, temporaryDirectory] in
TerminalSurface.installClaudeCommandShimIfPossible(
wrapperURL: wrapperURL,
surfaceId: surfaceId,
temporaryDirectory: temporaryDirectory,
fileManager: .default
)
}
let installTask = Task.detached(priority: .utility, operation: installOperation)
claudeCommandShimInstallTask = installTask
Task { @MainActor [weak self, weak view] in
let shim = await installTask.value
guard let self else { return }
self.claudeCommandShim = shim
self.claudeCommandShimInstallCompleted = true
self.claudeCommandShimInstallTask = nil
guard self.allowsRuntimeSurfaceCreation(), self.surface == nil else { return }
if let view, view.window != nil {
self.createSurface(for: view)
} else if let attachedView = self.attachedView, attachedView.window != nil {
self.createSurface(for: attachedView)
} else {
self.scheduleHeadlessRuntimeStartIfNeeded(reason: "claude-shim-ready")
}
}
}

return (false, nil)
}

@MainActor
func createSurface(for view: any TerminalSurfaceNativeViewing) {
guard allowsRuntimeSurfaceCreation() else {
#if DEBUG
logDebugEvent(
"surface.create.skip surface=\(id.uuidString.prefix(5)) " +
"reason=lifecycle.\(portalLifecycleState.rawValue)"
)
Self.surfaceLog(
"createSurface SKIPPED surface=\(id.uuidString) tab=\(tabId.uuidString) lifecycle=\(portalLifecycleState.rawValue)"
)
#endif
return
}
let claudeShimState = claudeCommandShimStateForSurface(view: view)
guard claudeShimState.isReady else { return }
let claudeShim = claudeShimState.shim
#if DEBUG
runtimeSurfaceCreateAttemptCountForTesting += 1
#endif
#if DEBUG
let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } ?? "(unset)"
let terminfo = getenv("TERMINFO").flatMap { String(cString: $0) } ?? "(unset)"
let xdg = getenv("XDG_DATA_DIRS").flatMap { String(cString: $0) } ?? "(unset)"
let manpath = getenv("MANPATH").flatMap { String(cString: $0) } ?? "(unset)"
Self.surfaceLog("createSurface start surface=\(id.uuidString) tab=\(tabId.uuidString) bounds=\(view.bounds) inWindow=\(view.window != nil) resources=\(resourcesDir) terminfo=\(terminfo) xdg=\(xdg) manpath=\(manpath)")
#endif

guard let app = engine.runtimeApp else {
#if DEBUG
logDebugEvent("ghostty.surface.create.failed reason=appNotInitialized surface=\(id.uuidString)")
#endif
#if DEBUG
Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty app not initialized")
#endif
return
}

let scaleFactors = scaleFactors(for: view)

var baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate()
var surfaceConfig = ghostty_surface_config_new()
surfaceConfig.font_size = baseConfig.fontSize
surfaceConfig.wait_after_command = baseConfig.waitAfterCommand
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
nsview: Unmanaged.passUnretained(view as NSView).toOpaque()
))
let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceHost: view, surfaceController: self))
surfaceConfig.userdata = callbackContext.toOpaque()
surfaceCallbackContext?.release()
surfaceCallbackContext = callbackContext
surfaceConfig.scale_factor = scaleFactors.layer
surfaceConfig.context = surfaceContext
#if DEBUG
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
logDebugEvent(
"zoom.create surface=\(id.uuidString.prefix(5)) context=\(GhosttySurfaceRuntimeProbe.contextName(surfaceContext)) " +
"templateFont=\(templateFontText)"
)
#endif
var envVars: [ghostty_env_var_s] = []
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
defer {
for (key, value) in envStorage {
free(key)
free(value)
}
}

var env = baseConfig.environmentVariables

var protectedStartupEnvironmentKeys: Set<String> = []
Self.applyManagedTerminalIdentityEnvironment(
to: &env,
protectedKeys: &protectedStartupEnvironmentKeys
)
func setManagedEnvironmentValue(_ key: String, _ value: String) {
env[key] = value
protectedStartupEnvironmentKeys.insert(key)
}

let socketPath = spawnPolicyProvider.controlSocketPath()
Self.applyManagedCmuxContextEnvironment(
Self.cmuxContextEnvironment(
workspaceId: tabId,
surfaceId: id,
socketPath: socketPath
),
to: &env,
protectedKeys: &protectedStartupEnvironmentKeys
)
setManagedEnvironmentValue("CMUX_SOCKET", "")
if let inheritedClaudeConfigDir = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"],
!inheritedClaudeConfigDir.isEmpty {
env["CLAUDE_CONFIG_DIR"] = ClaudeConfigDirectoryPath.preferredPath(inheritedClaudeConfigDir)
}
if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"),
FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) {
setManagedEnvironmentValue("CMUX_BUNDLED_CLI_PATH", bundledCLIURL.path)
}
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
setManagedEnvironmentValue("CMUX_BUNDLE_ID", bundleId)
}

// Port range for this workspace (base/range snapshotted once per app session)
do {
let startPort = sessionPortBase + portOrdinal * sessionPortRangeSize
setManagedEnvironmentValue("CMUX_PORT", String(startPort))
setManagedEnvironmentValue("CMUX_PORT_END", String(startPort + sessionPortRangeSize - 1))
setManagedEnvironmentValue("CMUX_PORT_RANGE", String(sessionPortRangeSize))
}

// One synchronous snapshot at the same point the legacy code read the
// individual settings stores.
let spawnPolicy = spawnPolicyProvider.currentSpawnPolicy()
let claudeHooksEnabled = spawnPolicy.claudeHooksEnabled
if !claudeHooksEnabled {
setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1")
}
if let customClaudePath = spawnPolicy.customClaudePath {
setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath)
}
setManagedEnvironmentValue(
spawnPolicy.subagentNotificationEnvironmentKey,
spawnPolicy.suppressSubagentNotifications ? "1" : "0"
)
if !spawnPolicy.cursorHooksEnabled {
setManagedEnvironmentValue("CMUX_CURSOR_HOOKS_DISABLED", "1")
}
if !spawnPolicy.geminiHooksEnabled {
setManagedEnvironmentValue("CMUX_GEMINI_HOOKS_DISABLED", "1")
}
if !spawnPolicy.kiroHooksEnabled {
setManagedEnvironmentValue("CMUX_KIRO_HOOKS_DISABLED", "1")
}
setManagedEnvironmentValue("CMUX_KIRO_NOTIFICATION_LEVEL", spawnPolicy.kiroNotificationLevel)
if !spawnPolicy.ampHooksEnabled {
setManagedEnvironmentValue("CMUX_AMP_HOOKS_DISABLED", "1")
}

if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
let currentPath = env["PATH"]
?? getenv("PATH").map { String(cString: $0) }
?? ProcessInfo.processInfo.environment["PATH"]
?? ""
if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) {
setManagedEnvironmentValue(
"PATH",
Self.pathByPrependingUniqueDirectory(cliBinPath, to: currentPath)
)
}
}

if let claudeShim {
setManagedEnvironmentValue("CMUX_CLAUDE_WRAPPER_SHIM", claudeShim.executablePath)
setManagedEnvironmentValue("CMUX_CLAUDE_WRAPPER_SHIM_ROOT", claudeShim.directoryPath)
let currentPath = env["PATH"]
?? getenv("PATH").map { String(cString: $0) }
?? ProcessInfo.processInfo.environment["PATH"]
?? ""
setManagedEnvironmentValue(
"PATH",
Self.pathByPrependingUniqueDirectory(claudeShim.directoryPath, to: currentPath)
)
}

// Shell integration: inject startup wrappers for supported shells; skipped when the bundled dir is missing (deleted app bundle), see shellIntegrationDirectoryExists.
if spawnPolicy.shellIntegrationEnabled,
let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path,
Self.shellIntegrationDirectoryExists(integrationDir) {
setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION", "1")
setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION_DIR", integrationDir)
Self.applyManagedGitWatchEnvironment(
watchGitStatusEnabled: spawnPolicy.watchGitStatusEnabled,
showPullRequestsEnabled: spawnPolicy.showPullRequestsEnabled,
to: &env,
protectedKeys: &protectedStartupEnvironmentKeys
)

let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil)
?? getenv("SHELL").map { String(cString: $0) }
?? ProcessInfo.processInfo.environment["SHELL"]
?? "/bin/zsh"
if let command = Self.applyManagedShellSpecificStartupEnvironment(
shell: shell,
integrationDir: integrationDir,
userGhosttyShellIntegrationMode: engine.userGhosttyShellIntegrationMode,
to: &env,
protectedKeys: &protectedStartupEnvironmentKeys
) {
if baseConfig.command?.isEmpty != false { baseConfig.command = command }
}
}
env = Self.mergedStartupEnvironment(
base: env,
protectedKeys: protectedStartupEnvironmentKeys,
additionalEnvironment: additionalEnvironment,
initialEnvironmentOverrides: initialEnvironmentOverrides
)
env["CMUX_SOCKET"] = ""

if !env.isEmpty {
envVars.reserveCapacity(env.count)
envStorage.reserveCapacity(env.count)
for (key, value) in env {
guard let keyPtr = strdup(key), let valuePtr = strdup(value) else { continue }
envStorage.append((keyPtr, valuePtr))
envVars.append(ghostty_env_var_s(key: keyPtr, value: valuePtr))
}
}

let createSurface = { [self] in
if !envVars.isEmpty {
let envVarsCount = envVars.count
envVars.withUnsafeMutableBufferPointer { buffer in
surfaceConfig.env_vars = buffer.baseAddress
surfaceConfig.env_var_count = envVarsCount
self.surface = ghostty_surface_new(app, &surfaceConfig)
}
} else {
self.surface = ghostty_surface_new(app, &surfaceConfig)
}
}

let resolvedWorkingDirectory: String? = {
if let workingDirectory, !workingDirectory.isEmpty {
return workingDirectory
}
return baseConfig.workingDirectory
}()
let resolvedCommand: String? = {
if let initialCommand, !initialCommand.isEmpty {
return initialCommand
}
return baseConfig.command
}()
let runtimeInitialInput = nextRuntimeInitialInput
let resolvedInitialInput: String? = {
if let runtimeInitialInput, !runtimeInitialInput.isEmpty {
return runtimeInitialInput
}
if let initialInput, !initialInput.isEmpty {
return initialInput
}
return baseConfig.initialInput
}()
func withOptionalCString<T>(_ value: String?, _ body: (UnsafePointer<CChar>?) -> T) -> T {
guard let value else {
return body(nil)
}
return value.withCString(body)
}

let createWithCommandAndWorkingDirectory = {
withOptionalCString(resolvedCommand) { cCommand in
surfaceConfig.command = cCommand
withOptionalCString(resolvedWorkingDirectory) { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
withOptionalCString(resolvedInitialInput) { cInitialInput in
surfaceConfig.initial_input = cInitialInput
createSurface()
}
}
}
}

createWithCommandAndWorkingDirectory()

if surface == nil {
surfaceCallbackContext?.release()
surfaceCallbackContext = nil
#if DEBUG
logDebugEvent("ghostty.surface.create.failed reason=surfaceNewNil surface=\(id.uuidString)")
#endif
#if DEBUG
Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty_surface_new returned nil")
if let cfg = engine.runtimeConfig {
let count = Int(ghostty_config_diagnostics_count(cfg))
Self.surfaceLog("createSurface diagnostics count=\(count)")
for i in 0..<count {
let diag = ghostty_config_get_diagnostic(cfg, UInt32(i))
let msg = diag.message.flatMap { String(cString: $0) } ?? "(null)"
Self.surfaceLog(" [\(i)] \(msg)")
}
} else {
Self.surfaceLog("createSurface diagnostics: config=nil")
}
#endif
return
}
guard let createdSurface = surface else { return }
registry.registerRuntimeSurface(createdSurface, ownerId: id)
// A freshly created runtime surface always owns a live (non-defunct)
// swap chain, so it is realized. Reset the flag in case this object's
// previous runtime surface had been released before being freed (e.g.
// agent-hibernation suspend/restore), which would otherwise let a later
// realizeRenderer() double-realize and trip Ghostty's defunct assert.
rendererRealized = true
recordRuntimeSurfaceCreation()
// Install the PTY tee so MobileTerminalByteTee receives every byte
// the read thread produces, in order, before the VT parser runs.
// Paired iPhones consume these bytes via `terminal.bytes` events
// and feed them into their own libghostty surface, guaranteeing
// grid parity by construction. The lease is released alongside
// `surfaceCallbackContext` when the surface tears down.
mobileByteTeeLease?.release()
mobileByteTeeLease = byteTee.installTee(on: createdSurface, surfaceID: id)
if runtimeInitialInput != nil {
nextRuntimeInitialInput = nil
}

// Session scrollback replay must be one-shot. Reusing it on a later runtime
// surface recreation would inject stale restored output into a live shell.
additionalEnvironment.removeValue(forKey: scrollbackReplayEnvironmentKey)

// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
// renderer can believe vsync is "running" but never deliver frames, which looks like a
// frozen terminal until focus/visibility changes force a synchronous draw.
//
// `view.window?.screen` can be transiently nil during early attachment; fall back to the
// primary screen so we always set *some* display ID, then update again on screen changes.
if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0 {
ghostty_surface_set_display_id(createdSurface, displayID)
}

ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y)
let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size
let wpx = pixelDimension(from: backingSize.width)
let hpx = pixelDimension(from: backingSize.height)
if wpx > 0, hpx > 0 {
ghostty_surface_set_size(createdSurface, wpx, hpx)
lastPixelWidth = wpx
lastPixelHeight = hpx
lastUncappedPixelWidth = wpx
lastUncappedPixelHeight = hpx
lastXScale = scaleFactors.x
lastYScale = scaleFactors.y
}

// Some GhosttyKit builds can drop inherited font_size during post-create
// config/scale reconciliation. If runtime points don't match the inherited
// template points, re-apply via binding action so all creation paths
// (new surface, split, new workspace) preserve zoom from the source terminal.
if let inheritedFontPoints = configTemplate?.fontSize,
inheritedFontPoints > 0 {
let currentFontPoints = GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints(createdSurface)
let shouldReapply = {
guard let currentFontPoints else { return true }
return abs(currentFontPoints - inheritedFontPoints) > 0.05
}()
if shouldReapply {
let action = String(format: "set_font_size:%.3f", inheritedFontPoints)
_ = performBindingAction(action)
}
}

// Re-apply the desired focus state after creation so the live runtime
// surface converges with any focus changes that happened while the
// surface was being initialized.
ghostty_surface_set_focus(createdSurface, desiredFocusState)

flushPendingSocketInputIfNeeded()

// Kick an initial draw after creation/size setup. On some startup paths Ghostty can
// miss the first vsync callback and sit on a blank frame until another focus/visibility
// transition nudges the renderer.
view.forceRefreshSurface()
ghostty_surface_refresh(createdSurface)

NotificationCenter.default.post(
name: .terminalSurfaceDidBecomeReady,
object: self,
userInfo: [
"surfaceId": id,
"workspaceId": tabId
]
)

#if DEBUG
let runtimeFontText = GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints(createdSurface).map {
String(format: "%.2f", $0)
} ?? "nil"
logDebugEvent(
"zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(GhosttySurfaceRuntimeProbe.contextName(surfaceContext)) " +
"runtimeFont=\(runtimeFontText)"
)
#endif
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Split this lifecycle extension before it grows further.

This new production file is already 900+ lines and now combines bootstrap-window management, liveness quarantine, teardown/hibernation, view attachment, Claude shim installation, and runtime/environment construction. That is past the repo’s hard review threshold for production Swift files; at minimum, createSurface/environment assembly and teardown/attachment should move into separate files. As per coding guidelines, "Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility."

🤖 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/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+RuntimeLifecycle.swift
around lines 13 - 919, The TerminalSurface+RuntimeLifecycle.swift extension file
exceeds 900 lines and violates the repository's coding guidelines by combining
multiple distinct responsibilities: bootstrap-window management, liveness
quarantine, teardown/hibernation, view attachment, Claude shim installation, and
runtime/environment construction. Split this file into separate, focused
extensions following the repository's guidance that Swift production files
should not exceed 800 lines. At minimum, move the createSurface method and all
related environment assembly logic (setManagedEnvironmentValue, environment
variable setup, command/working directory resolution) into one new extension
file (e.g., TerminalSurface+SurfaceCreation.swift), and move the
teardown-related methods (teardownSurface,
suspendRuntimeSurfaceForAgentHibernation, closeHeadlessStartupWindowIfNeeded)
and attachment logic (attachToView, attachedView management) into another
extension file (e.g., TerminalSurface+Teardown.swift or
TerminalSurface+Attachment.swift). Ensure each resulting file has a clear,
single primary responsibility and remains under 800 lines.

Source: Coding guidelines

Comment on lines +272 to +285
public func forceRefresh(reason: String = "unspecified") {
#if DEBUG
let hasSurface = surface != nil
let viewState: String
if let view = attachedView {
let inWindow = uiWindow != nil
let bounds = view.bounds
let metalOK = (view.layer as? CAMetalLayer) != nil
viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)"
} else {
viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)"
}
logDebugEvent("forceRefresh: \(id) reason=\(reason) \(viewState)")
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep forceRefresh free of debug formatting/logging.

This method is a per-keystroke hot path in this repo. The new DEBUG block adds string construction and a debug log call on every invocation, which is exactly the kind of work the typing-latency rule is trying to keep out of forceRefresh.

As per coding guidelines, TerminalSurface.forceRefresh() is called on every keystroke, so do not add allocations, file I/O, or formatting there.

Also applies to: 305-321

🤖 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/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+Sizing.swift
around lines 272 - 285, Remove the DEBUG block containing string construction
and the logDebugEvent call from the forceRefresh method because this method is a
per-keystroke hot path and adding string formatting and logging violates the
typing-latency coding guidelines. Delete the entire if DEBUG section from
forceRefresh and apply the same fix to any other similar debug logging blocks in
this file that have been added to hot-path methods.

Source: Coding guidelines

Comment on lines +312 to +315
if let displayID,
displayID != 0 {
ghostty_surface_set_display_id(currentSurface, displayID)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reapply the display ID to the reacquired surface.

The comment above already documents that view.forceRefreshSurface() can invalidate and recreate the runtime surface. In that case, ghostty_surface_set_display_id only updates currentSurface, while the surface reacquired before ghostty_surface_refresh never gets the topology fix.

Suggested fix
         guard let surface = liveSurfaceForGhosttyAccess(reason: refreshReason) else {
             return
         }
+        if let displayID,
+           displayID != 0 {
+            ghostty_surface_set_display_id(surface, displayID)
+        }
         ghostty_surface_refresh(surface)

Also applies to: 323-326

🤖 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/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface`+Sizing.swift
around lines 312 - 315, The display ID is being set on currentSurface after a
potential surface recreation via view.forceRefreshSurface(), but when the
surface is invalidated and recreated, the reacquired surface never receives the
display ID topology fix. Apply the display ID setting to the newly reacquired
surface immediately after it is obtained, before any subsequent operations like
ghostty_surface_refresh. This fix needs to be applied at both locations where
surfaces are reacquired in TerminalSurface+Sizing.swift (at lines 312-315 and
lines 323-326).

Comment on lines +207 to +220
private static func cmuxConfigPaths(
fileManager: FileManager = .default,
currentBundleIdentifier: String? = Bundle.main.bundleIdentifier
) -> [String] {
guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return []
}

return CmuxGhosttyConfigPathResolver.loadConfigURLs(
currentBundleIdentifier: currentBundleIdentifier,
appSupportDirectory: appSupport,
fileManager: fileManager
).map(\.path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the override-aware directory resolver for config-path discovery.

On Line 211, cmuxConfigPaths only reads FileManager.urls(...).first, which can skip CFFIXED_USER_HOME/fallback candidates already standardized by CmuxApplicationSupportDirectories.userDirectories(...). This can load the wrong effective config set in sandbox/test override scenarios.

🔧 Suggested fix
     private static func cmuxConfigPaths(
         fileManager: FileManager = .default,
-        currentBundleIdentifier: String? = Bundle.main.bundleIdentifier
+        currentBundleIdentifier: String? = Bundle.main.bundleIdentifier,
+        environment: [String: String] = ProcessInfo.processInfo.environment
     ) -> [String] {
-        guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
-            return []
-        }
-
-        return CmuxGhosttyConfigPathResolver.loadConfigURLs(
-            currentBundleIdentifier: currentBundleIdentifier,
-            appSupportDirectory: appSupport,
-            fileManager: fileManager
-        ).map(\.path)
+        var paths: [String] = []
+        var seen = Set<String>()
+        for appSupport in CmuxApplicationSupportDirectories.userDirectories(
+            environment: environment,
+            fileManager: fileManager
+        ) {
+            for url in CmuxGhosttyConfigPathResolver.loadConfigURLs(
+                currentBundleIdentifier: currentBundleIdentifier,
+                appSupportDirectory: appSupport,
+                fileManager: fileManager
+            ) where seen.insert(url.path).inserted {
+                paths.append(url.path)
+            }
+        }
+        return paths
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift`
around lines 207 - 220, The cmuxConfigPaths function currently uses
fileManager.urls(...).first which only retrieves a single application support
directory, ignoring CFFIXED_USER_HOME overrides and fallback candidates that are
standardized in CmuxApplicationSupportDirectories.userDirectories(...). Replace
the guard statement that retrieves appSupport using fileManager.urls with a call
to CmuxApplicationSupportDirectories.userDirectories(...), then update the
CmuxGhosttyConfigPathResolver.loadConfigURLs call to iterate through all
resolved directories instead of just a single appSupport directory, ensuring
proper handling of sandbox and test override scenarios.

Comment on lines +246 to +251
guard rawSidebarBackground != nil else {
if let opacity = sidebarTintOpacity {
UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity")
}
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale sidebar tint defaults when sidebar-background is removed.

On Line 246, the early-return branch preserves previously stored sidebarTintHex* keys. If a user deletes sidebar-background, stale tint values can continue to drive sidebar rendering.

🔧 Suggested fix
     public func applySidebarAppearanceToUserDefaults() {
         guard rawSidebarBackground != nil else {
+            let defaults = UserDefaults.standard
+            defaults.removeObject(forKey: "sidebarTintHexLight")
+            defaults.removeObject(forKey: "sidebarTintHexDark")
+            defaults.removeObject(forKey: "sidebarTintHex")
             if let opacity = sidebarTintOpacity {
-                UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity")
+                defaults.set(opacity, forKey: "sidebarTintOpacity")
+            } else {
+                defaults.removeObject(forKey: "sidebarTintOpacity")
             }
             return
         }

Also applies to: 255-269

🤖 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/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift`
around lines 246 - 251, When rawSidebarBackground is nil in the guard statement
at line 246-251 of GhosttyConfig.swift, stale sidebarTintHex* keys remain in
UserDefaults and continue to affect sidebar rendering. In the early-return
branch where rawSidebarBackground is nil, add code to remove the stale sidebar
tint defaults (the sidebarTintHex* keys) from UserDefaults in addition to the
existing sidebarTintOpacity handling. This same cleanup logic should also be
applied at the corresponding location mentioned around lines 255-269 to ensure
consistency across all cases where sidebar-background is removed or cleared.

Comment on lines +40 to +45
public func resetBackgroundBlur(windowNumber: Int) {
_ = cmuxCGSSetWindowBackgroundBlurRadius(
cmuxCGSDefaultConnectionForThread(),
UInt(windowNumber),
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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Swift 6, what happens when UInt(windowNumber)is called with a negativeInt value?

💡 Result:

In Swift 6, calling the initializer UInt(windowNumber) with a negative Int value will trigger a runtime error (a trap), causing your program to crash [1][2][3]. The standard init(_:) initializer for integer conversion requires that the value being converted is representable within the destination type [4]. Because UInt cannot represent negative numbers, passing a negative Int violates this requirement, resulting in a fatal runtime error [1][4][3]. If you need to handle potential negative values without crashing, consider using one of the following alternatives: 1. Use init?(exactly:): This failable initializer returns nil if the value cannot be represented in the destination type (e.g., if it is negative) [1][5]. let unsignedValue = UInt(exactly: windowNumber) // returns nil if windowNumber < 0 2. Use init(bitPattern:): If you specifically intend to reinterpret the underlying bits of the negative integer as an unsigned value (ignoring the numerical value), use this initializer [3]. This will not crash, but it will result in a large, positive UInt value based on the two's complement representation of the input [6][3]. let bitPatternValue = UInt(bitPattern: windowNumber) 3. Use init(clamping:): This initializer constrains the value to the destination type's representable range [1]. For a negative input converted to UInt, it will clamp the result to 0 [1]. let clampedValue = UInt(clamping: windowNumber) // returns 0 if windowNumber < 0

Citations:


🏁 Script executed:

cd /tmp && find . -type f -name "*.swift" 2>/dev/null | head -1 && cd -

Repository: manaflow-ai/cmux

Length of output: 79


🏁 Script executed:

# Locate the file being reviewed
fd "CompositorBlurController.swift" --type f

Repository: manaflow-ai/cmux

Length of output: 153


🏁 Script executed:

# Check the full CompositorBlurController to understand its public API surface
cat -n Packages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Blur/CompositorBlurController.swift

Repository: manaflow-ai/cmux

Length of output: 2507


🏁 Script executed:

# Search for resetBackgroundBlur callsites
rg "resetBackgroundBlur" --type swift

Repository: manaflow-ai/cmux

Length of output: 691


🏁 Script executed:

# Check how windowNumber is typically obtained in this codebase
rg "windowNumber" --type swift -B 2 -A 2 | head -40

Repository: manaflow-ai/cmux

Length of output: 2421


Guard negative window numbers before converting to UInt.

The public resetBackgroundBlur(windowNumber:) API accepts a raw Int, but UInt(windowNumber) crashes with a fatal runtime error on negative inputs. All current callsites safely pass NSWindow.windowNumber (which is positive for valid windows), but the API boundary should enforce this contract rather than rely on caller discipline. Either reject negatives with an early guard or tighten the signature to a nonnegative type.

Suggested fix
     public func resetBackgroundBlur(windowNumber: Int) {
+        guard windowNumber >= 0 else { return }
         _ = cmuxCGSSetWindowBackgroundBlurRadius(
             cmuxCGSDefaultConnectionForThread(),
             UInt(windowNumber),
             0
         )
     }
🤖 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/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Blur/CompositorBlurController.swift`
around lines 40 - 45, The resetBackgroundBlur(windowNumber:) function accepts an
Int parameter and directly converts it to UInt without validation, which will
cause a fatal runtime error if a negative value is passed. Add a guard statement
at the beginning of the function to verify that windowNumber is non-negative
(greater than or equal to 0), and either return early or throw an error if the
check fails. This ensures the API enforces its contract at the boundary rather
than relying on callers to only pass valid positive window numbers.

…ing fix

The MainActor.assumeIsolated wrapping added in the previous commit grew
GhosttyTerminalView.swift by 12 lines (12066 -> 12078). Refresh the checked-in
budget so the swift_file_length_budget guard passes; this is the sanctioned
cutover ratchet (no other tracked file changed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit and others added 3 commits June 13, 2026 19:29
…ackages

The re-sync merge incorrectly took main's Engine/Services GhosttyRuntimeTestStubs
(no quicklook_font stub) on the theory those packages don't reference the symbol.
They don't directly, but their test runners LINK CmuxTerminalCore, which now
calls `ghostty_surface_quicklook_font` from the lifted GhosttySurfaceRuntimeProbe
(6024's GhosttyConfig/probe move). Without the stub, `swift test` for
CmuxTerminalEngine and CmuxTerminalServices fails to link:
"Undefined symbols: _ghostty_surface_quicklook_font". This is the only symbol the
downstream test runners were missing; restoring the branch's stub (the CmuxTerminal
package and CmuxTerminalCore already carry it) makes all three `swift test` runs
pass the ci.yml tolerated-cosmetic-diagnostic gate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…013 ws-title)

Main advanced during re-sync. Re-merged: only the regenerable file-length budget
tsv conflicted (resolved by regeneration from the merged tree). GhosttyTerminalView
auto-merged cleanly with 6057's Metal-drawable fix and my isolation fixes; pbxproj
auto-merged (6013 workspace-title wiring) and parses (normalize+check+xcodebuild
-list green). All four prior fixes (3x MainActor.assumeIsolated, nonisolated(unsafe)
defaults, cmuxTests TerminalSurface import, Engine/Services quicklook stub) intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st file)

PR 6057 (Metal drawable fix) added cmuxTests/GhosttyDrawableSizeRetryTests.swift
on main while TerminalSurface was still an app-target type. After re-merging onto
6024 (TerminalSurface lifted to the CmuxTerminal package, no app-side typealias),
that new test file fails to compile: "cannot find 'TerminalSurface' in scope".
Hoisted `import CmuxTerminal` above the canImport block, same as the other
terminal test files. This is the recurring "main adds a TerminalSurface-using
test file" interaction; comprehensive re-sweep confirms this is the only one.

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

Main advanced with PR 5991. Re-merged: import-block conflicts in TabManager,
TerminalController, TabManagerUnitTests (unioned my CmuxTerminal import with
5991's CmuxWorkspaceCore/CmuxSidebar); budget tsv regenerated. pbxproj
auto-merged (5991's CmuxWorkspaceCore/CmuxSidebar packages) and parses
(normalize+check+xcodebuild -list green). cmuxTests import re-sweep clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@azooz2003-bit azooz2003-bit merged commit 55919de into main Jun 14, 2026
21 of 24 checks passed
azooz2003-bit added a commit that referenced this pull request Jun 14, 2026
…egrationSettingsStore

main's #6024 TerminalSurfaceRuntimeWiring still called the retired
*IntegrationSettings / AgentSubagentNotificationSettings static API that this
slice lifted onto CmuxSettings.AgentIntegrationSettingsStore. The cross-file
merge was silent (different files, no git conflict) and only the full app build
caught it. Rewrite the spawn-policy reads through the store with byte-identical
semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 14, 2026
…/CmuxRuntimeDebug/CmuxTestSupport (#5922)

* CmuxControlSocket stage 3c-1: ControlCommandCoordinator + window domain

Extract the window RPC domain (window.list/current/focus/create/close/displays/
display) out of TerminalController into a new @mainactor @observable
ControlCommandCoordinator in CmuxControlSocket, behind the read-only
ControlCommandContext seam (app target conforms; package never imports the app
target). The coordinator owns the kind:N ControlHandleRegistry (RPC selection
state per the decomposition plan); TerminalController delegates its ensureRef/
resolveRef/removeRef to it so refs stay consistent across moved and not-yet-
moved domains.

Faithful lift: the window bodies build ControlCallResult/JSONValue payloads
whose Foundation object is identical to the legacy [String: Any] dictionaries,
so the encoded wire bytes match. Dispatch runs on the main actor inside the
existing withSocketCommandPolicy scope, so the per-read v2MainSync hops the
legacy bodies used become plain in-isolation calls and disappear. window.current
preserves both distinct legacy errors (unavailable vs not_found) via
ControlCurrentWindowResolution.

TerminalController.swift 22074 -> 21921 (budget ratcheted). 17 new package
tests (128 total) drive every window method through a fake context, asserting
byte-identical payloads, ref minting, routing-selector parsing, and the two
window.current failures.

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

* stage 3c: multi-protocol seam umbrella + shared param/ref helper port

Restructure the seam into a per-domain protocol umbrella (ControlCommandContext:
ControlWindowContext, ...) so each domain can be built in its own files, and
port the shared TerminalControllerV2ParamParsingSupport pure helpers + ref
minting (workspaceRefs/tabRef/workspacePaneAndSurfaceRefs) into the coordinator
as JSONValue twins. Foundation for moving the remaining RPC domains.

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

* stage 3c: extract App Focus + Feed + Notification domains

Move the app-focus (app.focus_override.set, app.simulate_active), main-actor feed
(feed.jump, feed.list), and notification (create/create_for_surface/create_for_target/
list/dismiss/mark_read/open/jump_to_unread/clear) domains into the coordinator
behind their per-domain seams (ControlAppFocusContext/ControlFeedContext/
ControlNotificationContext), composed into the ControlCommandContext umbrella. The
core handle(_:) now chains per-domain handleX dispatchers.

Worker-lane methods stay app-side: feed.push/permission.reply/question.reply/
exit_plan.reply, and notification.create_for_caller (its own resolver).

Faithfulness: byte-identical payloads/errors (live socket sweep on ctl3c1 confirms
every result + error shape). Notification localized strings are resolved in the app
conformance (app bundle) and passed through ControlNotificationStrings, because
String(localized:) inside the package would bind to the package bundle and silently
drop the Japanese translations — a wire change for non-English locales.

Test fakes get benign defaults for non-window seams via ControlCommandContextTestStubs
so each fake implements only the domain it exercises (128 package tests still green).
TerminalController.swift 21952 -> 21522 (budget ratcheted).

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

* stage 3c: extract Mobile Host + Workspace Groups + Pane domains

Move workspace.group.* (17 methods), pane.* (9 methods), and mobile.host.status/
mobile.workspace.list/mobile.terminal.* (+terminal.* aliases) into the coordinator
behind ControlWorkspaceGroupContext/ControlPaneContext/ControlMobileHostContext,
composed into the umbrella; core handle(_:) chains the new handlers.

Workspace Groups + Pane are full lifts (bodies deleted, payloads rebuilt as JSONValue,
localized group strings routed app-side via ControlWorkspaceGroupStrings). Mobile Host
is a faithful pass-through: its 8 bodies are SHARED with the mobile data-plane
(mobileHostHandleRPC) so they stay in TerminalController (relaxed private->internal);
the coordinator decouples via the seam and the conformance bridges V2CallResult.

Pane folds the resize support helpers (kept app-side: Bonsplit-coupled); v2SurfaceMove
relaxed private->internal for pane.join forwarding. Live socket sweep on ctl3c1 confirms
faithful payloads + errors (group create/list, pane list/create split, mobile host status).

TerminalController.swift 21522 -> 20296. 128 package tests green. Two new Pane files
>500 lines get budget entries.

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

* stage 3c: fix int/double param helpers to match legacy NSNumber coercion

Regression found by the no-regression code review of the moved domains: the
ported int() did Int(value) on a JSON double, which TRAPS (crashes) on overflow/
NaN — reachable via pane.resize amount or workspace.group.move to_index with e.g.
1e30 — whereas legacy v2Int went through (params[key] as? NSNumber).intValue,
which clamps. Also int()/double() didn't coerce a JSON boolean to a number the
way the legacy as? NSNumber path did.

Both now route doubles/bools through NSNumber.intValue/.doubleValue, matching
v2Int/v2Double exactly (truncate-toward-zero, clamp out-of-range, bool->1/0).
5 regression tests cover truncation, overflow/NaN no-trap, and bool coercion.

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

* stage 3c: integrate Workspace + Surface domains (full lifts)

Workspace (21 methods incl. remote.*) and Surface (25 methods + debug.terminals)
move into ControlCommandCoordinator behind ControlWorkspaceContext/
ControlSurfaceContext. ~2640 lines deleted from TerminalController.swift
(20296 -> ~17650). Worker-lane workspace.remote.pty_* stay app-side.

Two shared bodies the drafting agents wrongly flagged for deletion were RESTORED
(internal/private): v2WorkspaceCreate(params:tabManager:) is still driven by the
mobile data-plane v2MobileWorkspaceCreate; workspaceCloseProtectedMessage() by the
v1 close path. surface.move + debug.terminals forward to the still-shared
v2SurfaceMove/v2DebugTerminals (relaxed internal), like pane.join. Relaxed to
internal for the conformances: tabManager, socketFastPathState, orderedPanels,
readTerminalTextRawSnapshot.

Live socket sweep on ctl3c1 confirms faithful payloads + errors across both
domains (workspace list/current/create/rename/select/next/close, surface list/
current/health/send_text+read_text round-trip/resume.get, error shapes).
133 package tests green.

KNOWN FOLLOW-UPS: workspace.create logic is duplicated (conformance reimplements +
restored shared body) — dedupe by forwarding; the 2 Workspace files >500 lines
(budget entries added) should be split; adversarial code-review verification of
these 2 domains still pending (8 prior domains verified clean).

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

* stage 3c: organize coordinator files into per-domain subfolders

Coordinator/ had grown to 105 files. Move each domain's coordinator extension,
seam protocol, and value/resolution/snapshot types into a per-domain subfolder
(Window/AppFocus/Feed/Notification/Pane/Surface/Workspace/WorkspaceGroup/
MobileHost). The 4 shared core files stay at the Coordinator/ root:
ControlCommandContext (umbrella), ControlCommandCoordinator (core dispatch +
handle registry), ControlCommandCoordinator+Params (shared param/ref helpers),
ControlRoutingSelectors. SwiftPM globs sources recursively, so this is purely
organizational — no Package.swift/import changes. Budget paths updated for the
moved Pane/Workspace coordinator files; TC.swift budget corrected to 17680
(the two restored shared bodies grew it after the last bump).

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

* stage 3c: dedupe workspace.create onto the shared v2WorkspaceCreate body

The Workspace lift had reimplemented workspace.create logic in the conformance
while the original v2WorkspaceCreate(params:tabManager:) was restored for the
mobile data-plane caller -- two copies that could diverge. Replace the typed
reimplementation with a passthrough that forwards to the single shared
v2WorkspaceCreate (relaxed private->internal) and bridges its Foundation result,
exactly like surface.move/debug.terminals/mobile. Deletes the now-unused
ControlWorkspaceCreateInputs/ControlWorkspaceCreateResolution. One source of
truth, byte-identical wire output.

Comprehensive socket sweep on ctl3c1 (all 10 domains, 38 ok + 13 expected
validation errors, zero crashes) confirms no regression: workspace.create happy
path + its cwd/layout validation errors preserved; pane.resize amount=1e30 now
clamps (invalid_state) instead of trapping (the int/double NSNumber fix). 133
package tests green.

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

* stage 3c: fix 8 divergences found by Workspace+Surface adversarial review

Surface (4): surface.clear_history with a present-but-invalid surface_id silently
cleared the FOCUSED surface instead of returning not_found (wrong-target side
effect; hasSurfaceIDParam now crosses the seam like send_text); surface.split
with an unrecognized direction returned unavailable instead of invalid_params
'Missing or invalid direction (left|right|up|down)' (coordinator now validates
the parseSplitDirection token set + a drift-safe .invalidDirection case);
surface.split error precedence restored (direction -> agent-session -> divider;
the agent-session token check moved before divider parsing); surface.resume.*
explicit target restored to surface_id ?? tab_id ONLY (terminal_id is a general
routing alias but was never a resume target) and the window branch now requires
a RESOLVABLE window_id like origin.

Workspace (4): select/close/rename get the routing precheck so unresolvable
routing returns unavailable before param validation (legacy TabManager-first
order, matching reorder); workspace.current with a stale selectedTabId returns
.ok with workspace:null again instead of not_found. Dead code removed
(JSONValue.isControlNull, surfaceIDForInput).

All confirmed by live socket sweep on the rebuilt ctl3c1 (each previously-wrong
response now byte-matches origin). 133 package tests green.

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

* Budget: entries for the two coordinator files grown by the divergence fixes

* CmuxSettings: align catalog hook/markdown defaults with runtime truth

The legacy cmuxApp settings namespaces (the runtime read path) default
claudeCodeHooksEnabled, cursorHooksEnabled, geminiHooksEnabled,
suppressSubagentNotifications, and openMarkdownInCmuxViewer to true; the
catalog said false for all five, so a never-touched Settings toggle
displayed OFF while the behavior was actually ON. Catalog defaults now
match the runtime defaults ahead of the settings-namespace convergence
that makes the catalog the single source of truth.

Who could observe it: only users who never stored these keys, and only in
the Settings UI display / DefaultsValueModel seed (toggles now correctly
show ON) and JSON-schema defaults. Runtime behavior is unchanged; any
stored override is unaffected.

Net: +13/-8 lines, no new tests (covered by the store tests landing next).

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

* CmuxSettings: add Wave-2 domain settings repositories + synchronous key access

Adds the cmuxApp settings-namespace replacements to the exemplar package:

- DefaultsKey.value(in:)/set(_:in:)/removeValue(in:)/hasStoredValue(in:):
  the synchronous read/write seam for non-async call paths.
  UserDefaultsSettingsStore.value/set now forward here, so there is one
  decode/encode path for actor, sync, and @LiveSetting reads.
- Domain repositories (Sendable structs over injected UserDefaults; no
  actors because every reader is synchronous, the types hold no mutable
  state, and UserDefaults is documented thread-safe):
  CloseTabWarningStore, CommandPaletteSettingsStore,
  AgentIntegrationSettingsStore, FileRouteSettingsStore,
  PreferredEditorSettingsStore, QuitConfirmationStore (verbatim legacy
  two-key fallback chain), AppIconSettingsStore, LanguageSettingsStore.
- Read seams: CloseTabWarningReading, CommandPaletteSettingsReading,
  AgentIntegrationSettingsReading, FileRouteSettingsReading,
  PreferredEditorReading.
- KiroNotificationLevel value enum (stored as raw String, shared wire
  format with the cmux CLI).

Tests: +27 behavior tests (DomainSettingsStoreTests) pinning legacy
defaults, legacy-key fallback chains, trim rules, markdown extension
matrix, file-route filesystem probes, and did-change notification posts.
Package suite: 103 tests green from a clean .build.

Net: +1018/-7 lines (package only; app convergence follows).

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

* CmuxTestSupport: new Wave-2 package lifting CmuxUITestCapture

UITestCaptureSink is a faithful lift of the legacy CmuxUITestCapture
namespace-enum (append-line + JSON-mutate capture writes, env-gated so
production short-circuits before any I/O). Differences from legacy, by
design:

- TestCaptureWriting protocol seam so call sites depend on the
  capability, not the concrete sink.
- environment injected at init (tests pass a fixture dictionary);
  ProcessInfo.environment is immutable per process, so per-call vs
  at-init reads are equivalent.
- stateless Sendable struct, not a namespace enum (conventions ban
  caseless enums of statics).

Write bodies are byte-identical to the legacy implementation. Tests: 5
behavior tests (no-op gating, parent-dir creation, append ordering,
JSON merge + sorted-keys format, unparsable-file replacement), green
from a clean .build.

Net: +241 lines (package only; app convergence follows).

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

* CmuxSettings: close-tab and quit confirmation policies onto the Wave-2 stores

Moves the two pure settings-derived policies out of the legacy cmuxApp
namespaces and onto the package types that own their inputs:

- CloseTabWarningReading.shouldConfirmClose(requiresConfirmation:source:)
  + CloseTabCloseSource value enum, verbatim from
  CloseTabConfirmationPolicy (shortcut warns only for
  confirmation-requiring tabs; X button additionally warns whenever its
  own toggle is on). The Decision enum is folded into the Bool result;
  every legacy caller only consumed shouldConfirm.
- QuitConfirmationStore.shouldShowConfirmation(isQuitWarningConfirmed:
  hasDirtyWorkspaces:isDevBuild:), verbatim from QuitWarningSettings
  (prior confirmation wins, dev builds never warn, dirtyOnly consults
  the dirty flag). BuildFlavor stays app-side; callers pass isDevBuild.

Tests: +9 policy tests pinning both matrices (fixed fakes plus the live
store path). Package suite: 117 tests green.

App-side convergence and deletion of the legacy namespaces follows in
the cutover tranche.

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

* CmuxFileOpen: new Wave-2 package lifting PreferredEditorService

Faithful lift of cmuxApp.swift's `PreferredEditorSettings.open` launch path
into a new `CmuxFileOpen` Wave-2 package, behind the `FileOpening` capability
seam (plan section 2/3, p03). The package depends only on earlier Wave-2
packages (CmuxSettings, CmuxTestSupport) and never names cmuxApp.

Lifted behavior, kept byte-faithful to the legacy namespace:
- UI-test capture interception via CMUX_UI_TEST_CAPTURE_OPEN_PATH (delegated
  to CmuxTestSupport's TestCaptureWriting seam, exactly the legacy gate).
- No configured editor command -> system default open (NSWorkspace).
- Otherwise /bin/sh -c "<command> '<path>'" with silenced stdio; launch
  failure or nonzero exit (e.g. command-not-found exiting 127) falls back to
  the system default open.
- POSIX single-quote splice (String.posixShellSingleQuoted) is byte-identical
  to the legacy shellQuote ('\'' escaping).

Two deliberate, observer-analyzed concurrency deltas (per faithful-lift
discipline, documented in the type's isolation essay):
- exit observation moves from DispatchQueue.global + waitUntilExit to
  Process.terminationHandler (plan section 8 modernization); the @sendable
  handler hops back to @mainactor for the fallback open. Observable delta:
  the fallback open is dispatched one main-actor hop later, matching the
  legacy DispatchQueue.main.async fallback hop. Spawn timing is identical
  (the service is @mainactor and spawns synchronously on the calling thread,
  like the legacy static func).

Collaborators are constructor-injected behind protocols (PreferredEditorReading,
TestCaptureWriting, SystemFileOpening) so tests pass recording fakes; the
production init(defaults:) wires PreferredEditorSettingsStore + UITestCaptureSink
+ NSWorkspaceFileOpener. No static let shared, no free functions.

The legacy PreferredEditorSettings namespace stays in cmuxApp.swift for now;
the app-target cutover (caller rewiring + namespace deletion) is a separate
cmuxApp.swift-touching commit to keep this lift machine-diffable.

Package suite: 4 tests green (capture interception, no-command fallback,
quoted-argument fidelity incl. spaces + embedded single quote, failing-command
fallback).

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

* cmuxApp services cutover: wire CmuxFileOpen + CmuxTestSupport into the app, retire the in-file PreferredEditorSettings and CmuxUITestCapture namespaces

App-target cutover for the two Wave-2 service packages the prior leg built but
left unconnected. CmuxFileOpen and CmuxTestSupport are now linked into the cmux
app target (pbxproj wiring), every call site rewires to the package types, and
the two legacy namespace enums are deleted from cmuxApp.swift.

Mechanism (faithful lift; no behavior change):
- PreferredEditorSettings.open(_:) -> PreferredEditorService(defaults: .standard).open(_:)
  at all 6 launch sites (ContentView, GhosttyTerminalView x2, HostSettingsActions,
  ConfigSettingsView, SidebarWorkspaceGroupConfigOpener, openCmuxSettingsFileInEditor).
  The service is @mainactor (every caller is a main-thread UI action); the one
  free-function caller openCmuxSettingsFileInEditor() and the
  SidebarWorkspaceGroupConfigOpener entry point are marked @mainactor to match.
- CmuxUITestCapture.{appendLineIfConfigured,mutateJSONObjectIfConfigured} ->
  UITestCaptureSink().<same> at all 15 capture sites across 7 files.
- PreferredEditorSettings.key (KeyboardShortcutSettingsFileStore) ->
  AppCatalogSection().preferredEditor.userDefaultsKey (same "preferredEditorCommand"
  UserDefaults key; wire format frozen).
- Deleted the redundant app-side PreferredEditorSettingsTests.swift: its subject
  (resolvedCommand) now lives in PreferredEditorSettingsStore, already covered by
  the package's DomainSettingsStoreTests; added the one missing plain-set case
  there so coverage is not weakened.

Adversarial faithfulness check: the three moved capture helper bodies
(appendLine/mutateJSONObject/ensureParentDirectory) machine-diff byte-identical
to the deleted cmuxApp.swift bodies (normalizing static->instance, env->environment);
posixShellSingleQuoted is byte-identical to the legacy shellQuote. The one
documented delta is the prior leg's PreferredEditorService modernization
(terminationHandler vs DispatchQueue.global+waitUntilExit; fallback one main-hop
later), already noted in the service DocC and PR body.

cmuxApp.swift 5462 -> 5339 lines (-123). Gates: pbxproj normalize+check+test-wiring
clean, ID-collision grep clean, file-length budget refreshed (cmuxApp shrink +
4 import-line growths), ios-package-conventions lint exit 0, CmuxSettings +
CmuxFileOpen + CmuxTestSupport package builds+tests green, app xcodebuild
** BUILD SUCCEEDED **.

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

* cmuxApp settings cutover: retire 13 in-file settings-namespace enums onto Wave-2 CmuxSettings stores

App-target cutover that finishes wiring the Wave-2 settings repositories the prior
legs built but left the legacy static-namespace enums shadowing in cmuxApp.swift.
Five domains, thirteen enums, all deleted; every call site rewired to the existing
CmuxSettings package stores + catalog keys. Faithful lift, no behavior change.

Domains cut over (enum -> store/seam):
- Close-tab: CloseTabWarningSettings + CloseTabConfirmationPolicy ->
  CloseTabWarningStore + CloseTabWarningReading.shouldConfirmClose(...source: CloseTabCloseSource).
- Command palette: CommandPaletteRenameSelectionSettings +
  CommandPaletteSwitcherSearchSettings -> CommandPaletteSettingsStore.
- File route: CmdClickMarkdownRouteSettings + CmdClickSupportedFileRouteSettings ->
  FileRouteSettingsStore (notify*/shouldRoute*, static .markdownRouteDidChange /
  .supportedFileRouteDidChange Notification.Name).
- Agent integration: ClaudeCodeIntegrationSettings, CursorIntegrationSettings,
  GeminiIntegrationSettings, KiroIntegrationSettings, AmpIntegrationSettings,
  AgentSubagentNotificationSettings -> AgentIntegrationSettingsStore (folds all six;
  KiroIntegrationSettings.NotificationLevel -> KiroNotificationLevel package enum;
  env key -> AgentIntegrationSettingsStore.subagentSuppressionEnvironmentKey).
- Quit warning: QuitWarningSettings + QuitConfirmationMode -> QuitConfirmationStore +
  ConfirmQuitMode (shouldShowConfirmation buildFlavor param -> isDevBuild:
  buildFlavor == .dev; legacy two-key confirmQuit/warnBeforeQuitShortcut chain
  preserved by the store).

Mechanism (byte-identical wire format; settings migrations frozen):
- Reads -> store read properties (stateless Sendable structs constructed inline,
  same idiom as the landed PreferredEditorService cutover).
- Raw UserDefaults key/default refs (@AppStorage, template, managedUserDefaults,
  palette toggle descriptors) -> AppCatalogSection()/IntegrationsCatalogSection()
  DefaultsKey.userDefaultsKey / .defaultValue. Every key string + default verified
  byte-identical to the deleted enum constants.
- Deleted QuitConfirmationMode.localizedSettingsTitle and
  KiroIntegrationSettings.NotificationLevel.title were dead: the Settings UI (already
  migrated to CmuxSettingsUI AppSection/AutomationSection) owns its own localized
  picker labels under the same xcstrings keys; no localization regression.

Tests retargeted faithfully (no assertions weakened): 8 cmuxTests files repointed to
the package types + catalog keys; shouldShowConfirmation buildFlavor args translated
to isDevBuild, defaults moved onto store init.

Adversarial faithfulness: each deleted enum's read logic machine-equivalent to its
store (object(forKey:)==nil ? default : typed read == DefaultsKey.value(in:);
QuitConfirmationStore reproduces the legacy confirmQuit/warnBeforeQuit fallback chain
verbatim per its DocC). Keys/defaults byte-compared against AppCatalogSection /
IntegrationsCatalogSection.

cmuxApp.swift 5340 -> 4972 lines (-368). Gates: CmuxSettings swift build + 118 tests
green; ios-package-conventions lint exit 0 (namespace-enum count reduced);
file-length budget refreshed (cmuxApp shrink + import-line growths); app xcodebuild
-derivedDataPath /tmp/cmux-capp3 build SUCCEEDED. No new app source files (CmuxSettings
already linked), so no pbxproj change. ghostty pointer unchanged.

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

* cmuxApp settings cutover: fix test-target type ambiguity from CmuxSettings import

The unit-test bundle `@testable import cmux_DEV`, so app-target types become
external symbols; a blanket `import CmuxSettings` in a test file that also uses
the app target's own AppIconMode/StoredShortcut/ShortcutContext/etc. (15 names are
defined in BOTH modules, those refactor targets not yet drained) makes every
unqualified use ambiguous. The app `build` does not surface this (source files
resolve same-module types), so it only failed in test-depot's "Run unit tests"
compile (WorkspaceUnitTests + siblings: "'StoredShortcut' is ambiguous",
"ambiguous use of 'automatic'").

Fix: in the four test files that touch the colliding names, replace the blanket
`import CmuxSettings` with selective per-symbol imports of exactly the settings
types the file needs (verified to cover every used symbol):
- WorkspaceUnitTests: IntegrationsCatalogSection, KiroNotificationLevel
- KeyboardShortcutSettingsFileStoreStartupTests: AppCatalogSection,
  QuitConfirmationStore, ConfirmQuitMode
- ShortcutAndCommandPaletteTests: AppCatalogSection, QuitConfirmationStore,
  CommandPaletteSettingsStore, ConfirmQuitMode
- WindowAndDragTests: AppCatalogSection, FileRouteSettingsStore
The three collision-free test files keep the blanket import. No assertion changed;
this is import-scope only. Budget refreshed for the import-comment growth; lint 0.

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

* cmuxApp settings cutover: retire WelcomeSettings + collapse TelemetrySettings onto CmuxSettings catalog

Finishes the in-flight settings tranche the prior leg left uncommitted in
cmuxApp.swift. Two more in-file settings-namespace enums drained:

- WelcomeSettings (shownKey "cmuxWelcomeShown") -> AccountCatalogSection().welcomeShown.
  Every call site rewired: AppDelegate.sendWelcomeCommandWhenReady, TabManager
  auto-welcome guard + sendWelcomeWhenReady, and the WindowAndDrag /
  WorkspaceStressProfile tests now key off
  AccountCatalogSection().welcomeShown.userDefaultsKey.
- TelemetrySettings reduced to the launch-frozen `enabledForCurrentLaunch` anchor.
  The persisted key, default, and read logic now live solely in
  AppCatalogSection().sendAnonymousTelemetry; the anchor freezes
  `.value(in: .standard)` once at process start (same restart-to-apply semantics
  as the old `isEnabled()` freeze). Call sites rewired: CommandPalette telemetry
  toggle descriptor, KeyboardShortcutSettings template default + managed-defaults
  store key, and the two GhosttyConfig telemetry tests (retargeted to
  `.value(in:)` / `.userDefaultsKey`, same assertions).

Mechanism (byte-identical wire format; settings migrations frozen):
- "cmuxWelcomeShown" and "sendAnonymousTelemetry" key strings + the `true`
  telemetry default verified byte-identical to the deleted enum constants.
- Reads -> DefaultsKey.value(in:) (same SettingCodable decode as the old bool
  read, absent-key -> defaultValue). Raw key/default refs (palette descriptor,
  template, managedUserDefaults, test fixtures) -> catalog
  .userDefaultsKey / .defaultValue.
- Tests use selective `import struct CmuxSettings.AccountCatalogSection` to avoid
  the app-target type-name ambiguity (same idiom as WindowAndDragTests).

cmuxApp.swift 4972 -> 4962. Faithful lift, no behavior change. App target
compiles clean (bounded xcodebuild Debug build SUCCEEDED). Budget refreshed.

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

* cmuxApp settings cutover: retire AppLanguage + LanguageSettings onto CmuxSettings

Retire the app-side `enum AppLanguage` and `enum LanguageSettings` from
cmuxApp.swift and route every consumer through the Wave-1/2 CmuxSettings
`AppLanguage` value type + `LanguageSettingsStore` repository.

Faithful lift, wire format frozen:
- `LanguageSettings.languageKey` ("appLanguage") ->
  `AppCatalogSection().language.userDefaultsKey` (identical "appLanguage").
- `LanguageSettings.defaultLanguage` (.system) ->
  `AppCatalogSection().language.defaultValue` (.system).
- `LanguageSettings.apply(_:)` ->
  `LanguageSettingsStore(defaults: .standard).applyLanguageOverride(_:)`
  (byte-identical AppleLanguages override body).
- `LanguageSettings.languageAtLaunch` ->
  `LanguageSettingsStore(defaults: .standard).storedLanguage`
  (same key, same .system default + unrecognized-value fallback).

`AppLanguage(rawValue:)` at the two KeyboardShortcutSettingsFileStore call
sites now binds to `CmuxSettings.AppLanguage`, a strict superset (adds `vi`)
that accepts every legacy raw value identically.

No UI regression: the settings picker's display names and legacy 19-case
ordering already live in CmuxSettingsUI/AppSection.swift
(`legacyLanguageCases` + `languageDisplayName`); there were no other app-side
`displayName`/`Identifiable` consumers.

cmuxApp.swift 4962 -> 4896 lines.

Gates: file-length budget respected (cmuxApp.swift ratcheted down), package
convention lint clean, CmuxSettings swift build + 118 tests pass, app target
xcodebuild BUILD SUCCEEDED.

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

* Re-sync fix: retarget TerminalSurfaceRuntimeWiring to lifted AgentIntegrationSettingsStore

main's #6024 TerminalSurfaceRuntimeWiring still called the retired
*IntegrationSettings / AgentSubagentNotificationSettings static API that this
slice lifted onto CmuxSettings.AgentIntegrationSettingsStore. The cross-file
merge was silent (different files, no git conflict) and only the full app build
caught it. Rewrite the spawn-policy reads through the store with byte-identical
semantics.

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

* Re-sync fix: drop dangling PreferredEditorSettingsTests.swift pbxproj refs

This slice deleted the app-target cmuxTests/PreferredEditorSettingsTests.swift
in its cutover (PreferredEditor moved to CmuxFileOpen with package tests). The
pbxproj merge unioned in main's references to that now-absent file, so the
cmux-unit CI build failed with "Build input file cannot be found:
PreferredEditorSettingsTests.swift". Remove the three pbxproj entries
(PBXBuildFile, PBXFileReference, Sources phase) to match this slice's pre-merge
state. Validated: normalize/check/test-wiring/xcodebuild -list all pass.

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

* Re-sync fix: complete CmuxSettings imports in retargeted cmuxTests

The re-sync's import-block resolution dropped CmuxSettings symbols that main's
merged test bodies reference: ShortcutAndCommandPaletteTests needs SettingCatalog
and UserDefaultsSettingsClient (added as selective imports, file pins
StoredShortcut to the app type); TabManagerUnitTests needs CloseTabWarningStore
and others, has no app-dup-type pins, so restore the blanket `import CmuxSettings`
(its pre-merge state). Audited every CmuxSettings public type against each
selectively-imported test file to confirm completeness. Budget refreshed for the
two added import lines.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant