* 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * Budget: entries for the two coordinator files grown by the divergence fixes * Fix duplicate parameter name warning (sessionID sessionID) in workspace 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) <[email protected]> * stage 3c: package side of System/Project/Debug/Sidebar/Browser domains (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) <[email protected]> * stage 3c: cut over System/Project/Debug/Sidebar/Browser domains to the 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) <[email protected]> * CmuxSidebarGit: extract TabManager git-metadata + PR-polling subsystem Lift the embedded sidebar git/PR subsystem out of TabManager into the new CmuxSidebarGit services package: - @MainActor SidebarGitMetadataService (behind SidebarGitMetadataServing): local probe state machine, retry walk over the preserved offsets [0, 0.5, 1.5, 3, 6, 10]s, per-directory snapshot dedupe behind the injected process-wide WorkspaceGitMetadataProbeLimiter, RecursivePathWatcher wiring, and the 5-minute fallback re-poll. - @MainActor PullRequestPollService (behind PullRequestProbing): PR poll deadlines (max(0.25, ...) floor, 10s/60s +-10% jitter, 15-min terminal sweeps, batch limit 3), repo cache pruning, transient-failure staleness, command-hint reconciliation. - TabManager keeps thin forwarders plus a SidebarGitHosting conformance (synchronous read/write seam so apply-turn interleavings stay identical); gh/git argv, GitHub request shapes, cache keys, and badge transitions are unchanged byte-for-byte. - GitPollClock moves into the package with the code that sleeps on it; the probe limiter singleton becomes constructor injection (process-wide instance at the composition root). TabManager.swift 9992 -> 8366 lines (-1626); package +~2900 lines incl. 16 behavior tests (virtual-clock probe scheduling, projection, poll-deadline floor, command hints, limiter). Package swift build + swift test green; full app build green; pbxproj normalized. Co-Authored-By: Claude Fable 5 <[email protected]> * CmuxSidebarGit tests: use a non-skip branch in PR poll tests "main" is a skip-lookup branch, so the poll-deadline floor test cleared the badge without ever starting a refresh and then waited forever for a poll timer that was never armed. Probe/PR suites now use feature/x where a real refresh is required; the dedicated skip-lookup test keeps main. 20 tests in 4 suites green. Net +5/-4 lines. Co-Authored-By: Claude Fable 5 <[email protected]> * stage 3c: dedupe file.open onto the shared v2FileOpen body 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) <[email protected]> * stage 3c: fix browser.focus_mode.set/zoom.set error precedence; finish 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) <[email protected]> * Budget tsv: strip accidental leading whitespace * CmuxSettings: SettingsReading/SettingsWriting sync seam + Wave-3 key convergence surface Adds the synchronous typed-settings seam the TabManager Wave-3 drain reads through: SettingsReading/SettingsWriting protocols plus the UserDefaultsSettingsClient conformer (same DefaultsKey + SettingCodable primitives as the actor store, no parallel mechanism). Adds the two missing TabManager keys as a workspaceGroups catalog section (anchorCloseSuppressed, newWorkspacePlacement with the tolerant WorkspaceGroupNewPlacement value enum) and folds the legacy indicator-style string mapping (rail/border/wash/lift/typography/washRail/blueWashColorRail) into WorkspaceIndicatorStyle's SettingCodable decode so converged reads keep resolving values written by earlier builds. Tests: 83 package tests green (19 new: client round-trip/reset/absence, legacy indicator decode matrix, tolerant group-placement parse). Net: +371 lines (package only; app cutover follows). Co-Authored-By: Claude Fable 5 <[email protected]> * Add CmuxSidebar, CmuxBrowser, CmuxNotifications Wave-3 packages (models + tests; app cutover follows) Three Wave-3 feature-domain packages from the TabManager blueprint, each a zero-dependency leaf with swift-tools 6.0 / macOS 14 / Swift 6 language mode: - CmuxSidebar: SidebarMultiSelectionModel (@MainActor @Observable) owning the sidebar multi-selection set plus typed SidebarMultiSelectionDidHideEvent / ShouldCollapseEvent wrappers (NotificationCenter delivery and userInfo wire shape byte-identical to the legacy stringly keys); SidebarWorkspaceAuxiliaryDetailVisibility lifted faithfully and SidebarWorkspaceDetailVisibility binding the two legacy SidebarWorkspaceDetailSettings resolvers into one value. - CmuxBrowser: BrowserModel (@MainActor @Observable) behind BrowserManaging, holding the bounded RecentlyClosedBrowserStack (faithful LIFO/capacity/ workspace-purge semantics), generic over BrowserPanelRestoreSnapshot because the full snapshot payload is Workspace-owned and migrates with the Workspace decomposition. - CmuxNotifications: NotificationDismissalModel (@MainActor @Observable) behind NotificationDismissing, a faithful lift of TabManager's dismissal decision flow (context policy enum, pending-selection context, suppress-focus-flash latch) over a synchronous two-way NotificationDismissalHosting seam (same isolation rationale as CmuxSidebarGit: one-turn read/write interleavings must not gain suspension points). Tests: 7 (CmuxSidebar) + 8 (CmuxBrowser) + 10 (CmuxNotifications) new package tests green, covering selection mutations, event wire shape, stack bounds, and the dismissal-state transition matrix incl. side-effect ordering. Net: +1147 lines (packages only). Co-Authored-By: Claude Fable 5 <[email protected]> * stage 3c: address review threads (handles observation, window.close test, 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 <[email protected]> * Wave-3 app cutover: TabManager drains settings enums + dismissal + browser stack + sidebar multi-selection into CmuxSettings/CmuxNotifications/CmuxBrowser/CmuxSidebar App-target side of the Wave-3 packages committed previously: - TabManager.swift 8366 -> 7547 lines (-819). The ten FooSettings namespace enums die; reads/writes go through SettingsReading/SettingsWriting against the CmuxSettings catalog (key strings verified byte-identical: every legacy userDefaults key matches its catalog entry, so no user-state reset). - Notification dismissal: TabManager forwards to NotificationDismissalModel behind NotificationDismissing; the synchronous host seam (TabManager+NotificationDismissalHosting) preserves one-turn read/write interleavings. dismissNotification core verified structurally identical to the legacy body (same guard order, same side-effect order). - Recently-closed browser stack: recentlyClosedBrowsers -> BrowserModel<ClosedBrowserPanelRestoreSnapshot> (snapshot payload stays Workspace-owned until the Workspace decomposition). - Sidebar multi-selection: sidebarSelectedWorkspaceIds -> SidebarMultiSelectionModel; the DidHide/ShouldCollapse NotificationCenter events keep identical userInfo wire shape via typed event wrappers; observers now match on the per-window model instance (was: TabManager instance) - same per-window scoping. - WorkspaceTabColorSettings moved verbatim to its own file (palette math staged for CmuxWorkspaces in Wave 4); WorkspacePlacement/ WorkspaceIndicatorStyle display+resolution split into app-side extensions. - Budget entries refreshed: TabManager.swift down to actual (7547); 11 touched files grew +1..+7 cosmetic lines (imports + catalog key spellings). Co-Authored-By: Claude Fable 5 <[email protected]> * Wave-4 tranche 1: CmuxWorkspaceNavigation package (FocusHistoryModel behind FocusHistoryNavigating) Lift TabManager's focus-history back/forward stack into a new CmuxWorkspaceNavigation package: FocusHistoryModel (@MainActor @Observable) owns the stack, recording suppression depth, and the deferred-selection suppression marks; TabManager hosts it via FocusHistoryHosting (synchronous two-way seam, single-turn interleavings preserved) and keeps thin forwarders for every existing entrypoint (menus, shortcuts, titlebar, socket). Value types (FocusHistoryEntry/Record/Menu*) move to the package as Sendable; FocusHistoryMenuSnapshotBuilder becomes FocusHistoryMenuSnapshot.recentlyFocused (no namespace enums in packages); localized menu formatting stays app-side in FocusHistory.swift. Session-restore reset and the closed-item restore paths now go through the model. 15 package tests cover record/coalesce/invalidate/ navigate/preserving-forward-branch behavior. Machine-diff vs pre-deletion TabManager.swift: 12/19 lifted bodies identical after spelling normalization; the 7 host-seam bodies verified one-to-one against the witness implementations. Co-Authored-By: Claude Fable 5 <[email protected]> * Wave-4 tranche 2: CmuxPanes package (SplitDirection/ResizeDirection values + split geometry + PaneLayoutService) Lift the pane-domain pure logic out of the app target into a new CmuxPanes package (first package to depend on vendor/bonsplit): SplitDirection and ResizeDirection move from TabManager+CompatibilityTypes.swift as Sendable values; the SplitEqualizer math and TabManager.resizeSplit's candidate walk, innermost-split selection, and 0.1-0.9 clamp become pure plan computations as ExternalTreeNode extensions (equalizeDividerPlan / resizeDividerAdjustment, snapshot-only inputs); a stateless @MainActor PaneLayoutService applies plans to BonsplitController preserving the legacy post-order divider-mutation sequence. TabManager owns one PaneLayoutService; TabManager+EqualizeSplits and the control-socket workspace-equalize witness forward through it. Deletes Sources/SplitEqualizer.swift and Sources/TabManager+CompatibilityTypes.swift; ten package tests cover span weighting, orientation filtering, invalid split ids, pixel-delta resize, child-side matching, innermost preference, clamping, and the direction value mappings. Machine-diff vs pre-deletion state: direction value bodies, spanCount, candidate walk, and candidate/trace structs identical after receiver-spelling normalization; the resize selection/clamp math identical token-for-token with the tab/pane lookups staying app-side. Budget TSV refreshed: TabManager.swift 7515 -> 7146; cutover files +1 import line each. Co-Authored-By: Claude Fable 5 <[email protected]> * Wave-4 tranche 3: CmuxWorkspaces package bootstrap (WorkspaceGroup value + batch-reorder planner) Start the CmuxWorkspaces domain package with the workspace values and pure planning TabManager carried at file scope: WorkspaceGroup (the sidebar group value with its anchor-lifecycle contract), WorkspaceReorderPlanItem, and WorkspaceBatchReorderError move over as Sendable values; the batch-reorder validation and pinned-stable final-order computation (workspaceBatchReorderPlan + batchWorkspaceReorderFinalIds) lift into a stateless WorkspaceReorderPlanner operating on WorkspaceOrderSnapshot (id, isPinned) captures. TabManager owns one planner, snapshots tabs at the call sites, and keeps the apply side (tabs[] rebuild, group-contiguity renormalization, order-change notifications) plus the group/pinned-aware per-item clamping, which reads live group state. Four package tests cover requested-ahead-of-unmentioned ordering, the pinned-ahead-of-unpinned invariant, duplicate/unknown rejection precedence, and the empty request. Machine-diff vs pre-deletion TabManager.swift: batchReorderPlan, batchReorderFinalIds, and the WorkspaceGroup field list identical after snapshot-seam renames (tabs -> current, workspacesById -> snapshotsById). Remaining for this package (documented in the PR body): WorkspacesModel (tabs/groups/selection stored state), the creation/reorder/group/close/ detached coordinators, SessionSnapshotRepository behind SessionSnapshotStoring, and the async CloseConfirming seam. Co-Authored-By: Claude Fable 5 <[email protected]> * Wave-4 tranche 4a: WorkspacesModel owns tabs/groups/selection storage (CmuxWorkspaces) TabManager's @Published tabs / workspaceGroups / selectedTabId stored state moves into a new @MainActor @Observable WorkspacesModel<Tab> in CmuxWorkspaces, generic over a minimal WorkspaceTabRepresenting seam (id/groupId/isPinned) that the app-target Workspace satisfies. TabManager stays the per-window composition point: it owns the model, forwards the legacy accessors, and implements WorkspacesHosting to run the legacy property-observer side effects verbatim at identical timing: - willSet hooks re-emit objectWillChange (what @Published did) plus the new legacy Combine bridge publishers (tabsPublisher/selectedTabIdPublisher, CurrentValueSubject: new-value-at-willSet + replay-on-subscribe, the exact Published.Publisher contract). - The selection willSet DEBUG trace and didSet side-effect chain move body-verbatim into the hook methods (git diff shows the bodies as unchanged context). - Hooks fire on every assignment including equal values (@Published parity); no-op guards stay in the host bodies. Covered by WorkspacesModelTests. The eleven remaining $tabs/$selectedTabId Combine subscribers (AppDelegate, CmuxConfig, ContentView, BackgroundWorkspacePrimeCoordinator, MobileWorkspaceListObserver, TabManager DEBUG scaffolding) move to the bridge publishers mechanically; per-site analysis confirmed none depend on Published-specific semantics beyond replay + willSet-time emission (dropFirst/throttle/switchToLatest all subject-compatible). Budget refreshed via --write-budget (cutover precedent): +64 lines of forwards/bridges/doc on TabManager.swift ahead of the tranche-4b drain. Co-Authored-By: Claude Fable 5 <[email protected]> * Wave-4 tranche 4b: group + reorder flows drain into CmuxWorkspaces coordinators The ~1,500-line group/reorder cluster leaves TabManager: - WorkspacesModel gains the invariant/traversal layer it owns the data for (Model/WorkspacesModel+Ordering.swift, +GroupInvariants.swift): top-level row derivation, pin-tier clamps, anchorFirst, contiguity/run normalization, group-order sync, assignGroup, anchor-close dissolve, and the selection auto-expand used by the selection didSet hook. - WorkspaceReorderCoordinator: move-to-top, single/before-after/batch reorders (owns the WorkspaceReorderPlanner), sidebar drag planning, drag-inferred group membership, pin toggles/batch pinning. - WorkspaceGroupCoordinator: group creation (fresh anchor + child adoption + stable creation placement), createWorkspaceInGroup, member add/remove, ungroup/delete, rename, collapse/pin/color/icon/anchor, group-slot moves, localized auto-naming via host-provided format. - Seams: WorkspaceOrderHosting (legacy postWorkspaceOrderDidChange NotificationCenter + event-bus publication stays app-side) and WorkspaceGroupHosting (workspace creation/teardown on the Workspace god, selection entry point, sidebar multi-selection sync, String(localized:) format, settings read, RenderableSystemSymbol normalization). WorkspaceTabRepresenting gains currentDirectory (group cwd inheritance). - TabManager keeps its full legacy API as one-line forwards (zero call-site churn in ContentView/AppDelegate/TerminalController/socket paths) and implements the two hosting protocols in the class body. Adversarial machine-diff vs the pre-tranche HEAD: 57 moved method bodies compared after mechanical normalization (model./host. prefixes, Tab type substitution); 50 are byte-identical, the 7 diffs are exactly the intended host-seam inversions (addWorkspace/closeWorkspace/select/sidebar/localized format/settings/icon-normalization hooks) plus comment rewording. CmuxWorkspaces now depends on CmuxSettings (WorkspaceGroupNewPlacement). 20 package tests cover hook parity, tier ordering, batch reorder errors, batch-unpin order parity, group creation/dissolve/anchor invariants, and collapse focus/selection stripping. Budget refreshed (--write-budget): TabManager.swift 7158 -> 6067 lines; the two coordinators are new >500-line package files made of moved code. Co-Authored-By: Claude Fable 5 <[email protected]> * Fix remaining conditional CmuxSettings imports in cmuxTests Same trap as BrowserConfigTests in the merge commit: `import CmuxSettings` sat inside the `#elseif canImport(cmux)` branch, which DEV CI builds (cmux_DEV module) never take, so SettingCatalog/UserDefaultsSettingsClient were out of scope and the `tests` job failed to compile. Move the import unconditional in ShortcutAndCommandPaletteTests and WorkspaceGroupTests (WorkspaceUnitTests precedent). Co-Authored-By: Claude Fable 5 <[email protected]> * Budget: ratchet for the unconditional test imports (+1 line each) Co-Authored-By: Claude Fable 5 <[email protected]> * Fix missing CmuxSidebar import in SidebarWorkspaceSnapshotRefreshPolicyTests SidebarWorkspaceAuxiliaryDetailVisibility lives only in the CmuxSidebar package (no app-side duplicate), so the tests job failed to compile once the earlier conditional-import fixes let compilation reach this file. Audited the remaining cmuxTests/cmuxUITests files for package-only type references without an unconditional import: this is the only one (other flagged names still have app-side legacy declarations reachable through @testable import). Co-Authored-By: Claude Fable 5 <[email protected]> * Repair WorkspaceUnitTests detail-visibility retarget; pin ambiguous app types in tests Two follow-ups surfaced once the tests job compiled past the earlier missing-import failures: 1. The SidebarWorkspaceDetailSettingsTests retarget (wave-3 cutover) was syntactically mangled: `.showsWorkspaceDescription` / `.showsNotificationMessage` were spliced into the middle of the UserDefaultsSettingsClient read. Restored the intended assertions (construct SidebarWorkspaceDetailVisibility from the catalog reads, then assert the resolved member), matching the legacy resolvedWorkspaceDescriptionVisibility / NotificationMessage semantics. 2. The app target still declares legacy duplicates of several CmuxSettings value types (StoredShortcut, ShortcutStroke, AppIconMode, BrowserThemeMode, BrowserSearchEngine). Files importing CmuxSettings unconditionally now saw both and failed with ambiguity once main's new tests landed in the merge. Pin the app types with conditional private typealiases in WorkspaceUnitTests, ShortcutAndCommandPaletteTests, KeyboardShortcutContextTests, GhosttyConfigTests, BrowserConfigTests - these tests exercise the app-side settings paths. Co-Authored-By: Claude Fable 5 <[email protected]> * KeyboardShortcutContextTests: ShortcutStroke pins to the package type The file's single ShortcutStroke use compares CmuxSettings.ShortcutAction.defaultStroke (package type, defaulted-arg init); pinning it to the app type broke the call. StoredShortcut stays pinned to the app type (store override APIs). Co-Authored-By: Claude Fable 5 <[email protected]> * Budget: ratchet for KeyboardShortcutContextTests typealias note (+2) Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
281 lines
11 KiB
Swift
281 lines
11 KiB
Swift
import AppKit
|
|
import CmuxSettings
|
|
import SwiftUI
|
|
|
|
/// Workspace tab color palette: persistence, legacy migration, palette
|
|
/// math, and display-color rendering.
|
|
///
|
|
/// Fused-enum split status (TabManager decomposition): the storage key is
|
|
/// the CmuxSettings catalog's `workspaceColors.palette` entry (sourced below
|
|
/// so the wire string is defined once); the pure palette math is **staged
|
|
/// for CmuxWorkspaces (Wave 4)**; the `NSColor`/SwiftUI rendering stays
|
|
/// app-side until the workspace UI package exists. Moved out of
|
|
/// `TabManager.swift` verbatim.
|
|
enum WorkspaceTabColorSettings {
|
|
static let paletteKey = WorkspaceColorsCatalogSection().palette.userDefaultsKey
|
|
|
|
private static let legacyDefaultOverridesKey = "workspaceTabColor.defaultOverrides"
|
|
private static let legacyCustomColorsKey = "workspaceTabColor.customColors"
|
|
|
|
private static let originalPRPalette: [WorkspaceTabColorEntry] = [
|
|
WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"),
|
|
WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"),
|
|
WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"),
|
|
WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"),
|
|
WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"),
|
|
WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"),
|
|
WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"),
|
|
WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"),
|
|
WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"),
|
|
WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"),
|
|
WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"),
|
|
WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"),
|
|
WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"),
|
|
WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"),
|
|
WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"),
|
|
WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"),
|
|
]
|
|
|
|
static var defaultPalette: [WorkspaceTabColorEntry] {
|
|
originalPRPalette
|
|
}
|
|
|
|
static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
|
|
let paletteMap = effectivePaletteMap(defaults: defaults)
|
|
let builtInOrder = defaultPalette.compactMap { entry -> WorkspaceTabColorEntry? in
|
|
guard let hex = paletteMap[entry.name] else { return nil }
|
|
return WorkspaceTabColorEntry(name: entry.name, hex: hex)
|
|
}
|
|
let builtInNames = Set(defaultPalette.map(\.name))
|
|
let customEntries = paletteMap
|
|
.filter { !builtInNames.contains($0.key) }
|
|
.sorted { lhs, rhs in
|
|
lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending
|
|
}
|
|
.map { WorkspaceTabColorEntry(name: $0.key, hex: $0.value) }
|
|
return builtInOrder + customEntries
|
|
}
|
|
|
|
static func customPaletteEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
|
|
let builtInNames = Set(defaultPalette.map(\.name))
|
|
return palette(defaults: defaults).filter { !builtInNames.contains($0.name) }
|
|
}
|
|
|
|
static func defaultColorHex(named name: String) -> String? {
|
|
defaultPalette.first(where: { $0.name == name })?.hex
|
|
}
|
|
|
|
static func currentColorHex(named name: String, defaults: UserDefaults = .standard) -> String? {
|
|
effectivePaletteMap(defaults: defaults)[name]
|
|
}
|
|
|
|
static func setColor(named name: String, hex: String, defaults: UserDefaults = .standard) {
|
|
guard let normalizedName = normalizedColorName(name),
|
|
let normalizedHex = normalizedHex(hex) else { return }
|
|
|
|
var palette = editablePaletteMap(defaults: defaults)
|
|
palette[normalizedName] = normalizedHex
|
|
persistPaletteMap(palette, defaults: defaults)
|
|
}
|
|
|
|
static func removeColor(named name: String, defaults: UserDefaults = .standard) {
|
|
guard let normalizedName = normalizedColorName(name) else { return }
|
|
var palette = editablePaletteMap(defaults: defaults)
|
|
palette.removeValue(forKey: normalizedName)
|
|
persistPaletteMap(palette, defaults: defaults)
|
|
}
|
|
|
|
static func persistPaletteMap(_ rawPalette: [String: String], defaults: UserDefaults = .standard) {
|
|
let normalizedPalette = normalizedPaletteMap(rawPalette)
|
|
if normalizedPalette == defaultPaletteMap {
|
|
defaults.removeObject(forKey: paletteKey)
|
|
} else {
|
|
defaults.set(normalizedPalette, forKey: paletteKey)
|
|
}
|
|
defaults.removeObject(forKey: legacyDefaultOverridesKey)
|
|
defaults.removeObject(forKey: legacyCustomColorsKey)
|
|
}
|
|
|
|
static func backupPaletteMap(defaults: UserDefaults = .standard) -> [String: String]? {
|
|
if let stored = storedPaletteMap(defaults: defaults) {
|
|
return stored
|
|
}
|
|
return legacyPaletteMap(defaults: defaults)
|
|
}
|
|
|
|
static func resolvedPaletteMap(defaults: UserDefaults = .standard) -> [String: String] {
|
|
effectivePaletteMap(defaults: defaults)
|
|
}
|
|
|
|
static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? {
|
|
guard let normalized = normalizedHex(hex) else { return nil }
|
|
var palette = editablePaletteMap(defaults: defaults)
|
|
if palette.contains(where: { $0.value == normalized }) {
|
|
return normalized
|
|
}
|
|
|
|
palette[nextCustomColorName(existingNames: Set(palette.keys))] = normalized
|
|
persistPaletteMap(palette, defaults: defaults)
|
|
return normalized
|
|
}
|
|
|
|
static func reset(defaults: UserDefaults = .standard) {
|
|
defaults.removeObject(forKey: paletteKey)
|
|
defaults.removeObject(forKey: legacyDefaultOverridesKey)
|
|
defaults.removeObject(forKey: legacyCustomColorsKey)
|
|
}
|
|
|
|
static func normalizedHex(_ raw: String) -> String? {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
|
guard body.count == 6 else { return nil }
|
|
guard UInt64(body, radix: 16) != nil else { return nil }
|
|
return "#" + body.uppercased()
|
|
}
|
|
|
|
static func displayColor(
|
|
hex: String,
|
|
colorScheme: ColorScheme,
|
|
forceBright: Bool = false
|
|
) -> Color? {
|
|
guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else {
|
|
return nil
|
|
}
|
|
return Color(nsColor: color)
|
|
}
|
|
|
|
static func displayNSColor(
|
|
hex: String,
|
|
colorScheme: ColorScheme,
|
|
forceBright: Bool = false
|
|
) -> NSColor? {
|
|
guard let normalized = normalizedHex(hex),
|
|
let baseColor = NSColor(hex: normalized) else {
|
|
return nil
|
|
}
|
|
|
|
if forceBright || colorScheme == .dark {
|
|
return brightenedForDarkAppearance(baseColor)
|
|
}
|
|
return baseColor
|
|
}
|
|
|
|
private static func effectivePaletteMap(defaults: UserDefaults) -> [String: String] {
|
|
if let stored = storedPaletteMap(defaults: defaults) {
|
|
return stored
|
|
}
|
|
if let legacy = legacyPaletteMap(defaults: defaults) {
|
|
return legacy
|
|
}
|
|
return defaultPaletteMap
|
|
}
|
|
|
|
private static func editablePaletteMap(defaults: UserDefaults) -> [String: String] {
|
|
if let stored = storedPaletteMap(defaults: defaults) {
|
|
return stored
|
|
}
|
|
if let legacy = legacyPaletteMap(defaults: defaults) {
|
|
return legacy
|
|
}
|
|
return defaultPaletteMap
|
|
}
|
|
|
|
private static func storedPaletteMap(defaults: UserDefaults) -> [String: String]? {
|
|
guard let raw = defaults.dictionary(forKey: paletteKey) as? [String: String] else { return nil }
|
|
return normalizedPaletteMap(raw)
|
|
}
|
|
|
|
private static func legacyPaletteMap(defaults: UserDefaults) -> [String: String]? {
|
|
let hasLegacyOverrides = defaults.object(forKey: legacyDefaultOverridesKey) != nil
|
|
let hasLegacyCustomColors = defaults.object(forKey: legacyCustomColorsKey) != nil
|
|
guard hasLegacyOverrides || hasLegacyCustomColors else { return nil }
|
|
|
|
var palette = defaultPaletteMap
|
|
|
|
if let rawOverrides = defaults.dictionary(forKey: legacyDefaultOverridesKey) as? [String: String] {
|
|
let validNames = Set(defaultPalette.map(\.name))
|
|
for (name, hex) in rawOverrides {
|
|
guard validNames.contains(name),
|
|
let normalized = normalizedHex(hex) else { continue }
|
|
palette[name] = normalized
|
|
}
|
|
}
|
|
|
|
if let rawCustomColors = defaults.array(forKey: legacyCustomColorsKey) as? [String] {
|
|
var index = 1
|
|
var seenCustomHexes: Set<String> = []
|
|
for rawHex in rawCustomColors {
|
|
guard let normalized = normalizedHex(rawHex),
|
|
seenCustomHexes.insert(normalized).inserted else { continue }
|
|
let name = nextCustomColorName(
|
|
existingNames: Set(palette.keys),
|
|
startingAt: index
|
|
)
|
|
palette[name] = normalized
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
return palette
|
|
}
|
|
|
|
private static func normalizedPaletteMap(_ rawPalette: [String: String]) -> [String: String] {
|
|
var normalized: [String: String] = [:]
|
|
for (rawName, rawHex) in rawPalette {
|
|
guard let name = normalizedColorName(rawName),
|
|
let hex = normalizedHex(rawHex) else { continue }
|
|
normalized[name] = hex
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
private static var defaultPaletteMap: [String: String] {
|
|
Dictionary(uniqueKeysWithValues: defaultPalette.map { ($0.name, $0.hex) })
|
|
}
|
|
|
|
private static func normalizedColorName(_ raw: String) -> String? {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func nextCustomColorName(
|
|
existingNames: Set<String>,
|
|
startingAt initialIndex: Int = 1
|
|
) -> String {
|
|
var index = max(1, initialIndex)
|
|
while true {
|
|
let candidate = "Custom \(index)"
|
|
if !existingNames.contains(where: { $0.caseInsensitiveCompare(candidate) == .orderedSame }) {
|
|
return candidate
|
|
}
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
private static func brightenedForDarkAppearance(_ color: NSColor) -> NSColor {
|
|
let rgbColor = color.usingColorSpace(.sRGB) ?? color
|
|
var hue: CGFloat = 0
|
|
var saturation: CGFloat = 0
|
|
var brightness: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
|
|
|
|
let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28))
|
|
// Preserve neutral grays when brightening to avoid introducing hue shifts.
|
|
let boostedSaturation: CGFloat
|
|
if saturation <= 0.08 {
|
|
boostedSaturation = saturation
|
|
} else {
|
|
boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12))
|
|
}
|
|
|
|
return NSColor(
|
|
hue: hue,
|
|
saturation: boostedSaturation,
|
|
brightness: boostedBrightness,
|
|
alpha: alpha
|
|
)
|
|
}
|
|
}
|