GhosttyTerminalView decomposition: terminal surface model (stack D)#6024
Conversation
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>
…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>
… 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>
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>
…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
…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>
There was a problem hiding this comment.
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 winAdd
CmuxTerminalandCmuxWorkspaceWindowtocmuxtarget package dependencies.Lines 1700 and 1710 add these products to the Frameworks phase, but
PBXNativeTarget "cmux"packageProductDependencies(around Line 2637) does not includeC750500000000000000000A2orC750400000000000000000D2. 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
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (133)
CLI/CMUXCLI+Config.swiftCLI/CMUXCLI+ThemeSupport.swiftCLI/CMUXCLI+Themes.swiftPackages/CmuxFoundation/Sources/CmuxFoundation/Color/NSColor+Hex.swiftPackages/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxApplicationSupportDirectories.swiftPackages/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigPathResolver.swiftPackages/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigSettingEditor.swiftPackages/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/GhosttyBackgroundBlur.swiftPackages/CmuxTerminal/Package.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Events/TerminalSurfaceNotifications.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceViewProviding.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Runtime/AgentHibernationRecording.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalEngineHosting.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererRealizationScheduling.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicy.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicyProviding.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/NSScreen+DisplayID.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+CopyMode.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PortalLease.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swiftPackages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swiftPackages/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swiftPackages/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.cPackages/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.hPackages/CmuxTerminalCore/Package.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/ColorScheme/TerminalColorSchemePreference.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/ColorScheme/TerminalStartupAppearancePreviewOverride.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/ColorScheme/TerminalSystemAppearance.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRuntimeProbe.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceClaudeCommandShim.swiftPackages/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCmuxContextEnvironment.swiftPackages/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SidebarFontSizeConfigTests.swiftPackages/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceTabBarFontSizeConfigTests.swiftPackages/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalSystemAppearanceTests.swiftPackages/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.cPackages/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.hPackages/CmuxWorkspaceWindow/Package.swiftPackages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Blur/CompositorBlurController.swiftPackages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Policy/WindowBackgroundPolicy.swiftPackages/CmuxWorkspaceWindow/Sources/CmuxWorkspaceWindow/Settings/WindowBackgroundSettingsReading.swiftPackages/CmuxWorkspaceWindow/Tests/CmuxWorkspaceWindowTests/WindowBackgroundPolicyTests.swiftSources/App/RendererRealizationController.swiftSources/App/ShortcutRoutingSupport.swiftSources/App/TerminalFindEscapeRouting.swiftSources/AppDelegate.swiftSources/AppearanceSettings.swiftSources/BackgroundWorkspacePrimeCoordinator.swiftSources/CmuxApplicationSupportDirectories.swiftSources/ContentView.swiftSources/DockPanelView.swiftSources/DragOverlayRoutingPolicy.swiftSources/Feed/FeedButtonStyleDebugWindowController.swiftSources/Find/SurfaceSearchOverlay.swiftSources/GhosttyConfig.swiftSources/GhosttyTerminalAppearance.swiftSources/GhosttyTerminalView.swiftSources/GhosttyTerminalViewSupport.swiftSources/HostSettingsActions.swiftSources/MainWindowFocusController.swiftSources/Mobile/MobileTerminalByteTee.swiftSources/Panels/AgentSessionWebTheme.swiftSources/Panels/BrowserPanel.swiftSources/Panels/MarkdownPanelView.swiftSources/Panels/MarkdownWebSupport.swiftSources/Panels/TerminalPanel.swiftSources/Panels/TerminalPanelView.swiftSources/Settings/ConfigSource.swiftSources/Sidebar/SidebarAppearanceSupport.swiftSources/SidebarWorkspaceGroupHeaderView.swiftSources/TabManager.swiftSources/TerminalController+ControlDebugContext.swiftSources/TerminalController+ControlSidebarContext3.swiftSources/TerminalController.swiftSources/TerminalImageTransfer.swiftSources/TerminalPaneDropTargetView.swiftSources/TerminalSurfaceClaudeCommandShim.swiftSources/TerminalSurfaceCmuxContextEnvironment.swiftSources/TerminalSurfaceRuntimeWiring.swiftSources/TerminalViewportUITestRecorder.swiftSources/TerminalWindowPortal.swiftSources/TextBoxInput.swiftSources/Windowing/WindowAppearanceSnapshot.swiftSources/Windowing/WindowBackdropController.swiftSources/Windowing/WindowBackgroundComposition.swiftSources/Windowing/WindowGlassEffect.swiftSources/Workspace.swiftSources/WorkspaceAppearanceResolution.swiftSources/WorkspaceContentView.swiftSources/WorkspaceSurfaceConfig.swiftSources/cmuxApp.swiftcmux.xcodeproj/project.pbxprojcmuxTests/AgentHibernationTests.swiftcmuxTests/AppDelegateMoveTabToNewWorkspaceTests.swiftcmuxTests/AppDelegateShortcutRoutingTests.swiftcmuxTests/CJKIMEInputTests.swiftcmuxTests/CJKIMEMarkedSelectionTests.swiftcmuxTests/FinderFileDropRegressionTests.swiftcmuxTests/FishShellIntegrationTests.swiftcmuxTests/GhosttyCommandShiftForwardingTests.swiftcmuxTests/GhosttyConfigPathResolverTests.swiftcmuxTests/GhosttyConfigTests.swiftcmuxTests/GhosttyNotificationDispatcherTests.swiftcmuxTests/GhosttyTerminalStartupEnvironmentTests.swiftcmuxTests/SessionPersistenceTests.swiftcmuxTests/ShellStartupMatrixTests.swiftcmuxTests/ShellStartupMissingBundleTests.swiftcmuxTests/SidebarWidthPolicyTests.swiftcmuxTests/TabManagerSessionSnapshotTests.swiftcmuxTests/TabManagerUnitTests.swiftcmuxTests/TerminalAndGhosttyTests.swiftcmuxTests/TerminalControllerSocketSecurityTests.swiftcmuxTests/TerminalSearchOverlayMouseReleaseTests.swiftcmuxTests/TraditionalChineseIMENumpadRegressionTests.swiftcmuxTests/WindowAppearanceSnapshotTests.swiftcmuxTests/WorkspaceAppearanceConfigResolutionTests.swiftcmuxTests/WorkspaceCloseTabsContextMenuTests.swiftcmuxTests/WorkspaceRemoteConnectionTests.swiftcmuxTests/WorkspaceSplitStartupCommandTests.swiftcmuxTests/WorkspaceUnitTests.swift
💤 Files with no reviewable changes (3)
- Sources/TerminalSurfaceCmuxContextEnvironment.swift
- Sources/TerminalSurfaceClaudeCommandShim.swift
- Sources/CmuxApplicationSupportDirectories.swift
| 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 | ||
| } |
There was a problem hiding this comment.
🧩 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:
- 1: https://developer.apple.com/documentation/foundation/scanner/scanhexint64(_:)
- 2: https://sosumi.ai/documentation/foundation/scanner/scanhexint64(_:)
- 3: https://developer.apple.com/documentation/foundation/scanner
- 4: https://www.kodeco.com/1039-scanner-tutorial-for-macos/page/2
🏁 Script executed:
find . -name "NSColor+Hex.swift" -type fRepository: 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:
-
Line 8:
replacingOccurrences(of: "#", with: "")removes all#characters globally, not just a leading prefix. Input like"1#23456"normalizes to"123456"and is accepted. -
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.
| private func nextRequestForWorker() -> TerminalSurfaceRuntimeTeardownRequest? { | ||
| guard !queuedRequests.isEmpty else { | ||
| isWorkerRunning = false | ||
| return nil | ||
| } | ||
| return queuedRequests.removeFirst() | ||
| } |
There was a problem hiding this comment.
🧩 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.swiftRepository: manaflow-ai/cmux
Length of output: 785
🏁 Script executed:
cat .github/review-bot-rules/algorithmic-complexity.mdRepository: manaflow-ai/cmux
Length of output: 2617
🏁 Script executed:
# Check the overall context of TerminalSurfaceRuntimeTeardownCoordinator
wc -l Packages/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swiftRepository: 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.swiftRepository: 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
| if let v = deviceDescription[key] as? Int { return UInt32(v) } | ||
| if let v = deviceDescription[key] as? NSNumber { return v.uint32Value } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ 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
| 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 |
There was a problem hiding this comment.
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
| if let displayID, | ||
| displayID != 0 { | ||
| ghostty_surface_set_display_id(currentSurface, displayID) | ||
| } |
There was a problem hiding this comment.
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).
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| guard rawSidebarBackground != nil else { | ||
| if let opacity = sidebarTintOpacity { | ||
| UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity") | ||
| } | ||
| return | ||
| } |
There was a problem hiding this comment.
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.
| public func resetBackgroundBlur(windowNumber: Int) { | ||
| _ = cmuxCGSSetWindowBackgroundBlurRadius( | ||
| cmuxCGSDefaultConnectionForThread(), | ||
| UInt(windowNumber), | ||
| 0 | ||
| ) |
There was a problem hiding this comment.
🧩 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:
- 1: https://srk1nn.medium.com/crash-during-int-to-uint-converting-swift-cb23f73b6bd8
- 2: https://forums.swift.org/t/prevent-using-integer-initializers-that-can-generate-runtime-errors/75467
- 3: https://docs.syntblaze.com/swift/numeric-types/uint
- 4: https://github.com/apple/swift/blob/main/stdlib/public/core/Integers.swift
- 5: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0080-failable-numeric-initializers.md
- 6: https://psychminds.org/swift_documentation/developer.apple.com/documentation/swift/binaryinteger
🏁 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 fRepository: 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.swiftRepository: manaflow-ai/cmux
Length of output: 2507
🏁 Script executed:
# Search for resetBackgroundBlur callsites
rg "resetBackgroundBlur" --type swiftRepository: 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 -40Repository: 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>
…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>
…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>
…/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>
Stack D of the modular refactor: decomposing
Sources/GhosttyTerminalView.swiftperGhosttyTerminalView.plan.md, stacked on the TerminalEngine slice (#5929). One large PR; tranches land as separate commits.Tranche 1 (landed): TerminalSurface model into CmuxTerminal
GhosttyTerminalView.swift15,221 -> 12,226 lines.New
Packages/CmuxTerminal(Wave 3 domain package, depends on CmuxTerminalCore + CmuxGhosttyKit):TerminalSurface+SearchStatelifted faithfully (stillObservableObject/@Published; modernization is a separate phase), split by role underSurface/,Spawn/,Lifecycle/,Hosting/,Runtime/,Events/.Seam inversions (all legacy reach-ups now injected through
TerminalSurfaceRuntimeDependencies):GhosttyApp.shared->TerminalEngineHostingTerminalSurfaceViewProviding(app injects theGhosttyNSView/GhosttySurfaceScrollViewfactory)TerminalSurfaceSpawnPolicyProvidingMobileTerminalByteTee.shared+Unmanagedtee context ->TerminalByteTeeBindingwith lease objectsRendererRealizationController.shared->TerminalRendererRealizationSchedulingrecordAgentHibernationTerminalInputfree function +AgentHibernationController.shared->AgentHibernationRecordingTerminalSurfaceRuntimeTeardownCoordinator.sharedactor singleton -> injected instance; its two enqueue free functions folded into one member with defaultedfreeSurfaceApp-side residue:
Sources/TerminalSurfaceRuntimeWiring.swiftholds the conformances/bridges plus a convenience init that keeps every legacyTerminalSurface(...)call site byte-identical.CmuxSurfaceConfigTemplate, the surface runtime probes, the Claude command shim, and the cmux context environment lifted toCmuxTerminalCore;WorkspaceSurfaceConfig.swiftkeeps forwarding shims. App-side tests naming the moved type getimport CmuxTerminalhoisted above thecanImport(cmux_DEV)block.Behavior invariants: Defaults keys, environment keys (
CMUX_*), and notification raw strings byte-identical (cmux.terminalSurfaceDidBecomeReadymoved, same string). Adversarial normalize-and-diff ofcreateSurfaceand 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.searchStateis@MainActor(its didSet always ran on main), sostartOrFocusTerminalSearchis now@MainActor(both callers already were);GhosttyNSView's copy-mode indicator accessors wentfileprivate-> 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 teston CmuxTerminal/Core/Engine/Services (Core 66, Engine 14, Services 15, Terminal 2 incl. new teardown-coordinator behavior tests), full appxcodebuild buildgreen.e2e:
FindSelectionShortcutUITestsPASSED at ea8d799 (exercises full app launch + terminal + the moved searchState path: manaflow-ai/cmux-dev-artifacts#2939).AutomationSocketUITestsfailed with app-activation timeouts (manaflow-ai/cmux-dev-artifacts#2942), but that class is red onmaintoo (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 (
GhosttyNSView3865–7923,GhosttySurfaceScrollView8027–11245, theNSTextInputClientIME extension 11246–11751, theGhosttyTerminalViewrepresentable 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 theGhosttyApp/AppDelegate/Workspacesingletons. ~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 — everyGhosttyApp.shared(11 members) andAppDelegate.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 isCmuxTypingTimingtelemetry. 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 flagsTerminalSurface.forceRefresh(),WindowTerminalHostView.hitTest(), and theSurfaceSearchOverlay-must-mount-from-GhosttySurfaceScrollViewportal-layering contract as fragile).Engine wrapper (
GhosttyApp362–3855, astatic let shared@MainActorsingleton; ~3,500 lines) references 45 distinct app types (incl.GhosttyConfig,MobileTerminalByteTee,RendererRealizationController,WindowBackdropController, settings types) with 17AppDelegate.shared+ 12Workspace.sharedreach-ups. Its target packageCmuxTerminalEnginecurrently depends only onCmuxTerminalCore.Even the plan's "pure Sendable engine value DTOs" (
ScrollbarVisibility,AppearanceSynchronizationPlan,RuntimeColorSchemeSynchronizationDecision,DefaultBackgroundValues, the font/appearance summaries) cannot be promoted intoCmuxTerminalEnginein isolation: they are nested inGhosttyApp, carry app types (GhosttyConfig.ColorSchemePreference,GhosttyBackgroundBlur,NSColor), and are produced byGhosttyAppstatic/instance methods that call otherGhosttyAppappearance-engine helpers. They only separate cleanly as part of the fullGhosttyApplift, which itself needsGhosttyConfigpackaged first.Prerequisite (the unblocker): package
GhosttyConfigand 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):GhosttyApp→GhosttyAppServiceinCmuxTerminalEngine, lifting the engine value DTOs to top-level Sendable types; invert itsAppDelegate/Workspacereach-ups viaWorkspaceResolving/tab-routing seams. This removes the heaviest view-layer reach-up family (GhosttyApp.shared).CmuxTerminalSurface, with the GhosttyApp/AppDelegate/Workspace/portal reach-ups injected as the plan'sTerminalSurfaceBackgroundProviding/TerminalPortalBinding/TerminalPortalGeometryReportingseams (all cold-path, so latency-neutral), the IME/scroll/overlay transient state staying in the moved view types. Preserve theSurfaceSearchOverlayportal-mount contract.CmuxWorkspaceWindow.Leg 2 leaves the worktree at the verified-green leg-1 head (file-length budget + package-conventions lint pass; CmuxTerminal package
swift buildgreen; full appxcodebuild buildBUILD 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
Improvements