From d05fb652ed51727300f701e5c2f71f5624c64cdb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 09:09:57 -0800 Subject: [PATCH 1/8] macos: update AGENTS.md --- AGENTS.md | 7 ------- macos/AGENTS.md | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21645e4d2..c6bd79b0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,6 @@ A file for [guiding coding agents](https://agents.md/). - macOS app: `macos/` - GTK (Linux and FreeBSD) app: `src/apprt/gtk` -## macOS App - -- Do not use `xcodebuild` -- Use `zig build` to build the macOS app and any shared Zig code -- Use `zig build run` to build and run the macOS app -- Run Xcode tests using `zig build test` - ## Issue and PR Guidelines - Never create an issue. diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 6321808b8..50e91781d 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -1,3 +1,9 @@ # macOS Ghostty Application - Use `swiftlint` for formatting and linting Swift code. +- If code outside of this directory is modified, use + `zig build -Demit-macos-app=false` before building the macOS app to update + the underlying Ghostty library. +- Use `xcodebuild` to build the macOS app, do not use `zig build` + (except to build the underlying library as mentioned above). +- Run unit tests directly with `xcodebuild` From ea8bf17df8b86b055f4fcc209cfe31e603928d3a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 09:16:03 -0800 Subject: [PATCH 2/8] macos: use combine to coalesce bell values --- macos/Sources/App/macOS/AppDelegate.swift | 140 ++++++++++++++---- macos/Sources/Features/Splits/SplitTree.swift | 52 +++++++ .../Terminal/BaseTerminalController.swift | 24 +++ 3 files changed, 188 insertions(+), 28 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 028d4506c..0624d28cd 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Combine import UserNotifications import OSLog import Sparkle @@ -151,6 +152,21 @@ class AppDelegate: NSObject, /// Signals private var signals: [DispatchSourceSignal] = [] + /// Cancellables used for app-level bell badge tracking. + private var bellTrackingCancellables: Set = [] + + /// Per-window bell observation cancellables keyed by controller identity. + private var windowBellCancellables: [ObjectIdentifier: AnyCancellable] = [:] + + /// Current bell state keyed by terminal controller identity. + private var windowBellStates: [ObjectIdentifier: Bool] = [:] + + /// Cached permission state for dock badges. + private var canShowDockBadgeForBell: Bool = false + + /// Prevent repeated badge permission prompts. + private var hasRequestedDockBadgeAuthorization: Bool = false + /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? @@ -254,6 +270,9 @@ class AppDelegate: NSObject, name: Ghostty.Notification.ghosttyNewTab, object: nil) + // Track per-window bell state and keep the dock badge in sync. + setupBellBadgeTracking() + // Configure user notifications let actions = [ UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") @@ -327,8 +346,8 @@ class AppDelegate: NSObject, // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil - // Clear the dock badge when the app becomes active - self.setDockBadge(nil) + // Recompute the dock badge based on active terminal bell state. + syncDockBadgeToTrackedBellState() // First launch stuff if !applicationHasBecomeActive { @@ -783,41 +802,105 @@ class AppDelegate: NSObject, } } + /// Sets up observation for all terminal window controllers and aggregates whether any + /// associated surface has an active bell. + private func setupBellBadgeTracking() { + let center = NotificationCenter.default + Publishers.MergeMany( + center.publisher(for: NSWindow.didBecomeMainNotification).map { _ in () }, + center.publisher(for: NSWindow.willCloseNotification).map { _ in () } + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshTrackedTerminalWindows() + } + .store(in: &bellTrackingCancellables) + + refreshTrackedTerminalWindows() + ghosttyUpdateBadgeForBell() + } + + private func refreshTrackedTerminalWindows() { + let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } + let controllersByID = Dictionary(uniqueKeysWithValues: controllers.map { (ObjectIdentifier($0), $0) }) + let trackedIDs = Set(windowBellCancellables.keys) + let currentIDs = Set(controllersByID.keys) + + for id in trackedIDs.subtracting(currentIDs) { + windowBellCancellables[id]?.cancel() + windowBellCancellables[id] = nil + windowBellStates[id] = nil + } + + for (id, controller) in controllersByID where windowBellCancellables[id] == nil { + windowBellCancellables[id] = makeWindowBellCancellable(controller: controller, id: id) + } + + syncDockBadgeToTrackedBellState() + } + + private func makeWindowBellCancellable( + controller: BaseTerminalController, + id: ObjectIdentifier + ) -> AnyCancellable { + controller.surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) + .map { $0.values.contains(true) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] hasBell in + self?.windowBellStates[id] = hasBell + self?.syncDockBadgeToTrackedBellState() + } + } + + private func syncDockBadgeToTrackedBellState() { + let anyBell = windowBellStates.values.contains(true) + let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell + + if wantsBadge && !canShowDockBadgeForBell && !hasRequestedDockBadgeAuthorization { + ghosttyUpdateBadgeForBell() + } + + setDockBadge(wantsBadge && canShowDockBadgeForBell ? "•" : nil) + } + private func ghosttyUpdateBadgeForBell() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in - switch settings.authorizationStatus { - case .authorized: - // Already authorized, check badge setting and set if enabled - if settings.badgeSetting == .enabled { - DispatchQueue.main.async { - self.setDockBadge() - } - } + DispatchQueue.main.async { + switch settings.authorizationStatus { + case .authorized: + // Already authorized, check badge setting and set if enabled + self.canShowDockBadgeForBell = settings.badgeSetting == .enabled + self.syncDockBadgeToTrackedBellState() - case .notDetermined: - // Not determined yet, request authorization for badge - center.requestAuthorization(options: [.badge]) { granted, error in - if let error = error { - Self.logger.warning("Error requesting badge authorization: \(error)") - return - } + case .notDetermined: + guard !self.hasRequestedDockBadgeAuthorization else { return } + self.hasRequestedDockBadgeAuthorization = true + + // Not determined yet, request authorization for badge + center.requestAuthorization(options: [.badge]) { granted, error in + if let error = error { + Self.logger.warning("Error requesting badge authorization: \(error)") + return + } - if granted { - // Permission granted, set the badge DispatchQueue.main.async { - self.setDockBadge() + self.canShowDockBadgeForBell = granted + self.syncDockBadgeToTrackedBellState() } } + + case .denied, .provisional, .ephemeral: + // In these known non-authorized states, do not attempt to set the badge. + self.canShowDockBadgeForBell = false + self.syncDockBadgeToTrackedBellState() + + @unknown default: + // Handle future unknown states by doing nothing. + self.canShowDockBadgeForBell = false + self.syncDockBadgeToTrackedBellState() } - - case .denied, .provisional, .ephemeral: - // In these known non-authorized states, do not attempt to set the badge. - break - - @unknown default: - // Handle future unknown states by doing nothing. - break } } } @@ -886,6 +969,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) TerminalController.all.forEach { $0.relabelTabs() } + syncDockBadgeToTrackedBellState() // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 86932b1bb..30caae0da 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,4 +1,5 @@ import AppKit +import Combine /// SplitTree represents a tree of views that can be divided. struct SplitTree { @@ -1215,6 +1216,57 @@ extension SplitTree: Collection { } } +// MARK: SplitTree Combine + +extension SplitTree { + /// Builds a publisher that emits current values for all leaf views keyed by view ID. + /// + /// The returned publisher emits a full `[ViewType.ID: Value]` snapshot whenever any leaf view + /// publishes through the provided publisher key path. + func valuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[ViewType.ID: Value], Never> { + // Flatten the split tree into a list of current leaf views. + let views = map { $0 } + guard !views.isEmpty else { + // If there are no leaves, immediately publish an empty snapshot. + // `Just([:])` keeps the return type simple and makes downstream usage easy. + return Just([:]).eraseToAnyPublisher() + } + + // Capture each view's current value up front. + // We key by `ViewType.ID` so updates can replace the correct entry later. + // This avoids waiting for all views to emit before consumers see data. + let initial = Dictionary(uniqueKeysWithValues: views.map { view in + (view.id, view[keyPath: valueKeyPath]) + }) + + // Build one publisher per view from the requested key path. + // Each emission is mapped into `(id, value)` so we know which entry changed. + // `MergeMany` combines all per-view streams into a single update stream. + let updates = Publishers.MergeMany(views.map { view in + view[keyPath: publisherKeyPath] + .map { (view.id, $0) } + .eraseToAnyPublisher() + }) + + return updates + // Accumulate updates into a full "latest value per ID" dictionary. + // This turns incremental events into complete state snapshots. + .scan(initial) { state, update in + var state = state + state[update.0] = update.1 + return state + } + // Emit the initial snapshot first so subscribers always get a + // complete value dictionary immediately upon subscription. + .prepend(initial) + // Hide implementation details and expose a stable API type. + .eraseToAnyPublisher() + } +} + // MARK: Structural Identity extension SplitTree.Node { diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9f65d35ce..1d5db199c 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -222,6 +222,30 @@ class BaseTerminalController: NSWindowController, // MARK: Methods + /// Creates a publisher for values on all surfaces in this controller's tree. + /// + /// The publisher emits a dictionary of surface IDs to values whenever the tree changes + /// or any surface publishes a new value for the key path. + func surfaceValuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[Ghostty.SurfaceView.ID: Value], Never> { + // `surfaceTree` can be replaced entirely when splits are added/removed/closed. + // For each tree snapshot we build a fresh publisher that watches all surfaces + // in that snapshot. + $surfaceTree + .map { tree in + tree.valuesPublisher( + valueKeyPath: valueKeyPath, + publisherKeyPath: publisherKeyPath + ) + } + // Keep only the latest tree publisher active. This automatically cancels + // subscriptions for old/removed surfaces when the tree changes. + .switchToLatest() + .eraseToAnyPublisher() + } + /// Create a new split. @discardableResult func newSplit( From 79ca4daea6565545cf6bce230bf73ff8c94f90ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 09:46:11 -0800 Subject: [PATCH 3/8] macos: try to clean up Appdelegate combine mess --- macos/Sources/App/macOS/AppDelegate.swift | 81 ++++++------------ .../Terminal/BaseTerminalController.swift | 82 +++++++++++++------ 2 files changed, 82 insertions(+), 81 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 0624d28cd..0e6a8dd2a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,6 +1,5 @@ import AppKit import SwiftUI -import Combine import UserNotifications import OSLog import Sparkle @@ -152,12 +151,6 @@ class AppDelegate: NSObject, /// Signals private var signals: [DispatchSourceSignal] = [] - /// Cancellables used for app-level bell badge tracking. - private var bellTrackingCancellables: Set = [] - - /// Per-window bell observation cancellables keyed by controller identity. - private var windowBellCancellables: [ObjectIdentifier: AnyCancellable] = [:] - /// Current bell state keyed by terminal controller identity. private var windowBellStates: [ObjectIdentifier: Bool] = [:] @@ -241,6 +234,12 @@ class AppDelegate: NSObject, name: NSWindow.didBecomeKeyNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillClose), + name: NSWindow.willCloseNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(quickTerminalDidChangeVisibility), @@ -259,6 +258,12 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(terminalWindowHasBell(_:)), + name: .terminalWindowBellDidChangeNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(ghosttyNewWindow(_:)), @@ -270,9 +275,6 @@ class AppDelegate: NSObject, name: Ghostty.Notification.ghosttyNewTab, object: nil) - // Track per-window bell state and keep the dock badge in sync. - setupBellBadgeTracking() - // Configure user notifications let actions = [ UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") @@ -771,6 +773,14 @@ class AppDelegate: NSObject, syncFloatOnTopMenu(notification.object as? NSWindow) } + @objc private func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let controller = window.windowController as? BaseTerminalController else { return } + + windowBellStates[ObjectIdentifier(controller)] = nil + syncDockBadgeToTrackedBellState() + } + @objc private func quickTerminalDidChangeVisibility(_ notification: Notification) { guard let quickController = notification.object as? QuickTerminalController else { return } self.menuQuickTerminal?.state = if quickController.visible { .on } else { .off } @@ -802,57 +812,14 @@ class AppDelegate: NSObject, } } - /// Sets up observation for all terminal window controllers and aggregates whether any - /// associated surface has an active bell. - private func setupBellBadgeTracking() { - let center = NotificationCenter.default - Publishers.MergeMany( - center.publisher(for: NSWindow.didBecomeMainNotification).map { _ in () }, - center.publisher(for: NSWindow.willCloseNotification).map { _ in () } - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshTrackedTerminalWindows() - } - .store(in: &bellTrackingCancellables) - - refreshTrackedTerminalWindows() - ghosttyUpdateBadgeForBell() - } - - private func refreshTrackedTerminalWindows() { - let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } - let controllersByID = Dictionary(uniqueKeysWithValues: controllers.map { (ObjectIdentifier($0), $0) }) - let trackedIDs = Set(windowBellCancellables.keys) - let currentIDs = Set(controllersByID.keys) - - for id in trackedIDs.subtracting(currentIDs) { - windowBellCancellables[id]?.cancel() - windowBellCancellables[id] = nil - windowBellStates[id] = nil - } - - for (id, controller) in controllersByID where windowBellCancellables[id] == nil { - windowBellCancellables[id] = makeWindowBellCancellable(controller: controller, id: id) - } + @objc private func terminalWindowHasBell(_ notification: Notification) { + guard let controller = notification.object as? BaseTerminalController, + let hasBell = notification.userInfo?[Notification.Name.terminalWindowHasBellKey] as? Bool else { return } + windowBellStates[ObjectIdentifier(controller)] = hasBell syncDockBadgeToTrackedBellState() } - private func makeWindowBellCancellable( - controller: BaseTerminalController, - id: ObjectIdentifier - ) -> AnyCancellable { - controller.surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) - .map { $0.values.contains(true) } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] hasBell in - self?.windowBellStates[id] = hasBell - self?.syncDockBadgeToTrackedBellState() - } - } - private func syncDockBadgeToTrackedBellState() { let anyBell = windowBellStates.values.contains(true) let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1d5db199c..ebaf5fd23 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -83,6 +83,9 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// Cancellable for aggregating bell state across all surfaces in this controller. + private var bellStateCancellable: AnyCancellable? + /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. var titleOverride: String? { @@ -134,6 +137,9 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) + + // Setup our bell state for the window + setupBellNotificationPublisher() // Setup our notifications for behaviors let center = NotificationCenter.default @@ -222,30 +228,6 @@ class BaseTerminalController: NSWindowController, // MARK: Methods - /// Creates a publisher for values on all surfaces in this controller's tree. - /// - /// The publisher emits a dictionary of surface IDs to values whenever the tree changes - /// or any surface publishes a new value for the key path. - func surfaceValuesPublisher( - valueKeyPath: KeyPath, - publisherKeyPath: KeyPath.Publisher> - ) -> AnyPublisher<[Ghostty.SurfaceView.ID: Value], Never> { - // `surfaceTree` can be replaced entirely when splits are added/removed/closed. - // For each tree snapshot we build a fresh publisher that watches all surfaces - // in that snapshot. - $surfaceTree - .map { tree in - tree.valuesPublisher( - valueKeyPath: valueKeyPath, - publisherKeyPath: publisherKeyPath - ) - } - // Keep only the latest tree publisher active. This automatically cancels - // subscriptions for old/removed surfaces when the tree changes. - .switchToLatest() - .eraseToAnyPublisher() - } - /// Create a new split. @discardableResult func newSplit( @@ -1494,3 +1476,55 @@ extension BaseTerminalController: NSMenuItemValidation { appliedColorScheme = scheme } } + +// MARK: Combine Methods + +extension BaseTerminalController { + /// Publishes an app-wide notification whenever this terminal window's aggregate + /// bell state changes. + private func setupBellNotificationPublisher() { + bellStateCancellable = surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) + .map { $0.values.contains(true) } + .removeDuplicates() + .sink { [weak self] hasBell in + guard let self else { return } + NotificationCenter.default.post( + name: .terminalWindowBellDidChangeNotification, + object: self, + userInfo: [Notification.Name.terminalWindowHasBellKey: hasBell] + ) + } + } + + /// Creates a publisher for values on all surfaces in this controller's tree. + /// + /// The publisher emits a dictionary of surface IDs to values whenever the tree changes + /// or any surface publishes a new value for the key path. + func surfaceValuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[Ghostty.SurfaceView.ID: Value], Never> { + // `surfaceTree` can be replaced entirely when splits are added/removed/closed. + // For each tree snapshot we build a fresh publisher that watches all surfaces + // in that snapshot. + $surfaceTree + .map { tree in + tree.valuesPublisher( + valueKeyPath: valueKeyPath, + publisherKeyPath: publisherKeyPath + ) + } + // Keep only the latest tree publisher active. This automatically cancels + // subscriptions for old/removed surfaces when the tree changes. + .switchToLatest() + .eraseToAnyPublisher() + } +} + +// MARK: Notifications + +extension Notification.Name { + /// Terminal window aggregate bell state changed. + static let terminalWindowBellDidChangeNotification = Notification.Name("com.mitchellh.ghostty.terminalWindowBellDidChange") + static let terminalWindowHasBellKey = terminalWindowBellDidChangeNotification.rawValue + ".hasBell" +} From 3aca7224159c3b06d5d1b120b47cab4cd89e33b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 09:50:16 -0800 Subject: [PATCH 4/8] macos: further simplication of AppDelegate bell state --- macos/Sources/App/macOS/AppDelegate.swift | 26 +++---------------- .../Terminal/BaseTerminalController.swift | 18 ++++++++++++- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 0e6a8dd2a..dcda887d1 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -151,9 +151,6 @@ class AppDelegate: NSObject, /// Signals private var signals: [DispatchSourceSignal] = [] - /// Current bell state keyed by terminal controller identity. - private var windowBellStates: [ObjectIdentifier: Bool] = [:] - /// Cached permission state for dock badges. private var canShowDockBadgeForBell: Bool = false @@ -234,12 +231,6 @@ class AppDelegate: NSObject, name: NSWindow.didBecomeKeyNotification, object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(windowWillClose), - name: NSWindow.willCloseNotification, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(quickTerminalDidChangeVisibility), @@ -773,14 +764,6 @@ class AppDelegate: NSObject, syncFloatOnTopMenu(notification.object as? NSWindow) } - @objc private func windowWillClose(_ notification: Notification) { - guard let window = notification.object as? NSWindow, - let controller = window.windowController as? BaseTerminalController else { return } - - windowBellStates[ObjectIdentifier(controller)] = nil - syncDockBadgeToTrackedBellState() - } - @objc private func quickTerminalDidChangeVisibility(_ notification: Notification) { guard let quickController = notification.object as? QuickTerminalController else { return } self.menuQuickTerminal?.state = if quickController.visible { .on } else { .off } @@ -813,15 +796,14 @@ class AppDelegate: NSObject, } @objc private func terminalWindowHasBell(_ notification: Notification) { - guard let controller = notification.object as? BaseTerminalController, - let hasBell = notification.userInfo?[Notification.Name.terminalWindowHasBellKey] as? Bool else { return } - - windowBellStates[ObjectIdentifier(controller)] = hasBell + guard notification.object is BaseTerminalController else { return } syncDockBadgeToTrackedBellState() } private func syncDockBadgeToTrackedBellState() { - let anyBell = windowBellStates.values.contains(true) + let anyBell = NSApp.windows + .compactMap { $0.windowController as? BaseTerminalController } + .contains { $0.bell } let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell if wantsBadge && !canShowDockBadgeForBell && !hasRequestedDockBadgeAuthorization { diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ebaf5fd23..51d6a263d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -51,6 +51,9 @@ class BaseTerminalController: NSWindowController, /// Set if the terminal view should show the update overlay. @Published var updateOverlayIsVisible: Bool = false + /// True when any surface in this controller currently has an active bell. + @Published private(set) var bell: Bool = false + /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { self.derivedConfig.focusFollowsMouse @@ -137,7 +140,7 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) - + // Setup our bell state for the window setupBellNotificationPublisher() @@ -1206,6 +1209,17 @@ class BaseTerminalController: NSWindowController, func windowWillClose(_ notification: Notification) { guard let window else { return } + // Emit a final bell-state transition so any observers can clear state + // without separately tracking NSWindow lifecycle events. + if bell { + bell = false + NotificationCenter.default.post( + name: .terminalWindowBellDidChangeNotification, + object: self, + userInfo: [Notification.Name.terminalWindowHasBellKey: false] + ) + } + // I don't know if this is required anymore. We previously had a ref cycle between // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. @@ -1486,8 +1500,10 @@ extension BaseTerminalController { bellStateCancellable = surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) .map { $0.values.contains(true) } .removeDuplicates() + .receive(on: DispatchQueue.main) .sink { [weak self] hasBell in guard let self else { return } + bell = hasBell NotificationCenter.default.post( name: .terminalWindowBellDidChangeNotification, object: self, From 454a89e011c51c1943400aec2788e1aa544b4ad1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 09:59:29 -0800 Subject: [PATCH 5/8] macos: clean up badge request state --- macos/Sources/App/macOS/AppDelegate.swift | 98 +++++++++-------------- 1 file changed, 39 insertions(+), 59 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index dcda887d1..199999a91 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -151,12 +151,6 @@ class AppDelegate: NSObject, /// Signals private var signals: [DispatchSourceSignal] = [] - /// Cached permission state for dock badges. - private var canShowDockBadgeForBell: Bool = false - - /// Prevent repeated badge permission prompts. - private var hasRequestedDockBadgeAuthorization: Bool = false - /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? @@ -339,9 +333,6 @@ class AppDelegate: NSObject, // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil - // Recompute the dock badge based on active terminal bell state. - syncDockBadgeToTrackedBellState() - // First launch stuff if !applicationHasBecomeActive { applicationHasBecomeActive = true @@ -789,67 +780,49 @@ class AppDelegate: NSObject, if ghostty.config.bellFeatures.contains(.attention) { // Bounce the dock icon if we're not focused. NSApp.requestUserAttention(.informationalRequest) - - // Handle setting the dock badge based on permissions - ghosttyUpdateBadgeForBell() } } @objc private func terminalWindowHasBell(_ notification: Notification) { guard notification.object is BaseTerminalController else { return } - syncDockBadgeToTrackedBellState() + syncDockBadge() } - private func syncDockBadgeToTrackedBellState() { - let anyBell = NSApp.windows - .compactMap { $0.windowController as? BaseTerminalController } - .contains { $0.bell } - let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell - - if wantsBadge && !canShowDockBadgeForBell && !hasRequestedDockBadgeAuthorization { - ghosttyUpdateBadgeForBell() - } - - setDockBadge(wantsBadge && canShowDockBadgeForBell ? "•" : nil) - } - - private func ghosttyUpdateBadgeForBell() { + private func syncDockBadge() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in - DispatchQueue.main.async { - switch settings.authorizationStatus { - case .authorized: - // Already authorized, check badge setting and set if enabled - self.canShowDockBadgeForBell = settings.badgeSetting == .enabled - self.syncDockBadgeToTrackedBellState() - - case .notDetermined: - guard !self.hasRequestedDockBadgeAuthorization else { return } - self.hasRequestedDockBadgeAuthorization = true - - // Not determined yet, request authorization for badge - center.requestAuthorization(options: [.badge]) { granted, error in - if let error = error { - Self.logger.warning("Error requesting badge authorization: \(error)") - return - } - + switch settings.authorizationStatus { + case .authorized: + // If we're authorized and allow badges, then set the badge. + if settings.badgeSetting == .enabled { + DispatchQueue.main.async { + self.setDockBadge() + } + } + + case .notDetermined: + // Not determined yet, request authorization for badge + center.requestAuthorization(options: [.badge]) { granted, error in + if let error = error { + Self.logger.warning("Error requesting badge authorization: \(error)") + return + } + + if granted { + // Permission granted, set the badge DispatchQueue.main.async { - self.canShowDockBadgeForBell = granted - self.syncDockBadgeToTrackedBellState() + self.setDockBadge() } } - - case .denied, .provisional, .ephemeral: - // In these known non-authorized states, do not attempt to set the badge. - self.canShowDockBadgeForBell = false - self.syncDockBadgeToTrackedBellState() - - @unknown default: - // Handle future unknown states by doing nothing. - self.canShowDockBadgeForBell = false - self.syncDockBadgeToTrackedBellState() } + + case .denied, .provisional, .ephemeral: + // In these known non-authorized states, do not attempt to set the badge. + break + + @unknown default: + // Handle future unknown states by doing nothing. + break } } } @@ -874,7 +847,12 @@ class AppDelegate: NSObject, _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) } - private func setDockBadge(_ label: String? = "•") { + private func setDockBadge() { + let anyBell = NSApp.windows + .compactMap { $0.windowController as? BaseTerminalController } + .contains { $0.bell } + let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell + let label = wantsBadge ? "•" : nil NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() } @@ -918,7 +896,9 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) TerminalController.all.forEach { $0.relabelTabs() } - syncDockBadgeToTrackedBellState() + + // Update our badge since config can change what we show. + syncDockBadge() // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal From 5389fdfbafea8f45f1a291703d57693d52c31c07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 10:14:21 -0800 Subject: [PATCH 6/8] macos: lint --- macos/Sources/App/macOS/AppDelegate.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 199999a91..194d20037 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -799,7 +799,7 @@ class AppDelegate: NSObject, self.setDockBadge() } } - + case .notDetermined: // Not determined yet, request authorization for badge center.requestAuthorization(options: [.badge]) { granted, error in @@ -807,7 +807,7 @@ class AppDelegate: NSObject, Self.logger.warning("Error requesting badge authorization: \(error)") return } - + if granted { // Permission granted, set the badge DispatchQueue.main.async { @@ -815,11 +815,11 @@ class AppDelegate: NSObject, } } } - + case .denied, .provisional, .ephemeral: // In these known non-authorized states, do not attempt to set the badge. break - + @unknown default: // Handle future unknown states by doing nothing. break @@ -896,7 +896,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) TerminalController.all.forEach { $0.relabelTabs() } - + // Update our badge since config can change what we show. syncDockBadge() From dcb7c9a4b8eace183b1da65eac4c78a1c073f61e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 10:20:26 -0800 Subject: [PATCH 7/8] macos: show the notification count number in the badge --- macos/Sources/App/macOS/AppDelegate.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 194d20037..bcd9a0ffa 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -848,11 +848,11 @@ class AppDelegate: NSObject, } private func setDockBadge() { - let anyBell = NSApp.windows + let bellCount = NSApp.windows .compactMap { $0.windowController as? BaseTerminalController } - .contains { $0.bell } - let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell - let label = wantsBadge ? "•" : nil + .reduce(0) { $0 + ($1.bell ? 1 : 0) } + let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && bellCount > 0 + let label = wantsBadge ? (bellCount > 99 ? "99+" : String(bellCount)) : nil NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() } From dc514c9e116ba32641365702e52760c6365d797f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 10:30:12 -0800 Subject: [PATCH 8/8] build: don't build OpenGL support into imgui on iOS --- src/build/SharedDeps.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 0ca43e78d..9276c9914 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -477,7 +477,9 @@ pub fn add( .freetype = true, .@"backend-metal" = target.result.os.tag.isDarwin(), .@"backend-osx" = target.result.os.tag == .macos, - .@"backend-opengl3" = target.result.os.tag != .macos, + // OpenGL3 backend should only be built on non-Apple targets. + // Apple platforms use Metal (and macOS may also use the OSX backend). + .@"backend-opengl3" = !target.result.os.tag.isDarwin(), })) |dep| { step.root_module.addImport("dcimgui", dep.module("dcimgui")); step.linkLibrary(dep.artifact("dcimgui"));