From 12cd5372139992acabb13de7c87d98938b422a5e Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Mon, 25 May 2026 22:39:15 -0700 Subject: [PATCH 1/4] fix: handle provider switcher shortcuts in open menu --- .../CodexBar/StatusItemController+Menu.swift | 11 +- ...tatusItemController+ProviderSwitcher.swift | 121 +++++++++++++++++ .../StatusItemController+SwitcherViews.swift | 6 + Sources/CodexBar/StatusItemController.swift | 3 + Sources/CodexBar/StatusItemMenu.swift | 16 ++- .../StatusMenuSwitcherClickTests.swift | 127 ++++++++++++++++++ 6 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+ProviderSwitcher.swift diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index b5cbc33a6..96c7360ff 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -118,6 +118,7 @@ extension StatusItemController { // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu } + self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -126,6 +127,9 @@ extension StatusItemController { func menuDidClose(_ menu: NSMenu) { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) + if ObjectIdentifier(menu) == self.providerSwitcherShortcutMenuID { + self.removeProviderSwitcherShortcutMonitor() + } self.forgetClosedMenu(menu) if wasHostedSubviewMenu { self.refreshOpenMenusIfNeeded() @@ -308,10 +312,9 @@ extension StatusItemController { guard !menu.items.isEmpty else { return [] } var reusableRows: [NSMenuItem] = [] - var index = 0 - if menu.items.first?.view is ProviderSwitcherView { + var index = self.providerSwitcherContentStartIndex(in: menu) + if index > 0 { reusableRows.append(menu.items[0]) - index = 2 } if menu.items.count > index, menu.items[index].view is CodexAccountSwitcherView @@ -344,7 +347,7 @@ extension StatusItemController { context: MenuUpdateContext) { self.performMenuMutationWithoutAnimation { - let contentStartIndex = menu.items.first?.view is ProviderSwitcherView ? 2 : 0 + let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) if let switcherView = menu.items.first?.view as? ProviderSwitcherView { switcherView.updateSelection(context.switcherSelection) switcherView.updateQuotaIndicators() diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift new file mode 100644 index 000000000..b5c30bca6 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -0,0 +1,121 @@ +import AppKit +import CodexBarCore + +final class ProviderSwitcherShortcutEventMonitor { + private let events: NSEvent.EventTypeMask + private let callback: @MainActor (NSEvent) -> NSEvent? + private let observer: CFRunLoopObserver + private var isActive = false + + init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> NSEvent?) { + self.events = events + self.callback = callback + + self.observer = CFRunLoopObserverCreateWithHandler( + nil, + CFRunLoopActivity.beforeSources.rawValue, + true, + 0) + { [events, callback] _, _ in + MainActor.assumeIsolated { + var queuedEvents: [NSEvent] = [] + while let event = NSApp.nextEvent(matching: .any, until: nil, inMode: .default, dequeue: true) { + queuedEvents.append(event) + } + + for event in queuedEvents { + let eventMask = NSEvent.EventTypeMask(rawValue: 1 << event.type.rawValue) + let eventToPost = if events.contains(eventMask) { + callback(event) + } else { + event + } + guard let eventToPost else { continue } + NSApp.postEvent(eventToPost, atStart: false) + } + } + } + } + + deinit { + self.stop() + } + + func start() { + guard !self.isActive else { return } + CFRunLoopAddObserver( + RunLoop.main.getCFRunLoop(), + self.observer, + CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + self.isActive = true + } + + func stop() { + guard self.isActive else { return } + CFRunLoopRemoveObserver( + RunLoop.main.getCFRunLoop(), + self.observer, + CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + self.isActive = false + } +} + +extension StatusItemController { + func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) { + guard Self.menuRefreshEnabled, + self.shouldMergeIcons, + menu.items.first?.view is ProviderSwitcherView + else { + return + } + + self.removeProviderSwitcherShortcutMonitor() + let monitor = ProviderSwitcherShortcutEventMonitor(events: [.keyDown]) { [weak self, weak menu] event in + guard let self, + let menu, + self.openMenus[ObjectIdentifier(menu)] != nil, + menu.items.first?.view is ProviderSwitcherView + else { + return event + } + + return self.handleProviderSwitcherShortcut(event, menu: menu) ? nil : event + } + monitor.start() + self.providerSwitcherShortcutEventMonitor = monitor + self.providerSwitcherShortcutMenuID = ObjectIdentifier(menu) + } + + func removeProviderSwitcherShortcutMonitor() { + self.providerSwitcherShortcutEventMonitor?.stop() + self.providerSwitcherShortcutEventMonitor = nil + self.providerSwitcherShortcutMenuID = nil + } + + func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int { + menu.items.first?.view is ProviderSwitcherView ? 2 : 0 + } + + @discardableResult + func handleProviderSwitcherShortcut(_ event: NSEvent, menu: NSMenu) -> Bool { + if let index = StatusItemMenu.providerSelectionIndex(for: event) { + return self.selectProviderSwitcherSegment(at: index, menu: menu) + } + if let direction = StatusItemMenu.providerNavigationDirection(for: event) { + self.navigateProviderSwitcher(direction) + return true + } + return false + } + + @discardableResult + private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView, + switcherView.handleKeyboardSelection(at: index) + else { + return false + } + self.applyIcon(phase: nil) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index e025ce185..a7fe4c684 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -311,6 +311,12 @@ final class ProviderSwitcherView: NSView { self.applySelection(at: pressedTag) } + func handleKeyboardSelection(at index: Int) -> Bool { + guard self.segments.indices.contains(index) else { return false } + self.applySelection(at: index) + return true + } + private func applySelection(at index: Int) { let selection = self.segments[index].selection self.updateSelection(selection) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 5ddd472a6..779f4e9d8 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -104,6 +104,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? + var providerSwitcherShortcutMenuID: ObjectIdentifier? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? var isReleasedForTesting = false @@ -849,6 +851,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin for task in self.menuRefreshTasks.values { task.cancel() } + self.removeProviderSwitcherShortcutMonitor() self.menuRefreshTasks.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemMenu.swift b/Sources/CodexBar/StatusItemMenu.swift index e16a31438..be8a7b0d7 100644 --- a/Sources/CodexBar/StatusItemMenu.swift +++ b/Sources/CodexBar/StatusItemMenu.swift @@ -61,7 +61,7 @@ final class StatusItemMenu: NSMenu { } } - private nonisolated static func providerNavigationDirection( + nonisolated static func providerNavigationDirection( for event: NSEvent) -> StatusItemMenuProviderNavigationDirection? { guard event.type == .keyDown else { return nil } @@ -76,4 +76,18 @@ final class StatusItemMenu: NSMenu { return nil } } + + nonisolated static func providerSelectionIndex(for event: NSEvent) -> Int? { + guard event.type == .keyDown else { return nil } + let relevantModifiers = event.modifierFlags.intersection([.command, .option, .control, .shift]) + guard relevantModifiers == .command, + let characters = event.charactersIgnoringModifiers, + characters.count == 1, + let number = Int(characters), + (1...9).contains(number) + else { + return nil + } + return number - 1 + } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 793e1f338..a3891c3be 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -233,6 +233,119 @@ struct StatusMenuSwitcherClickTests { #expect(settings.selectedMenuProvider == .codex) } + @Test + func `merged switcher handles command number shortcuts in visible order`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("3", keyCode: 20), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("1", keyCode: 18), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try !controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("9", keyCode: 25), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + } + + @Test + func `provider shortcut monitor is removed when tracked menu closes after switcher rebuild`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = StatusItemMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let switcherItem = NSMenuItem() + switcherItem.view = switcher + menu.addItem(switcherItem) + menu.addItem(.separator()) + + controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + menu.removeAllItems() + controller.menuDidClose(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + @Test func `switcher hover styling keeps layout stable`() { let view = ProviderSwitcherView( @@ -459,6 +572,20 @@ struct StatusMenuSwitcherClickTests { keyCode: keyCode)) } + private static func commandKeyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } + @Test func `multi-row switcher uses compact height and stays inside bounds`() { // 14 providers + Overview forces the four-row path and includes multi-word titles. From 91677d03504280ec6cbaacb8c8f9e5f7543a29a5 Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Tue, 26 May 2026 02:19:10 -0700 Subject: [PATCH 2/4] fix: clean up provider switcher monitor on direct close --- .../CodexBar/StatusItemController+Menu.swift | 7 +- .../StatusMenuSwitcherClickTests.swift | 98 ++++++++++++------- 2 files changed, 66 insertions(+), 39 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 96c7360ff..1347838be 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -127,9 +127,6 @@ extension StatusItemController { func menuDidClose(_ menu: NSMenu) { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) - if ObjectIdentifier(menu) == self.providerSwitcherShortcutMenuID { - self.removeProviderSwitcherShortcutMonitor() - } self.forgetClosedMenu(menu) if wasHostedSubviewMenu { self.refreshOpenMenusIfNeeded() @@ -139,6 +136,10 @@ extension StatusItemController { func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) + if key == self.providerSwitcherShortcutMenuID { + self.removeProviderSwitcherShortcutMonitor() + } + self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index a3891c3be..9fe93b78d 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -24,6 +24,48 @@ struct StatusMenuSwitcherClickTests { syntheticTokenStore: NoopSyntheticTokenStore()) } + private func makeInstalledSwitcherShortcutMonitor() -> (controller: StatusItemController, menu: StatusItemMenu) { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = StatusItemMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let switcherItem = NSMenuItem() + switcherItem.view = switcher + menu.addItem(switcherItem) + menu.addItem(.separator()) + + controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + return (controller, menu) + } + @Test func `merged switcher routes runtime clicks after overview round-trip`() throws { // Regression test for #867: after Provider → Overview, subsequent runtime clicks on a @@ -297,50 +339,34 @@ struct StatusMenuSwitcherClickTests { StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .claude - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + menu.removeAllItems() + controller.menuDidClose(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + + @Test + func `switcher shortcut monitor is removed from direct close cleanup`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let controller = StatusItemController( - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() defer { controller.releaseStatusItemsForTesting() } - let menu = StatusItemMenu() - let switcher = ProviderSwitcherView( - providers: [.codex, .claude], - selected: .provider(.codex), - includesOverview: true, - width: 320, - showsIcons: false, - iconProvider: { _ in NSImage() }, - weeklyRemainingProvider: { _ in nil }, - onSelect: { _ in }) - let switcherItem = NSMenuItem() - switcherItem.view = switcher - menu.addItem(switcherItem) - menu.addItem(.separator()) - - controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) #expect(controller.providerSwitcherShortcutEventMonitor != nil) #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) - menu.removeAllItems() - controller.menuDidClose(menu) + controller.forgetClosedMenu(menu) #expect(controller.providerSwitcherShortcutEventMonitor == nil) #expect(controller.providerSwitcherShortcutMenuID == nil) From 6ec819726bc23a5400399eea0d5573fdcc98a82a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 20:56:33 +0100 Subject: [PATCH 3/4] fix: avoid blocking provider shortcut monitor --- .../CodexBar/StatusItemController+Menu.swift | 6 ++-- ...tatusItemController+ProviderSwitcher.swift | 34 +++++++++---------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1347838be..518de2d4f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -117,10 +117,8 @@ extension StatusItemController { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu - } - self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) - // Only schedule refresh after menu is registered as open - refreshNow is called async - if Self.menuRefreshEnabled { + self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) } } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index b5c30bca6..72bf0ff60 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -3,11 +3,11 @@ import CodexBarCore final class ProviderSwitcherShortcutEventMonitor { private let events: NSEvent.EventTypeMask - private let callback: @MainActor (NSEvent) -> NSEvent? + private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private var isActive = false - init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> NSEvent?) { + init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { self.events = events self.callback = callback @@ -18,20 +18,18 @@ final class ProviderSwitcherShortcutEventMonitor { 0) { [events, callback] _, _ in MainActor.assumeIsolated { - var queuedEvents: [NSEvent] = [] - while let event = NSApp.nextEvent(matching: .any, until: nil, inMode: .default, dequeue: true) { - queuedEvents.append(event) - } - - for event in queuedEvents { - let eventMask = NSEvent.EventTypeMask(rawValue: 1 << event.type.rawValue) - let eventToPost = if events.contains(eventMask) { - callback(event) - } else { - event - } - guard let eventToPost else { continue } - NSApp.postEvent(eventToPost, atStart: false) + while let event = NSApp.nextEvent( + matching: events, + until: .distantPast, + inMode: .default, + dequeue: false) + { + guard callback(event) else { break } + _ = NSApp.nextEvent( + matching: events, + until: .distantPast, + inMode: .default, + dequeue: true) } } } @@ -76,10 +74,10 @@ extension StatusItemController { self.openMenus[ObjectIdentifier(menu)] != nil, menu.items.first?.view is ProviderSwitcherView else { - return event + return false } - return self.handleProviderSwitcherShortcut(event, menu: menu) ? nil : event + return self.handleProviderSwitcherShortcut(event, menu: menu) } monitor.start() self.providerSwitcherShortcutEventMonitor = monitor From 995a8803fabe4d8204cb541a22d124e718439c61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 21:02:13 +0100 Subject: [PATCH 4/4] fix: poll provider shortcuts in menu tracking mode --- Sources/CodexBar/StatusItemController+ProviderSwitcher.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 72bf0ff60..3b0f68735 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -21,14 +21,14 @@ final class ProviderSwitcherShortcutEventMonitor { while let event = NSApp.nextEvent( matching: events, until: .distantPast, - inMode: .default, + inMode: .eventTracking, dequeue: false) { guard callback(event) else { break } _ = NSApp.nextEvent( matching: events, until: .distantPast, - inMode: .default, + inMode: .eventTracking, dequeue: true) } }