Files
cmux/Sources/WorkspaceTabColorSettings.swift
Abdulaziz AlbaharandClaude Fable 5 7481d76f7d TabManager decomposition: Wave 3+4 sub-models (Browser/Notifications/Sidebar/Workspaces/Panes/FocusHistory) + settings convergence; TabManager becomes a composition root (#5921)
* 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]>
2026-06-13 14:07:48 -07:00

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
)
}
}