Repository navigation
Fix terminal surfaces losing theme after config reload #2707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
853003f
8d3c578
14a0980
10f2d7a
352ad20
0519a81
7662dde
6a15254
93a9341
ad605e1
2e9b441
c4378cf
27ddeee
2910b82
1951e3a
2f90041
93d63ad
598ca17
2c3e61d
65db12c
69e6439
08cad14
d4a9ae3
df47ffe
21e9e70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,20 +11,37 @@ import Darwin | |
|
|
||
| final class MainWindowHostingView<Content: View>: NSHostingView<Content> { | ||
| private let zeroSafeAreaLayoutGuide = NSLayoutGuide() | ||
| private let usesSystemSafeArea: Bool | ||
|
|
||
| override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } | ||
| override var safeAreaRect: NSRect { bounds } | ||
| override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } | ||
| override var safeAreaInsets: NSEdgeInsets { | ||
| usesSystemSafeArea ? super.safeAreaInsets : NSEdgeInsetsZero | ||
| } | ||
| override var safeAreaRect: NSRect { | ||
| usesSystemSafeArea ? super.safeAreaRect : bounds | ||
| } | ||
| override var safeAreaLayoutGuide: NSLayoutGuide { | ||
| usesSystemSafeArea ? super.safeAreaLayoutGuide : zeroSafeAreaLayoutGuide | ||
| } | ||
|
|
||
| required init(rootView: Content) { | ||
| if #available(macOS 26.0, *) { | ||
| // On macOS 26, use system safe area so: | ||
| // - Sidebar (.ignoresSafeArea) extends under the glass titlebar | ||
| // - Terminal content respects the titlebar and stays below it | ||
| self.usesSystemSafeArea = true | ||
| } else { | ||
| self.usesSystemSafeArea = false | ||
| } | ||
| super.init(rootView: rootView) | ||
| addLayoutGuide(zeroSafeAreaLayoutGuide) | ||
| NSLayoutConstraint.activate([ | ||
| zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), | ||
| zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), | ||
| zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), | ||
| zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
| ]) | ||
| if !usesSystemSafeArea { | ||
| addLayoutGuide(zeroSafeAreaLayoutGuide) | ||
| NSLayoutConstraint.activate([ | ||
| zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), | ||
| zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), | ||
| zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), | ||
| zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
| ]) | ||
| } | ||
| } | ||
|
|
||
| @available(*, unavailable) | ||
|
|
@@ -2581,6 +2598,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| syncMenuBarExtraVisibility() | ||
| updateController.startUpdaterIfNeeded() | ||
| } | ||
| // Start the titlebar accessory controller on all versions so the | ||
| // notifications popover infrastructure is available. On macOS 26 | ||
| // the visual titlebar items come from SwiftUI .toolbar, but the | ||
| // popover is still managed by the accessory controller. | ||
| titlebarAccessoryController.start() | ||
| windowDecorationsController.start() | ||
| installMainWindowKeyObserver() | ||
|
|
@@ -5810,6 +5831,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| terminalPanel.hostedView.reconcileGeometryNow() | ||
| terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() | ||
| terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") | ||
| // Force each surface to re-derive its config with the correct light/dark | ||
| // conditional state and re-apply the color scheme from the current appearance. | ||
| terminalPanel.surface.reapplyColorSchemeAndConfig() | ||
| refreshedCount += 1 | ||
| } | ||
| #if DEBUG | ||
|
|
@@ -7084,10 +7108,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| window.collectionBehavior.insert(.fullScreenDisallowsTiling) | ||
| } | ||
| window.title = "" | ||
| window.titleVisibility = .hidden | ||
| window.titlebarAppearsTransparent = true | ||
| if #available(macOS 26.0, *) { | ||
| // On macOS 26+, let the system render the native glass titlebar | ||
| window.titleVisibility = .hidden | ||
| window.titlebarAppearsTransparent = false | ||
| } else { | ||
| window.titleVisibility = .hidden | ||
| window.titlebarAppearsTransparent = true | ||
| } | ||
| window.isMovableByWindowBackground = false | ||
| window.isMovable = false | ||
| if #available(macOS 26.0, *) { | ||
| window.isMovable = true | ||
| } else { | ||
| window.isMovable = false | ||
| } | ||
| let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) | ||
| if let restoredFrame { | ||
| window.setFrame(restoredFrame, display: false) | ||
|
|
@@ -10119,6 +10153,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| #endif | ||
|
|
||
| func attachUpdateAccessory(to window: NSWindow) { | ||
| if #available(macOS 26.0, *) { | ||
| // On macOS 26, toolbar buttons are native SwiftUI .toolbar items | ||
| // in the NavigationSplitView. Skip attaching the old titlebar | ||
| // accessory views, but the controller is already started (for | ||
| // notifications popover support). | ||
| return | ||
| } | ||
|
Comment on lines
10155
to
+10162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unify the macOS 26 notifications popover behind one window-scoped source of truth. These changes move 26+ away from 🧭 Possible starting point func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) {
if `#available`(macOS 26.0, *) {
- NotificationCenter.default.post(name: Self.toggleNotificationsPopoverNotification, object: nil)
+ let targetWindow = anchorView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow
+ NotificationCenter.default.post(
+ name: Self.toggleNotificationsPopoverNotification,
+ object: targetWindow
+ )
return
}
titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView)
}Companion changes are still needed so Also applies to: 10171-10179 🤖 Prompt for AI Agents |
||
| titlebarAccessoryController.start() | ||
| titlebarAccessoryController.attach(to: window) | ||
| } | ||
|
|
@@ -10127,7 +10168,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| windowDecorationsController.apply(to: window) | ||
| } | ||
|
|
||
| /// Notification posted on macOS 26 to toggle the SwiftUI notifications popover. | ||
| static let toggleNotificationsPopoverNotification = Notification.Name("cmux.toggleNotificationsPopover") | ||
|
|
||
| func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { | ||
| if #available(macOS 26.0, *) { | ||
| NotificationCenter.default.post(name: Self.toggleNotificationsPopoverNotification, object: nil) | ||
| return | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Notifications popover dismiss/query broken on macOS 26Medium Severity On macOS 26, Additional Locations (1)Reviewed by Cursor Bugbot for commit 21e9e70. Configure here. |
||
| titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { | |
| var description: String? | ||
| var keywords: [String]? | ||
| var restart: CmuxRestartBehavior? | ||
| var autoApply: Bool? | ||
| var workspace: CmuxWorkspaceDefinition? | ||
| var command: String? | ||
| var confirm: Bool? | ||
|
|
@@ -24,6 +25,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { | |
| description: String? = nil, | ||
| keywords: [String]? = nil, | ||
| restart: CmuxRestartBehavior? = nil, | ||
| autoApply: Bool? = nil, | ||
| workspace: CmuxWorkspaceDefinition? = nil, | ||
| command: String? = nil, | ||
| confirm: Bool? = nil | ||
|
|
@@ -32,6 +34,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { | |
| self.description = description | ||
| self.keywords = keywords | ||
| self.restart = restart | ||
| self.autoApply = autoApply | ||
| self.workspace = workspace | ||
| self.command = command | ||
| self.confirm = confirm | ||
|
|
@@ -43,6 +46,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { | |
| description = try container.decodeIfPresent(String.self, forKey: .description) | ||
| keywords = try container.decodeIfPresent([String].self, forKey: .keywords) | ||
| restart = try container.decodeIfPresent(CmuxRestartBehavior.self, forKey: .restart) | ||
| autoApply = try container.decodeIfPresent(Bool.self, forKey: .autoApply) | ||
| workspace = try container.decodeIfPresent(CmuxWorkspaceDefinition.self, forKey: .workspace) | ||
| command = try container.decodeIfPresent(String.self, forKey: .command) | ||
| confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) | ||
|
|
@@ -90,23 +94,33 @@ enum CmuxRestartBehavior: String, Codable, Sendable { | |
| case confirm | ||
| } | ||
|
|
||
| enum CmuxWorkspaceTarget: String, Codable, Sendable { | ||
| /// Apply the layout to the currently selected workspace. | ||
| case current | ||
| /// Create a new workspace (default). | ||
| case new | ||
| } | ||
|
|
||
| struct CmuxWorkspaceDefinition: Codable, Sendable { | ||
| var name: String? | ||
| var cwd: String? | ||
| var color: String? | ||
| var target: CmuxWorkspaceTarget? | ||
| var layout: CmuxLayoutNode? | ||
|
|
||
| init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) { | ||
| init(name: String? = nil, cwd: String? = nil, color: String? = nil, target: CmuxWorkspaceTarget? = nil, layout: CmuxLayoutNode? = nil) { | ||
| self.name = name | ||
| self.cwd = cwd | ||
| self.color = color | ||
| self.target = target | ||
| self.layout = layout | ||
| } | ||
|
|
||
| init(from decoder: Decoder) throws { | ||
| let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| name = try container.decodeIfPresent(String.self, forKey: .name) | ||
| cwd = try container.decodeIfPresent(String.self, forKey: .cwd) | ||
| target = try container.decodeIfPresent(CmuxWorkspaceTarget.self, forKey: .target) | ||
| layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) | ||
|
|
||
| if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { | ||
|
|
@@ -270,6 +284,8 @@ final class CmuxConfigStore: ObservableObject { | |
| return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") | ||
| }() | ||
|
|
||
| private weak var trackedTabManager: TabManager? | ||
| private var autoAppliedWorkspaceIds = Set<UUID>() | ||
| private var cancellables = Set<AnyCancellable>() | ||
| private var localFileWatchSource: DispatchSourceFileSystemObject? | ||
| private var localFileDescriptor: Int32 = -1 | ||
|
|
@@ -292,6 +308,7 @@ final class CmuxConfigStore: ObservableObject { | |
| // MARK: - Public API | ||
|
|
||
| func wireDirectoryTracking(tabManager: TabManager) { | ||
| trackedTabManager = tabManager | ||
| cancellables.removeAll() | ||
|
|
||
| tabManager.$selectedTabId | ||
|
|
@@ -311,6 +328,23 @@ final class CmuxConfigStore: ObservableObject { | |
| } | ||
| .store(in: &cancellables) | ||
|
|
||
| // Separate observer for autoApply: fires on every workspace switch | ||
| // (after a short delay so the workspace is fully visible). | ||
| // Captures the tab ID at emission time so a rapid B→C switch | ||
| // doesn't accidentally apply B's config to C. | ||
| tabManager.$selectedTabId | ||
| .dropFirst() // skip the initial value on subscribe | ||
| .removeDuplicates() | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] tabId in | ||
| guard let tabId else { return } | ||
| // Small delay so the config for the new directory loads first. | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { | ||
| self?.checkAutoApply(forTabId: tabId) | ||
| } | ||
| } | ||
| .store(in: &cancellables) | ||
|
|
||
| if let directory = tabManager.selectedWorkspace?.currentDirectory { | ||
| updateLocalConfigPath(directory) | ||
| } | ||
|
|
@@ -381,6 +415,47 @@ final class CmuxConfigStore: ObservableObject { | |
| loadedCommands = commands | ||
| commandSourcePaths = sourcePaths | ||
| configRevision &+= 1 | ||
| checkAutoApply() | ||
| } | ||
|
|
||
| /// If the selected workspace hasn't been auto-applied this session and a | ||
| /// loaded command has `autoApply: true` with `target: "current"`, execute | ||
| /// it automatically. Tracks applied workspaces so it only fires once per | ||
| /// workspace per app session. | ||
| /// - Parameter forTabId: When provided, only applies if this tab is still | ||
| /// selected, preventing stale delayed applications after rapid switching. | ||
| private func checkAutoApply(forTabId: UUID? = nil) { | ||
| guard let tabManager = trackedTabManager, | ||
| let workspace = tabManager.selectedWorkspace, | ||
| // If a specific tab ID was requested, verify it's still selected. | ||
| forTabId == nil || workspace.id == forTabId, | ||
| !autoAppliedWorkspaceIds.contains(workspace.id), | ||
| // Only auto-apply to workspaces with a single pane — don't tear | ||
| // down user-customized layouts or restored split configurations. | ||
| workspace.panels.count <= 1 | ||
| else { return } | ||
|
Comment on lines
+427
to
+436
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the intent is to skip auto-apply on restored workspaces, you could seed |
||
|
|
||
| // Prefer local config directory; fall back to global config directory | ||
| // so that autoApply commands defined only in the global config still work. | ||
| let baseCwd: String | ||
| if let localPath = localConfigPath { | ||
| baseCwd = (localPath as NSString).deletingLastPathComponent | ||
| } else { | ||
| baseCwd = (globalConfigPath as NSString).deletingLastPathComponent | ||
| } | ||
|
|
||
| guard let command = loadedCommands.first(where: { | ||
| $0.autoApply == true && $0.workspace?.target == .current | ||
| }) else { return } | ||
|
Comment on lines
+447
to
+449
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The filter Consider either logging a warning when a non-applicable command has |
||
|
|
||
| autoAppliedWorkspaceIds.insert(workspace.id) | ||
| CmuxConfigExecutor.execute( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Auto-apply executes local workspace config implicitly on tab/workspace activation without trust/confirmation gating, enabling untrusted cmux.json to trigger actions automatically. Prompt for AI agents |
||
| command: command, | ||
| tabManager: tabManager, | ||
| baseCwd: baseCwd, | ||
| configSourcePath: commandSourcePaths[command.id], | ||
| globalConfigPath: globalConfigPath | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - Parsing | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,30 @@ struct CmuxConfigExecutor { | |
| baseCwd: String | ||
| ) { | ||
| let workspaceName = wsDef.name ?? command.name | ||
| let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) | ||
|
|
||
| // "target": "current" — apply the layout to the selected workspace in-place. | ||
| // If no workspace is selected, skip silently rather than falling through | ||
| // to the name-based create/recreate path. | ||
| if wsDef.target == .current { | ||
| guard let current = tabManager.selectedWorkspace else { return } | ||
| current.setCustomTitle(workspaceName) | ||
| if let color = wsDef.color { | ||
| current.setCustomColor(color) | ||
| } | ||
| if let layout = wsDef.layout { | ||
| // Close all panels except the focused one so applyCustomLayout | ||
| // starts from a single pane and doesn't stack on existing splits. | ||
| let keep = current.focusedPanelId | ||
| let panelIdsToClose = current.panels.keys.filter { $0 != keep } | ||
| for panelId in panelIdsToClose { | ||
| current.closePanel(panelId, force: true) | ||
| } | ||
| current.applyCustomLayout(layout, baseCwd: resolvedCwd) | ||
| } | ||
| return | ||
|
Comment on lines
+96
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch returns before the 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| let restart = command.restart ?? .ignore | ||
|
|
||
| if let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) { | ||
|
|
@@ -118,7 +142,6 @@ struct CmuxConfigExecutor { | |
| } | ||
| } | ||
|
|
||
| let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) | ||
| let newWorkspace = tabManager.addWorkspace(workingDirectory: resolvedCwd) | ||
| newWorkspace.setCustomTitle(workspaceName) | ||
| if let color = wsDef.color { | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: macOS 26 skips accessory attachment, but menu-bar notifications still require attached accessory controllers, making the popover path a no-op.
Prompt for AI agents