diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index b5cbc33a6..518de2d4f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -117,9 +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 - } - // 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) } } @@ -135,6 +134,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() @@ -308,10 +311,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 +346,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..3b0f68735 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -0,0 +1,119 @@ +import AppKit +import CodexBarCore + +final class ProviderSwitcherShortcutEventMonitor { + private let events: NSEvent.EventTypeMask + private let callback: @MainActor (NSEvent) -> Bool + private let observer: CFRunLoopObserver + private var isActive = false + + init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { + self.events = events + self.callback = callback + + self.observer = CFRunLoopObserverCreateWithHandler( + nil, + CFRunLoopActivity.beforeSources.rawValue, + true, + 0) + { [events, callback] _, _ in + MainActor.assumeIsolated { + while let event = NSApp.nextEvent( + matching: events, + until: .distantPast, + inMode: .eventTracking, + dequeue: false) + { + guard callback(event) else { break } + _ = NSApp.nextEvent( + matching: events, + until: .distantPast, + inMode: .eventTracking, + dequeue: true) + } + } + } + } + + 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 false + } + + return self.handleProviderSwitcherShortcut(event, menu: menu) + } + 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..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 @@ -233,6 +275,103 @@ 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 (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #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 (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + controller.forgetClosedMenu(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + @Test func `switcher hover styling keeps layout stable`() { let view = ProviderSwitcherView( @@ -459,6 +598,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.