Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
119 changes: 119 additions & 0 deletions Sources/CodexBar/StatusItemController+ProviderSwitcher.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 6 additions & 0 deletions Sources/CodexBar/StatusItemController+SwitcherViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var fallbackMenu: NSMenu?
var openMenus: [ObjectIdentifier: NSMenu] = [:]
var menuRefreshTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor?
var providerSwitcherShortcutMenuID: ObjectIdentifier?
#if DEBUG
var onDelayedMenuRefreshAttemptForTesting: (() -> Void)?
var isReleasedForTesting = false
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion Sources/CodexBar/StatusItemMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
}
}
153 changes: 153 additions & 0 deletions Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down