fix: honor Focus / Do Not Disturb for the fallback notification sound#5651
Conversation
Adds runtime tests for NotificationSoundSettings.isSuppressedByActiveFocus against the Do Not Disturb assertion store (active Focus, ended Focus, missing store). The predicate is a stub that returns false here, so the active-Focus case fails. The following commit implements it and wires the gate. Per the repo's two-commit regression policy: this commit is the red half. Co-Authored-By: Princess Fiona of Far Far Away (claude-opus-4-8[1m]) <noreply@anthropic.com>
When desktop notifications are denied (or undeliverable), FeedCoordinator falls back to playing the sound directly via NSSound (runFallbackEffectsIfStillAwaiting -> NotificationSoundSettings.playSelectedSound). That path is not gated by the OS, so it plays even when the user has turned notifications off for cmux and while a macOS Focus / Do Not Disturb mode is active. The UNUserNotificationCenter path (content.sound) is gated by the OS and is unaffected. Implement isSuppressedByActiveFocus by reading the Do Not Disturb daemon's assertion store, and gate playSelectedSound() on it. The Settings preview path (previewSound) does not go through playSelectedSound, so previews still play. Fails open: any read or parse error plays the sound as before. This is the green half: the tests added in the previous commit now pass. Fixes manaflow-ai#5650. Co-Authored-By: Borat Margaret Sagdiyev (claude-opus-4-8[1m]) <noreply@anthropic.com>
|
@Reebz is attempting to deploy a commit to the Manaflow Team on Vercel. A member of the Team first needs to authorize it. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughNotificationSoundSettings reads the Focus daemon Assertions.json off-main to detect active macOS Focus/DND, gates NSSound playback (async check with main-queue play and completion), and TerminalNotificationStore adds fallbackEffects that disables fallback sound when authorization is .denied; FeedCoordinator uses the helper. ChangesFocus/Do Not Disturb sound suppression & fallback effects
Sequence Diagram(s)sequenceDiagram
participant Caller
participant playSelectedSound
participant dndAssertionQueue
participant AssertionsJSON
participant mainQueue
Caller->>playSelectedSound: playSelectedSound(defaults:, assertionsFileURL:, completion:)
playSelectedSound->>dndAssertionQueue: isSuppressedByActiveFocus(...)
dndAssertionQueue->>AssertionsJSON: read Assertions.json
AssertionsJSON-->>dndAssertionQueue: parse -> suppression Bool
dndAssertionQueue-->>mainQueue: suppression Bool
mainQueue->>playSelectedSound: if suppression == false -> playSound(...) and completion(true)
alt suppression == true
mainQueue->>playSelectedSound: skip playSound and completion(false)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 20 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (20 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR fixes cmux playing its fallback notification sound through macOS Focus / Do Not Disturb and through explicit user denial, using two complementary gates: stripping
Confidence Score: 5/5Safe to merge; all changed paths are well-tested and the two-gate approach correctly handles the documented macOS TCC limitation. The denied-authorization gate is a pure value transform with no shared mutable state, and nine tests cover the edge cases exhaustively. The Focus / DND gate correctly moves disk I/O off the main actor and fails open on permission errors, matching the PR's stated invariants. The only nit is that the new completion-handler / GCD pattern in No files require special attention beyond the legacy GCD/completion-handler shape in Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller as Caller (MainActor)
participant TNS as TerminalNotificationStore
participant NSS as NotificationSoundSettings
participant BG as dndAssertionQueue
participant FS as Assertions.json
Caller->>TNS: scheduleUserNotification(effects)
TNS->>TNS: ensureAuthorization
alt authorized
TNS->>TNS: UNUserNotificationCenter.add(request)
opt delivery failure
TNS->>NSS: playSelectedSound()
end
else denied
TNS->>TNS: fallbackEffects(effects, .denied)
Note over TNS: sound=false, no playback
else notDetermined or unknown
TNS->>TNS: fallbackEffects(effects, state)
TNS->>NSS: playSelectedSound()
end
NSS->>BG: dndAssertionQueue.async
BG->>FS: Data(contentsOf: assertionsFileURL)
FS-->>BG: data or error
BG->>BG: isSuppressedByActiveFocus()
BG->>Caller: DispatchQueue.main.async
alt not suppressed
Caller->>Caller: playSound(value)
end
Caller->>Caller: completion?(didPlay)
Reviews (9): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@Sources/TerminalNotificationStore.swift`:
- Around line 304-325: The Focus assertion-file reads in
isSuppressedByActiveFocus (Data(contentsOf:) and JSON parsing) are synchronous
on the main-actor path invoked by playSelectedSound; move those disk/JSON
operations to a background task and expose a cheap in-memory snapshot that
playSelectedSound reads instead. Implement a cached Bool (or small struct)
updated asynchronously from a background queue/Task when
defaultAssertionsFileURL changes or periodically; change
isSuppressedByActiveFocus to return the cached value (thread-safe via actor,
DispatchQueue, or atomic) and add a background refresh routine that reads
defaultAssertionsFileURL, parses the JSON, and updates the cache. Ensure
playSelectedSound continues to call isSuppressedByActiveFocus (no blocking I/O)
and keep the file-IO/JSON logic confined to the background refresher.
🪄 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: 385df7f7-b0dd-457c-a9ad-814d212eabf1
📒 Files selected for processing (2)
Sources/TerminalNotificationStore.swiftcmuxTests/NotificationSoundSettingsTests.swift
The synchronous Assertions.json read and JSON parse in isSuppressedByActiveFocus landed on the main thread, because playSelectedSound is reached from the @mainactor fallback path. On a network-mounted home directory that read can stall the UI on every fallback notification. Cache the result and refresh it on a background utility queue, so the playback path reads a cheap in-memory snapshot instead of touching disk. The pure, injectable isSuppressedByActiveFocus is unchanged, so the three Focus/DND regression tests stay green without edits. Co-Authored-By: Wade Winston Wilson (claude-opus-4-8[1m]) <noreply@anthropic.com>
|
Pushed cb7dddc to address the one substantive point across the bot reviews: the Focus read no longer happens synchronously on the main actor. It now reads a cached value, refreshed on a background utility queue, so the On the other pre-merge check failures, for the record:
Happy to adjust any of these. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@Sources/TerminalNotificationStore.swift`:
- Around line 337-359: The cache is updated only after returning the old
snapshot, causing a one-notification lag; change
isActiveFocusSuppressionCached() so that when needsRefresh is true you set
dndRefreshInFlight, release the lock, compute suppressed =
isSuppressedByActiveFocus() synchronously (do not dispatch async), then
reacquire dndSuppressionLock to set cachedDndSuppression = suppressed and clear
dndRefreshInFlight before returning; keep the lock/unlock around reads/writes to
cachedDndSuppression and dndRefreshInFlight (references:
isActiveFocusSuppressionCached, dndSuppressionLock, cachedDndSuppression,
dndRefreshInFlight, isSuppressedByActiveFocus, playSelectedSound) so the
decision in playSelectedSound uses the current value.
🪄 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: 9bcb7043-b6d5-49d5-9244-9de3c70b0566
📒 Files selected for processing (1)
Sources/TerminalNotificationStore.swift
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The Focus/DND gate added for manaflow-ai#5650 reads a cached snapshot that only refreshes after deciding, so the first sound after the user enables a Focus still punches through — the canonical repro for the bug the gate exists to fix (and the first play after a Focus ends stays wrongly silent for one notification). Drive the real playback entry point in tests: playSelectedSound gains an injectable assertion-store URL and a main-queue completion reporting whether the sound was allowed to play (production callers pass nothing). The gate still consults the stale cache here, so the two first-play assertions fail. Per the repo's two-commit regression policy: this commit is the red half. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the stale cached Focus snapshot with a fresh assertion-store read per play. The read still happens on the background utility queue (no disk I/O on the @mainactor callers); playback hops back to the main queue afterwards. An out-of-band sound a few milliseconds later is imperceptible, but a stale snapshot is not: it let the first sound after the user enables a Focus punch through, which is the canonical repro of the bug this gate exists to fix. This deletes the cache, its lock, and the in-flight flag outright - notification sounds are cooldown-throttled, so per-play reads need no caching layer. This is the green half: the staleness tests from the previous commit now pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two DEBUG-only seams for dogfooding the Focus/DND sound gate in a running app: - notification.sound.focusGate logs each gate decision to the unified debug event log, alongside the existing notification.store.sideEffects events. storeReadable distinguishes "no Focus active" from "assertion store unreadable" - on a default install ~/Library/DoNotDisturb is TCC protected (Full Disk Access), and those states are otherwise indistinguishable through the fail-open gate. - CMUX_DEBUG_DND_ASSERTIONS_PATH points the gate at a fixture file so a tagged dev build can be driven end-to-end without toggling the real system Focus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continued: staleness fixed, plus a significant empirical findingBuilt this locally as a tagged Debug app and drove the gate end-to-end in the running app (socket What changed (three commits)
Live verification (tagged dev build, macOS 26.4.1)
Finding: on a default install, this gate cannot see Focus at all
Where that leaves #5650
|
…d-honor-focus-dnd
workflow-guard-tests fails on the merged tree: this branch grows Sources/TerminalNotificationStore.swift by 83 lines (2378 -> 2461) for the Focus/DND gate, its tests, and the debug probe, and current main has itself drifted past the checked-in budget on three other files (ContentView.swift 19068>19052, TabManager.swift 9939>9914, WorkspaceGroupTests.swift 906>853). Regenerated with scripts/swift_file_length_budget.py --write-budget, so shrunk files ratchet down at the same time. Splitting NotificationSoundSettings out of TerminalNotificationStore.swift is the right longer-term fix for that file's size and is left to a dedicated refactor PR, matching how previous extractions were scoped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The out-of-band NSSound fallback fires exactly when the OS will not deliver the banner. For a user who explicitly turned cmux notifications off, that inverts their intent: the denial is the request for silence (manaflow-ai#5650's "Expected"), and empirically the Focus assertion-store gate cannot close this hole on a default install because the store is TCC protected (storeReadable=0 in-app), so the denied branch must not rely on it. fallbackEffects(_:authorizationState:) is the shared policy seam for all fallback call sites; the red-half stub returns effects unchanged, so the denied case fails. Only .denied strips the sound: fresh installs (.notDetermined) and granted states keep the audible fallback. Per the repo's two-commit regression policy: this commit is the red half. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement fallbackEffects(_:authorizationState:) and route every out-of-band fallback call site through it: FeedCoordinator's denied branch, its just-declined-the-prompt branch, and the store's unauthorized scheduleUserNotification fallback. Only .denied strips the sound; .notDetermined (deferred prompt) and granted states keep the audible fallback, and delivery-failure fallbacks for authorized users are unchanged. This is the TCC-independent half of manaflow-ai#5650: the Focus assertion-store gate cannot run on a default install (the store needs Full Disk Access), but a user who turned cmux notifications off has already asked for silence - including during Focus, which suppresses banners through the same denied-shaped fallback. The Focus gate remains as best-effort for FDA installs and the app-frontmost suppressed-banner path. This is the green half: the denied-authorization test from the previous commit now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up to the findings above — three more changes pushed:
PR description rewritten to match the two-half scope. |
ensureAuthorization completions now carry the effective authorization state alongside the decision. The state property is refreshed asynchronously after a prompt response, so the unauthorized fallback in scheduleUserNotification read a stale .notDetermined for the very notification whose prompt the user had just declined and kept its fallback sound; FeedCoordinator's prompt path already passed .denied explicitly. A declined prompt now reports .denied directly (matching FeedCoordinator), so fallbackEffects strips the sound there too. No new test: the denial-mapping policy itself is covered by the fallbackEffects tests, and exercising the prompt race end-to-end would need a UNUserNotificationCenter seam this store does not have - the center is a private constant - which is a larger refactor than this fix warrants. Stated per the test-quality policy. Budget TSV refreshed for the nine-line growth in TerminalNotificationStore.swift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A requestAuthorization failure is not a user decision. Both prompt paths (store and FeedCoordinator) were collapsing granted=false with an error into .denied, which the new fallback policy silences; report .unknown for request errors instead so the fallback stays audible, reserving .denied for a real declined prompt. Fail-open preserved. The .unknown-keeps-sound behavior is already covered by otherAuthorizationStatesKeepFallbackSound. Budget TSV refreshed for the four-line growth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
workflow-guard-tests is red on every PR right now: main itself has drifted past the checked-in budget (ContentView.swift 19068>19052, TabManager.swift 9939>9914, WorkspaceGroupTests.swift 906>853, and Workspace.swift/GhosttyTerminalView.swift moved as well), so any merged tree fails the budget step regardless of the PR's own changes. Regenerated with scripts/swift_file_length_budget.py --write-budget on current main (shrunk files ratchet down at the same time), plus a pre-allocated 2474 for Sources/TerminalNotificationStore.swift to cover the in-flight Focus/DND notification-sound fix (#5651, +96 lines). That PR comes from a fork, and a fork PR whose diff touches .github/ cannot run workflows at all - which is why the bump has to land from a same-repo branch first. Splitting NotificationSoundSettings out of that file is the tracked longer-term fix. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d-honor-focus-dnd # Conflicts: # .github/swift-file-length-budget.tsv
The Focus/DND notification-sound fix grows Sources/Feed/FeedCoordinator.swift 1239 -> 1255 (denied-prompt and request-error handling around the fallback effects). #5651 is a fork PR and cannot carry .github/ changes without losing workflow runs, so the bump lands from a same-repo branch like #5784. Regenerated with --write-budget on current main at the same time. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d-honor-focus-dnd
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
main commit 11f1f6c (#5651) grew TerminalNotificationStore.swift to 2623 lines and Feed/FeedCoordinator.swift to 1255 without refreshing the budget, leaving workflow-guard-tests red on main and on any branch that merges it. Accept that growth here so the merge is buildable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TerminalNotificationStore.swift (+96, grown on main by #5651 without a budget bump) and FeedCoordinator.swift (+16) exceed the budgets recorded on main itself — main's gate is currently red independent of this branch. Bump both here so this PR's merge CI reflects only its own debt.
#5651 grew Sources/TerminalNotificationStore.swift (+96) and Sources/Feed/FeedCoordinator.swift (+16) without refreshing .github/swift-file-length-budget.tsv, so workflow-guard-tests ("Validate Swift file length budget") now fails on main and on every PR merged with current main. Accept the already-merged growth as known debt via scripts/swift_file_length_budget.py --write-budget; both files remain split candidates. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…dget (#6020) #5651 grew Sources/TerminalNotificationStore.swift (+96 over budget) and Sources/Feed/FeedCoordinator.swift (+16 over budget) without updating .github/swift-file-length-budget.tsv, so workflow-guard-tests now fails on every PR. Move the self-contained NotificationSoundSettings enum (sound selection, custom sound staging, Focus/DND suppression, fallback playback, custom command execution) into its own file. TerminalNotificationStore.swift drops 2623 -> 1941 lines, well under its 2527 budget, and the budget ratchets down to the new actual. FeedCoordinator's +16 is inline authorization-error wiring with no extractable chunk, so its budget is refreshed to 1255 as known debt already merged on main. No code changes, pure file move plus pbxproj wiring and budget refresh. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Re-merge to clear CONFLICTING after main advanced today. Main brought in: iOS sign-out local-first/offline-safe revocation (#5776), iOS workspace list groups/unread-dots/last-activity previews + shared Unread filter (#5726), Focus/DND-honoring fallback notification sound (#5651), and three Swift file-length-budget refreshes (#6018, #6020, #6025) which is where the conflict landed. Conflict resolution: - .github/swift-file-length-budget.tsv: only conflict, on the GhosttyTerminalView.swift / TerminalController.swift entries. Regenerated the whole budget to merged-tree actual counts via --write-budget (no hunk-surgery); check passes. The slice-lifted TerminalController+ControlWorkspaceContext.swift entry is preserved. - cmux.xcodeproj/project.pbxproj: auto-merged (union), re-normalized via normalize-pbxproj.py; check-pbxproj.sh passes, no ID collisions. - CmuxTerminalCore (slice-owned) kept intact; ghostty submodule pointer matches origin/main; no CmuxControlSocket phantom inherited. Gates: ensure-ghosttykit OK, swift_file_length_budget exit 0, lint-ios-package-conventions exit 0, CmuxTerminalCore swift build OK, app xcodebuild Debug BUILD SUCCEEDED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-merge to clear CONFLICTING after main advanced today. Main brought in: iOS sign-out local-first/offline-safe revocation (#5776), iOS workspace list groups/unread-dots/last-activity previews + shared Unread filter (#5726), Focus/DND-honoring fallback notification sound (#5651), and the budget refreshes #6018/#6020/#6025. #6020 extracted NotificationSoundSettings out of TerminalNotificationStore.swift into a new Sources/NotificationSoundSettings.swift; #6025 deduped the TerminalNotificationStore budget entry. Conflict resolution: - Sources/TerminalNotificationStore.swift: main redesigned this file by extracting the NotificationSoundSettings enum. Took main's version (the extraction wins). The slice's two edits here (import CmuxFoundation; swap ProcessPipeReader.readDataToEndOfFileOrEmpty(from:) for the new FileHandle.readDataToEndOfFileOrEmpty() extension) both targeted the enum body that main moved, so they no longer belong in this file. - Sources/NotificationSoundSettings.swift (main's new extracted file): RE-LIFT. The slice removed the app-target ProcessPipeReader type in favor of a CmuxFoundation FileHandle extension, so main's extracted file would not compile as-is. Re-lifted the slice's migration here: added import CmuxFoundation and switched the errorPipe read to errorPipe.fileHandleForReading.readDataToEndOfFileOrEmpty(). - .github/swift-file-length-budget.tsv: two conflict regions (slice vs main ordering, plus #6025's TerminalNotificationStore dedup). Regenerated the whole budget to merged-tree actual counts via --write-budget; check passes. - cmux.xcodeproj/project.pbxproj: auto-merged, re-normalized; check-pbxproj passes, NotificationSoundSettings.swift wired, no ID collisions. Slice packages (CmuxCore/CmuxRemoteDaemon/CmuxRemoteSession/CmuxRemoteWorkspace) and the modified CmuxFoundation kept intact; ghostty pointer matches origin/main; no CmuxControlSocket phantom inherited. Gates: ensure-ghosttykit OK, swift_file_length_budget exit 0, lint-ios-package-conventions exit 0, all slice packages + CmuxFoundation swift build OK, app xcodebuild Debug BUILD SUCCEEDED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ghostty (#6007) * Extract Ghostty modifier/translation mapping into testable seams Move GhosttyNSView's NSEvent-flags -> ghostty mods mapping and the duplicated libghostty translation-mods -> NSEvent.ModifierFlags loop into free functions (cmuxGhosttyModsFromFlags, cmuxTranslationModifierFlags), and add a KeyboardLayout.textInputCharacter overload that translates against a specific input source ID. No behavior change; this provides the runtime seams for Option/Alt regression coverage (#5993). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add Option/Alt side-bit and composition regression tests (#5993) Covers the consolidated Option/Alt issue: NSEvent device side bits must map to GHOSTTY_MODS_*_RIGHT so libghostty can apply macos-option-as-alt = left|right per physical key, translation flags must keep Option on the composing side, and Option composition must resolve on US (Opt+; -> ...), German (Opt+L -> @), Polish Pro (Opt+A -> a-ogonek), and Canadian-CSA (Opt -> /) layouts. The four right-side-bit tests fail at this commit by design (two-commit regression policy): cmux currently maps both physical Option keys to the generic GHOSTTY_MODS_ALT bit. The follow-up commit adds the side bits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Honor macos-option-as-alt left/right: send sided modifier bits to libghostty (#5993) Set GHOSTTY_MODS_{SHIFT,CTRL,ALT,SUPER}_RIGHT from the NSEvent device-dependent flags when translating modifiers for libghostty, mirroring Ghostty.app's ghosttyMods. With the side bits present: - ghostty_surface_key_translation_mods strips Alt only for the configured side, so with macos-option-as-alt = left the right Option key keeps composing characters (Opt+; -> ..., right-Opt+L -> @ on German, right-Opt+A -> a-ogonek on Polish Pro, AltGr -> / on Canadian-CSA) while the left Option sends Alt/Meta - and mirrored for = right. - The key encoder's legacyAltPrefix and kitty associated-text rules apply per physical side instead of treating every Option as the left key. Closes the gap where cmux captured both Option keys regardless of the macos-option-as-alt setting while standalone Ghostty honored it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard test-only KeyboardLayout input-source seam behind #if DEBUG The inputSourceID-parameterized textInputCharacter overload and its TISCreateInputSourceList helper exist for the Option-composition regression suite; keep them out of the release binary (Greptile P2, matches the existing debugInputSourceIdOverride precedent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Strip Command device bits in suppressed hover mods With sided modifier mapping, subtracting only the generic .command flag left NX_DEVICE*CMDKEYMASK bits behind, producing a SUPER_RIGHT-only mod during command-hover suppression. libghostty stores binding-only mouse mods, so the side-only value compared unequal on every hover event and re-dirtied the screen while suppression was active. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep sided modifier bits off mouse/hover/link paths libghostty stores only binding modifiers for mouse state but compares incoming mods against that stored value, so sided mods on the mouse path made every event with a held right-side modifier register as a modifier change and re-dirty the screen (full-row rebuild per mouse move). Split the boundary: key events keep the side bits macos-option-as-alt needs (cmuxGhosttyModsFromFlags); mouse, hover, and link updates send normalized binding bits (cmuxGhosttyMouseModsFromFlags), restoring cmux's pre-fix mouse behavior. Supersedes the narrower hover-suppression device-bit fix and locks the boundary with a regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update ghostty submodule: binding-mods comparison for modifier tracking Pulls manaflow-ai/ghostty 05c3e2908 (feat-renderer-realized-offscreen): modsChanged and the key callback's link-highlight gate now compare binding mods against binding mods, so the sided modifier bits cmux sends for macos-option-as-alt = left|right no longer register as a modifier change on every key/cursor event (full-screen re-dirty + link refresh per keystroke while a right-side modifier is held). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pin GhosttyKit checksum for ghostty 05c3e2908 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh Swift file length budget for main's notification-sound growth main commit 11f1f6c (#5651) grew TerminalNotificationStore.swift to 2623 lines and Feed/FeedCoordinator.swift to 1255 without refreshing the budget, leaving workflow-guard-tests red on main and on any branch that merges it. Accept that growth here so the merge is buildable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* CmuxCanvas: pure canvas layout model package
Pane frames, canonical gap metrics, drag/resize snapping with guides,
align/distribute/tidy commands, spatial focus navigation, new-pane
placement, and viewport math. Foundation-only, deterministic, 57 tests.
Part of the freeform canvas layout for workspace panes (design in
docs/canvas-layout-design.md).
* Canvas layout: AppKit rendering layer + workspace integration
NSScrollView-based freeform canvas (CanvasRootView) hosting terminal
surfaces directly (portal-detached, no edge reflow) and other panel
kinds via NSHostingView. Pane chrome with title-bar move drag, edge/
corner resize with snap guides, explicit offscreen occlusion lifecycle,
document auto-sizing with origin compensation, overview zoom, reveal.
Workspace gains layoutMode + canvasModel; canvas-aware moveFocus;
WorkspaceContentView branches per mode.
* Canvas: shortcut actions, AppDelegate dispatch, palette commands, View menu
12 new KeyboardShortcutSettings actions (4 bound: ctrl+cmd+C/R/O/T,
8 unbound align/distribute/equalize), all routed through the shared
CanvasActionExecutor; palette commands gated on canvas mode via a new
workspace.canvasLayout context key; View menu entries.
* Canvas: canvas.* socket domain + cmux canvas CLI namespace
New ControlCanvasContext seam slice (info/set_mode/set_frame/align/
reveal/overview) lifted onto the coordinator; TerminalController
witnesses route through CanvasActionExecutor so the socket shares the
single execution path. CLI: cmux canvas info|mode|set-frame|align|
reveal|overview with --workspace/--surface ref normalization.
* Canvas: session persistence, CmuxSettings ShortcutAction parity, localization
SessionWorkspaceSnapshot gains layoutMode + canvasPanes (z-ordered,
panel-id remapped on restore; restore sets layoutMode directly to skip
seed-from-splits). Package ShortcutAction mirrors the 12 canvas actions
(cmux.json bindings + Settings UI defaults). 17 new Localizable.xcstrings
keys translated for all 19 locales.
* Canvas: web docs category, coordinator canvas tests, window/canvas test stubs
Shortcut docs + configuration docs render the new canvas category from
cmux-shortcuts.ts (en/ja messages added). 8 coordinator tests cover the
canvas domain wire contract; stub file gains window + canvas defaults.
* Canvas: center overview zoom explicitly
setMagnification(centeredAt:) lands off-center on large magnification
deltas; anchor by setting the clip bounds origin to contentCenter -
clipSize/2 in the same animation group.
* Canvas: extract AppKit rendering layer into CmuxCanvasUI package
Sources/Canvas kept only app glue (Workspace integration, panel content
mounts, palette/menu/socket surfaces). The package owns the scroll view,
document, pane chrome, drag/resize sessions, lifecycle, and CanvasModel,
behind three seams: CanvasPaneContentMounting (panel content), CanvasTheme
(colors), and pre-localized CanvasPaneChrome strings. 7 CanvasModel tests
cover sync/seed/restore/z-order/alignment.
* Canvas: address review findings
- web shortcut docs: canvas category title/blurb added to all 18 remaining
locale catalogs (greptile P1)
- package ShortcutAction canvas display names localized via the existing
shortcut.*.label xcstrings keys (coderabbit)
- canvas.reveal with no surface and no focused panel returns a dedicated
invalid_state error instead of minting a random UUID (coderabbit)
- unit test covering canvas-mode moveFocus spatial routing (coderabbit)
Not changed: setOcclusion polarity — the parameter is named 'visible'
(ghostty_surface_set_occlusion(surface, visible)), so setRendering maps
correctly; reviewer inferred inverted semantics from the method name.
* Canvas: Cmd+Scroll panning + zoom in/out/reset
Cmd+Scroll routes scroll events to the canvas via a local event monitor
regardless of what is under the cursor, so terminal scrollback no longer
interrupts panning. Zoom in/out (1.25x steps) and actual-size, anchored
at the viewport center, through the full shared pipeline: shortcuts
(opt+cmd+= / opt+cmd+- / opt+cmd+0), palette, canvas.zoom socket verb,
cmux canvas zoom CLI, ShortcutAction mirror, 19-locale labels, web docs.
* Canvas: fix stranded/blank browser webviews
Two root causes. (1) The window portal only re-synced webview geometry
on split-layout events; canvas scrolls/zooms/drags moved anchors with no
such event, leaving webviews at stale window coordinates. CanvasRootView
now fires a coalesced onViewportGeometryChanged callback from every
geometry path (scroll, zoom, drag frame, layout, animated alignment) and
the host nudges BrowserWindowPortalRegistry.scheduleExternalGeometry-
Synchronize. (2) Workspace.renderedVisiblePanelIdsForCurrentLayout was
purely bonsplit-based, so focusing another canvas pane deselected the
browser's bonsplit tab and force-hid its webview (visibleInUI=0, never
restored). Canvas mode now reports every panel as rendered (the portal's
frame clamp handles offscreen), and setLayoutMode reconciles portal
visibility immediately in both directions.
Verified: reveal/tidy/overview round-trips keep webview content glued to
its pane; splits mode unaffected.
* Canvas: gap + snapping settings, typed canvas.zoom direction
canvas.paneGap (0-64, default 16) and canvas.snappingEnabled join the
settings catalog (CanvasCatalogSection), the Settings UI (App section:
stepper + toggle, localized for 19 locales), cmux.json (supported paths,
web schema + en/ja schema descriptions). CanvasLayoutSettings accepts
any numeric UserDefaults representation since the catalog writes Int.
Verified live: gap=40 changes tidy pitch to 680/460.
canvas.zoom direction crosses the seam as a typed
ControlCanvasZoomDirection so the app witness switch is exhaustive
(review finding: unknown strings previously fell through to reset).
* Canvas: tabbed-pane model (CmuxCanvas) + persistence shape
CanvasPane now hosts ordered panelIds + selectedPanelId (CanvasPanelID
distinct from CanvasPaneID; single-tab panes reuse the founding panel's
UUID so existing behavior and persistence stay identical). CanvasLayout
gains tab operations: pane(containing:), addPanel (join/move primitive),
removePanel (pane dies with last tab, selection moves to neighbor),
breakOutPanel, selectPanel. CanvasModel resolves all panel-keyed APIs
through hosting panes (frame/setFrame/bringToFront/snap exclusion/
spatial focus -> neighbor's selected tab) and adds joinPanel/breakOut-
Panel/selectPanel. Session snapshots carry panelIds + selectedPanelId
(pre-tab snapshots decode as single-tab panes; pane identity follows the
first surviving panel through the restore id remap).
12 new tests across both packages; single-tab canvas behavior verified
unchanged on the tagged build. UI tab strip + join/break surfaces come
next.
* Canvas: tab strip UI + canvas.join/break/select_tab verbs
Pane views are now keyed by CanvasPaneID and host a SwiftUI tab strip
(single tab reads as a title bar; multiple tabs render selectable pills
with hover close). One content mount per pane — exactly the selected
tab — switching unmounts/remounts through the existing seam. The root
reconciles panes+mounts+chrome from cached descriptors on external
model mutations, so socket verbs update the UI without a SwiftUI pass.
focusPanel selects the tab in canvas mode so focus and visibility never
diverge. New verbs: canvas.join {surface,target_surface}, canvas.break,
canvas.select_tab; CLI: cmux canvas join/break/select-tab; 2 new
coordinator tests (146 green).
Preflight on tagged build: join renders a two-tab strip, select-tab
switches mounted terminals (scrollback intact), break returns the tab
to its own pane.
* Canvas: browser focus ring, frame-synced webview tracking, blank-webview fix
Three dogfood-reported browser flaws:
- Focus ring occluded: window-portal webviews float above the pane's
layer border; hosted canvas content now insets 2px so the ring stays
visible (terminals unaffected, they render under the layer border).
- Webview lagging pane resize: onViewportGeometryChanged now syncs each
browser anchor synchronously (BrowserWindowPortalRegistry.synchronize-
ForAnchor) per drag/scroll frame, with the coalesced scheduled pass as
settle-up, matching split-divider per-frame behavior.
- Webviews going blank: the hidden-webview discard (300s) fires when
panel-level visibility flips false (stale bonsplit pane ownership under
canvas mode) and nothing on the canvas path ever restored. The canvas
mount now drives panel-level webview visibility directly: mount/render
-> visible (restores a discarded webview), occlude/unmount -> hidden
(discard timer may reclaim offscreen browsers, correctly).
* Canvas: satisfy Swift file-length budget gate
Split CanvasRootView into core + PaneGestures + Viewport extension
files (617 -> 394/101/125; gesture and viewport conformances are
coherent units; touched members widened private -> internal within the
package). Split the window-domain test stubs out of the over-threshold
stub file. Budget bumps for in-place feature growth that belongs where
it is: CLI canvas namespace, shortcut enum cases, settings rows,
Workspace hooks, persistence snapshot fields, unit test (+17/+12/+12/+7
app entry hooks).
* Canvas: tab cycling via next/prev-surface shortcuts + tabs in canvas.info
selectNextSurface/selectPreviousSurface branch to canvas-pane tab
cycling (wrapping, focus follows) when the focused pane has multiple
tabs, falling back to bonsplit semantics otherwise — cmd+shift+]/[ now
does the expected thing on a tabbed canvas pane. canvas.info panes gain
surface_ids/surface_refs + selected_surface_id/ref, and the focused
flag uses containment so multi-tab panes report correctly. Verified
live: shortcut cycling flips selection with focus, and tab groups
persist across app relaunch.
* Canvas: Cmd+T opens a tab in the focused canvas pane
newTerminalSurfaceInFocusedPane joins the new panel into the focused
panel's canvas pane (sync first so the panel exists in the model), so
Cmd+T matches workspace-pane tab semantics; Cmd+D / Cmd+Shift+D remain
the new-floating-pane shortcuts (split path -> placer). Verified live:
focused pane [surface:3] -> [surface:3, surface:23] selected.
Also: groundwork for inline browser hosting on canvas (environment flag
cmuxCanvasInlineBrowserHosting wired into BrowserPanelView's hosting
decision) to fix webview content trailing during pans at the root.
Parked default-off: the inline slot mis-lays out under the canvas's
flipped document hierarchy (small offset rect); per-frame portal anchor
sync remains until that layout path is fixed.
* Canvas: portal/inline hosting ownership flag (inline still parked)
BrowserPanel.canvasInlineHostingActive marks webviews owned by a canvas
pane's inline host; the workspace portal-visibility reconciler and the
canvas geometry sync skip such panels so they cannot rebind the webview
into the window portal (this was the inline-hosting tug-of-war: 4 stray
portal binds after inline takeover). With the war fixed, inline content
renders live and stable, but the webview frame inside the slot still
lays out partial-width/right-aligned, so the env flag stays default-off
for dogfood; next step is the pinHostedWebView frame-reset path under
the canvas hierarchy.
* Canvas: runtime debug flag + frame diagnostics for inline browser hosting
canvasInlineBrowserHostingDebug (launch-time UserDefaults bool) flips
inline hosting on for diagnosis without a rebuild; the inline update
path logs host/slot/webview frames, inspector presence, and companions
(DEBUG only). Diagnosis so far: pinning is correct per host (web ==
slot == host bounds once settled), no phantom inspector split — the
misplacement comes from MULTIPLE live BrowserPanelView hosts competing
for one webview (orphan fragments render at dead hosts' positions).
Next: audit canvas mount lifecycle for double-mounts and the
browser-open placement=reuse flow. Flag remains default-off.
* Canvas: focused pane raises to front; portal settle-refresh + z-order
Focusing a pane through ANY entrypoint now brings it to the front:
click focus raises immediately in the gesture path, and sync() raises
the pane hosting focusedPanelId (covers keyboard, palette, socket, and
webview-click focus). Browser portal z-priorities mirror canvas z-order
(front pane's webview stacks above a back pane's), re-derived on every
layout change. New onViewportSettled callback (didEndLiveScroll/Magnify)
force-refreshes every portal-hosted browser so content cannot rest at a
stale frame after a pan — addressing dogfood screenshots where page
content stayed window-fixed while its pane scrolled.
Verified live: focusing back pane surface:8 moved it to the z-order
front. Known residual: during the pan itself, portal content still
trails (inherent to window-portal hosting; inline hosting work
continues).
* Canvas: pin hosted panel content with constraints (root cause of shifted webviews)
NSHostingView self-sizes to SwiftUI's ideal size under autoresizing
(sizingOptions), so hosted canvas content could settle at a small
intrinsic rect instead of filling its pane. The browser portal anchors
live inside that hosting view, so webview content rendered at the wrong
rect in BOTH hosting modes — this is the mechanism behind dogfood
screenshots where page content sat shifted/partial relative to the pane.
Hosted mounts now pin to the content container with constraints and set
sizingOptions = []; terminal mounts keep frame+autoresizing. Verified
inline-flag run: focused browser pane fills edge-to-edge.
* Merge origin/main; reconcile budget for main-side untracked growth
TerminalNotificationStore.swift (+96, grown on main by #5651 without a
budget bump) and FeedCoordinator.swift (+16) exceed the budgets recorded
on main itself — main's gate is currently red independent of this
branch. Bump both here so this PR's merge CI reflects only its own
debt.
* Canvas: inline browser hosting ON by default
With the hosting-view self-sizing root cause fixed (a106b6b) and the
portal ownership war resolved (50f7adf), inline hosting now verifies
clean: webviews fill their panes pixel-perfect at 1x, multiple browser
panes render simultaneously with correct clipping, z-order stacks
natively (focused pane's browser above a back pane's), content scales
with overview/zoom magnification, and pans move content in the same
CoreAnimation transaction as the pane — the window-portal trailing
Aziz reported is structurally gone. Launch-time default
canvasInlineBrowserHostingDisabled reverts to portal hosting if needed.
* Canvas: dragging a multi-tab pane by its tab strip works
Tab pills are SwiftUI Buttons and consume mouse-down, so the AppKit
title-bar drag path never engaged on panes with several tabs (dogfood:
'can't really drag a multi-tab pane'). Each pill now carries a
simultaneous DragGesture (4pt threshold) that relays its translation to
the same pane-drag delegate path (snap + guides + Command suspend
included); taps still select, hover-close still works. Translation is
pane-local, which equals document points at any magnification.
* Canvas: tab bar matches workspace split pane tabs
Rebuilt the canvas pane strip to bonsplit's tab anatomy (TabBarMetrics
parity): 30pt bar, full-height square tabs with 1px trailing separators,
selected/hover rectangle fills, 14pt icon slot that swaps to a 16pt
close button on hover, 11pt middle-truncated titles, 220pt max tab
width, active/inactive text alphas 0.82/0.62. Single-tab panes render
as a real tab bar too, matching split panes. Tab drags still relay into
the pane-drag path; clicks select; hover-close closes.
* Canvas: tab strip events back on the AppKit path (drag regression fix)
The simultaneous SwiftUI DragGesture on tab pills made dragging fight
the Button recognizers and feel slow (gesture-system delivery vs
NSEvent tracking), and the nested hover-close Button got swallowed —
dogfood: 'pane fighting against me', 'sometimes can't close tabs'.
The strip is now render-only SwiftUI: tabs report their tab/close hit
rects via PreferenceKey, CanvasPaneView owns ALL strip mouse events
(hitTest claims the title-bar region), routes drags through the
original fast AppKit drag session (snap, guides, autoscroll), and
resolves clicks at mouse-up against the reported rects (close beats
select; sub-threshold movement still counts as a click). Close hit
rect is padded for forgiving clicks.
* Canvas: fix terminal row duplication under canvas zoom
Surface pixel sizing used convertToBacking, which folds ancestor
transforms — the canvas NSScrollView magnification — into the backing
size: zooming out re-typeset the terminal at a shrunken pixel grid and
rendered duplicated prompt rows (dogfood screenshots, reproduced at 3x
zoom-out). Pixel size now derives from the window backingScaleFactor
only (identical in split mode), so terminals keep their logical pixel
density and the magnification scales them purely visually. Verified:
deep zoom-out shows single rows everywhere; zoom round-trip + live
typing keeps the grid intact.
* Canvas: rendered-panel set is each pane's selected tab
With tabbed panes, reporting ALL panels as rendered made the terminal
window portal float unmounted background-tab terminals at stale frames
(chromeless prompt slivers found in regression sweep). The canvas
branch of renderedVisiblePanelIdsForCurrentLayout now returns exactly
the selected panel of each canvas pane, matching mount reality; this
also restores correct hidden-discard eligibility for background-tab
browsers and hibernation visibility semantics. Verified: full verb
sweep + tidy + overview shows no floating fragments.
* Canvas: tab tear-out (Option+drag) and drop-to-join
Option+dragging a tab of a multi-tab pane tears it out into its own
pane whose tab bar lands under the cursor, and the drag continues
seamlessly (sessions now route by session pane, not the event-source
view, since AppKit keeps tracking on the mouse-downed view). Dropping a
single-tab pane onto another pane's tab bar joins it as a tab there —
the canvas twin of bonsplit's tab drop — resolved front-most-first
against pane bar rects. Plain drags still move the pane (Aziz's
requested behavior); Option is the tear-out modifier so the two never
conflict. Gesture paths can't be socket-driven: needs hand
verification.
* Canvas: Option+drag on a single-tab pane degrades to a pane drag
breakOutPanel correctly no-ops on single-tab panes, but the tear-out
entry then started no drag session, leaving Option+drag dead. It now
falls back to a normal pane-drag session so the modifier never inerts
the gesture.
* ci: re-trigger workflows (push events dropped during org API throttling)
* Budget: account for zoom-fix comments (+6 GhosttyTerminalView) and canvas tab hook (+1 Workspace)
* Canvas: corner resize cursors, viewport restore on workspace switch, toolbar mode toggle
Three Aziz dogfood items:
- Corner bands now show diagonal resize cursors (NW-SE / NE-SW) via the
private window-resize cursors with public fallbacks; matches the
existing horizontal/vertical edge cursors.
- Switching workspaces away and back restores the exact canvas viewport
(center + zoom) instead of snapping to a default. CanvasModel persists
savedViewport (canvas coords); the view re-applies it when becoming
visible. Fixed via instrumentation: restore must defer until the
scroll view is sized (contentSize>1) or it lands at a garbage origin,
and saves are suppressed while a restore is pending/in-flight so the
programmatic scroll can't overwrite the saved value with a transient.
- Discoverable Splits|Canvas toolbar toggle: NSSegmentedControl in
WindowToolbarController, selection driven by Workspace.layoutMode and
synced via a new .workspaceLayoutModeDidChange notification (covers
shortcut/palette/menu/toolbar entrypoints), localized 19 locales.
Viewport restore verified: workspace switch away+back lands pixel-identical.
* Browser: collapse omnibar accessories into overflow menu at narrow width
At narrow pane widths (canvas or split) the browser chrome clipped: the
address field vanished and trailing accessory buttons ran off the pane
edge. The chrome now measures its width (preference key) and below 420pt
collapses the plain-action buttons (focus mode, screenshot, React Grab,
dev tools) into a single overflow menu, keeping nav + address field +
the popover-anchored profile/theme buttons. Shared with split mode, so
narrow splits benefit too. Verified: 340pt pane shows address field +
overflow + profile/theme with no clipping; 720pt shows the full row.
* Canvas: one-time Command+scroll discovery hint
Scrolling inside a pane (content consumes it, canvas doesn't pan) is the
teachable moment for Command+scroll panning. After ~1.2s of in-pane
scrolling the canvas shows a soft scale+fade pill — 'Command+scroll pans
the canvas from anywhere' — once per session, auto-dismissing after
~3.4s. Debounce and dismiss use cancellable Tasks wired to teardown (not
the banned asyncAfter). The local scroll monitor distinguishes
command-scroll (pans), in-pane plain scroll (hint), and empty-canvas
scroll (already pans, no hint). Hint text crosses the package seam
pre-localized (19 locales). Command-scroll + hint split into
CanvasRootView+CommandScroll.swift to keep the core under the file
budget. Gesture-triggered, so needs hand-verification.
* Canvas: horizontal scrolling for overflowing pane tabs
Tabs in a narrow pane previously spilled past the pane edge. The tab
strip now clips to the bar and scrolls horizontally: CanvasPaneView
owns the title bar's scrollWheel (it already claims the region for
drag/click routing, so a SwiftUI ScrollView can't be used), tracks a
clamped offset against the measured content width, and feeds it to the
render-only strip. Hit frames are reported in the bar-anchored
coordinate space post-offset, so a scrolled-out tab reports a frame
outside the bar and clicks stay correct. Dominant-axis delta lets a
mostly-vertical trackpad swipe still scroll the strip. Verified: 5 tabs
in a 300pt pane stay clipped inside the pane; scroll gesture needs
hand-verification. Exact bonsplit color fidelity is the remaining
self-contained follow-up.
* canvas: port bonsplit tab fill/text colors for visual parity
Active/hover tab fills now derive from the pane background using the same
lighten-on-dark / darken-on-light treatment as bonsplit's TabBarColors,
instead of a flat Color.primary.opacity. Text uses system label colors.
Threads barBackground (paneBackground) through CanvasPaneTitleBarView and
CanvasPaneTabItem, rebuilding the title bar when the pane background changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: clear terminal portal on entering canvas to kill ghost artifacts
Toggling splits->canvas left split-mode terminal surfaces floating at
their old split frames over the canvas (Aziz dogfood), persisting until
some later layout event happened to reconcile portal visibility.
setLayoutMode reconciled only the browser portal, never the terminal one.
Entering canvas now calls hideAllTerminalPortalViews() so the portal layer
is cleared immediately; the canvas mount re-shows each visible pane's
selected terminal via its portal-detach path. Leaving canvas reconciles to
the split-mode rendered set. Relaxed reconcileTerminalPortalVisibility...
from private to internal so the canvas extension can call it.
Repro (3x rapid splits<->canvas, screenshot with no interaction): green
stale terminal block over empty canvas pre-fix; clean post-fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: trackpad pinch + option-scroll zoom (mouse-friendly)
Zoom was keyboard-only. Add two pointer paths that reuse the viewport
magnification machinery:
- Trackpad pinch: intercept .magnify in the canvas input monitor and
forward to scrollView.magnify(with:). Without intercepting, a pane
swallows the gesture (Ghostty font-zoom), so pinching over a terminal
never zoomed the canvas. Native forwarding anchors at the gesture point
and fires didEndLiveMagnify so portals settle through the normal path.
- Mouse option+scroll: a mouse has no pinch, so option+scroll zooms toward
the cursor via setMagnification(centeredAt:). Cmd+scroll stays pan; plain
scroll stays pane content. A debounced onViewportSettled re-anchors
portals since synthesized magnification never fires didEndLiveMagnify.
Verified shared machinery via canvas zoom out (renders all panes at smaller
scale, no ghosts). Raw pinch/scroll input is not socket-drivable; those
input paths are hand-verify.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: plain-drag tab tear-out/move for split-layout parity
Dragging a tab out of a pane required holding Option, and a plain drag on
the tab moved the whole pane — inverted from split layout, where dragging a
tab manipulates the tab. Aziz: tabs must drag out into their own pane and
move between panes with full parity.
Now a plain drag that begins on a tab (not its close glyph) of a multi-tab
pane tears that tab into its own pane and keeps dragging it; dropping on
another pane's tab bar joins it there (existing join-on-drop path). Single-
tab panes and drags on the empty title-bar area still move the whole pane.
The tear-out/join model ops are unchanged — only the input trigger moved
from Option+mouseDown to plain drag-activation in CanvasPaneView, so a plain
click still selects the tab.
Tab drag is gesture-only (not socket-drivable) = hand-verify; the underlying
tear-out/join were already verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: don't leak internal TabManager class name in socket API error
greptile P1: the canvas.* API returned message "TabManager not available",
exposing an internal cmux class name to cmux canvas CLI callers, violating
the user-facing error rule that API error bodies describe failures in
product terms. Changed to "No active cmux window", matching the sibling
canvas error messages ("Workspace not found", "Canvas pane not found").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: show Cmd+scroll discovery hint at top of canvas, not bottom
Aziz preference: the one-time pill teaching Cmd+scroll-pans reads better
anchored near the top of the canvas. Switch the pill constraint from
bottomAnchor (-24) to topAnchor (+24). Scale+fade animation is
non-directional so top placement needs no other change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: first-tab tear-out fix, Cmd+drag pane move, drag drop indicator
Three tab-drag improvements from Aziz dogfood (implemented by a subagent,
reviewed + Task A socket-verified here):
1. First/founding tab can now tear out. CanvasModel.breakOutPanel minted the
new pane id from the panel UUID; a single-tab pane's id equals its founding
panel's UUID, so for the founding tab that id collided with the source
pane and layout.breakOutPanel's !contains guard rejected it — the first
tab could never tear out. Mint a fresh UUID for the torn-out pane instead.
Verified via canvas.break on a founding panel (45->46 panes, tab tears out).
Adds a CanvasLayout founding-panel break-out unit test.
2. Cmd+drag a tab moves the whole pane (skips tear-out), mirroring Cmd+scroll
targeting the canvas instead of pane content. Guarantees a move handle even
when the tab bar overflows and has no empty title-bar region.
3. Drop indicator during a drag: CanvasGuidesView draws an accent highlight on
the target pane's tab bar when the drag would join there, computed from the
same predicate that commits the join on drop. Clears over empty canvas and
on drag end.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* canvas: add set-viewport + new-pane CLI/socket verbs, report viewport in info
Closes the two CLI-coverage gaps Aziz flagged. Both flow through the typed
canvas.* seam (context protocol -> coordinator -> CLI), 1:1 with the socket.
- canvas.set_viewport / `cmux canvas set-viewport --x --y [--zoom]`: centers
the viewport on a canvas point and optionally sets magnification (explicit
clip-origin math, mirrors setMagnification). Makes Cmd+scroll pan and
pinch/option-scroll zoom scriptable.
- canvas.info now reports `magnification` and `viewport_center` (omitted in
splits), so zoom/center are inspectable — and pan/zoom become socket-testable
instead of eyeball-only.
- canvas.new_pane / `cmux canvas new-pane [--type terminal|browser]`: opens a
standalone canvas pane via Workspace.openNewCanvasPane (creates the surface,
does NOT join as a tab, placer positions it, focus+reveal), returns the new
surface ref. New .created(mode:surfaceID:) resolution case.
Socket-verified: info shows magnification/center; set-viewport --x 600 --y 400
--zoom 0.5 reads back 0.5 @ (599.86,399.78); new-pane terminal+browser bump the
pane count and return refs; invalid type rejected. 155 control-socket tests
pass (+8). Adds coordinator tests + context stubs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
With notifications turned off for cmux, or while a macOS Focus / Do Not Disturb mode is active, cmux still plays its notification sound. The OS suppresses the banner, but the sound keeps playing.
The cause is the out-of-band fallback path: when
getNotificationSettingsreturns.denied, or delivery fails, cmux plays the sound directly viaNotificationSoundSettings.playSelectedSound()(NSSound), which the OS does not gate. TheUNUserNotificationCenterpath (content.sound) is gated correctly and is untouched.Fixes #5650.
Fix (two complementary halves)
1. Denied authorization silences the fallback (TCC-independent — this is what fixes the issue's repro).
TerminalNotificationStore.fallbackEffects(_:authorizationState:)is the shared policy seam for every out-of-band fallback call site (FeedCoordinator's denied branch, its just-declined-the-prompt branch, and the store's unauthorizedscheduleUserNotificationfallback). Only.deniedstrips the sound: a user who turned cmux notifications off asked for silence. Fresh installs (.notDetermined, deferred prompt) and granted states keep the audible fallback, and delivery-failure fallbacks for authorized users are unchanged.2. Focus / DND gate on the playback path (best-effort).
playSelectedSound()reads the Focus daemon's assertion store (~/Library/DoNotDisturb/DB/Assertions.json) fresh for every play on a background utility queue (no main-actor disk I/O), and hops back to the main queue to play. Non-emptystoreAssertionRecordsmeans a Focus is active. Fails open on any read/parse error.Empirical caveat, verified live in a dev build on macOS 26.4.1: the assertion store is TCC-protected (Full Disk Access bucket). On a default install the in-app read fails (
storeReadable=0) and this gate never suppresses — which is exactly why half 1 exists and carries the issue. The gate engages for FDA-granted installs and also covers the app-frontmost suppressed-banner path there. Real Focus detection without FDA would needINFocusStatusCenter(entitlement + authorization prompt) — out of scope here.previewSound) does not go throughplaySelectedSound, so previews still play.notification.sound.focusGate suppressed=… storeReadable=…) and honorCMUX_DEBUG_DND_ASSERTIONS_PATHfor fixture-driven end-to-end testing.Tests
Nine runtime tests in the already-wired
NotificationSoundSettingsTests(no new test file, noproject.pbxprojchange), in three red/green commit pairs:.deniedstrips the fallback sound, leaves other effects intact; all other authorization states keep it.Live verification (tagged dev build)
Summary by CodeRabbit