diff --git a/Sources/CodexBar/ActiveAppDetector.swift b/Sources/CodexBar/ActiveAppDetector.swift new file mode 100644 index 000000000..02d5eccad --- /dev/null +++ b/Sources/CodexBar/ActiveAppDetector.swift @@ -0,0 +1,59 @@ +import AppKit +import CodexBarCore + +/// Maps active application bundle identifiers to their corresponding UsageProvider. +/// Only providers with desktop apps can be detected; CLI-only and web-only providers return nil. +enum ActiveAppDetector { + /// Maps bundle identifier prefixes to their corresponding provider. + /// Order matters: more specific prefixes should come first. + private static let bundleIdToProvider: [(prefix: String, provider: UsageProvider)] = [ + // Desktop AI apps + ("com.openai.codex", .codex), + ("com.anthropic.claude", .claude), + ("com.cursor.sh", .cursor), + ("com.opencodeos.opencode", .opencode), + ("com.google.antigravity", .antigravity), + ("com.augmentcode.augment", .augment), + ("com.minimax.agent", .minimax), + ("dev.warp.Warp-Stable", .warp), + ("com.electron.ollama", .ollama), + + // VS Code (GitHub Copilot - must check before generic JetBrains) + ("com.microsoft.VSCode", .copilot), + + // JetBrains IDEs with Copilot support + ("com.jetbrains.", .copilot), + + // Local AI servers (ollama runs locally, so it may show as active window) + ("com.ollama", .ollama), + ] + + /// Detects the provider associated with the currently active frontmost application. + /// - Returns: The provider for the active AI app, or nil if no relevant AI app is active. + static func activeProvider() -> UsageProvider? { + guard let frontmostApp = NSWorkspace.shared.frontmostApplication, + let bundleId = frontmostApp.bundleIdentifier + else { + return nil + } + + return self.provider(for: bundleId) + } + + /// Looks up the provider for a given bundle identifier. + /// - Parameter bundleId: The application's bundle identifier. + /// - Returns: The corresponding provider, or nil if no match found. + static func provider(for bundleId: String) -> UsageProvider? { + // Try exact match first for special cases + for (prefix, provider) in self.bundleIdToProvider where bundleId == prefix { + return provider + } + + // Then try prefix match (handles versioned bundle IDs like com.anthropic.claude-2) + for (prefix, provider) in self.bundleIdToProvider where bundleId.hasPrefix(prefix) { + return provider + } + + return nil + } +} diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 04050b3bb..8b60cb8df 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -33,6 +33,12 @@ struct DisplayPane: View { binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) + PreferenceToggleRow( + title: "Show active provider", + subtitle: "Auto-show the provider for the active AI app window.", + binding: self.$settings.menuBarShowsActiveProvider) + .disabled(!self.settings.mergeIcons) + .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( title: "Menu bar shows percent", subtitle: "Replace critter bars with provider branding icons and a percentage.", diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 44d83a023..047106ffe 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -280,6 +280,14 @@ extension SettingsStore { } } + var menuBarShowsActiveProvider: Bool { + get { self.defaultsState.menuBarShowsActiveProvider } + set { + self.defaultsState.menuBarShowsActiveProvider = newValue + self.userDefaults.set(newValue, forKey: "menuBarShowsActiveProvider") + } + } + var switcherShowsIcons: Bool { get { self.defaultsState.switcherShowsIcons } set { @@ -474,6 +482,24 @@ extension SettingsStore { } } + /// The last provider that was shown in the menu bar when an active AI app was detected. + /// Used as fallback when no relevant AI app is active. + var lastActiveProviderRaw: String? { + get { self.userDefaults.string(forKey: "lastActiveProvider") } + set { + if let newValue { + self.userDefaults.set(newValue, forKey: "lastActiveProvider") + } else { + self.userDefaults.removeObject(forKey: "lastActiveProvider") + } + } + } + + var lastActiveProvider: UsageProvider? { + get { self.lastActiveProviderRaw.flatMap(UsageProvider.init(rawValue:)) } + set { self.lastActiveProviderRaw = newValue?.rawValue } + } + var debugLoadingPattern: LoadingPattern? { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 1ad4ee00f..7d204cb43 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -221,6 +221,7 @@ extension SettingsStore { if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true + let menuBarShowsActiveProvider = userDefaults.object(forKey: "menuBarShowsActiveProvider") as? Bool ?? false let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let mergedMenuLastSelectedWasOverview = userDefaults.object( forKey: "mergedMenuLastSelectedWasOverview") as? Bool ?? false @@ -258,6 +259,7 @@ extension SettingsStore { openAIWebAccessEnabled: openAIWebAccessEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, + menuBarShowsActiveProvider: menuBarShowsActiveProvider, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..0f195a34d 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -29,6 +29,7 @@ struct SettingsDefaultsState { var openAIWebAccessEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool + var menuBarShowsActiveProvider: Bool var switcherShowsIcons: Bool var mergedMenuLastSelectedWasOverview: Bool var mergedOverviewSelectedProvidersRaw: [String] diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 5f422862f..d7d5f2659 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -481,6 +481,19 @@ extension StatusItemController { } private func primaryProviderForUnifiedIcon() -> UsageProvider { + // When "show active provider" is enabled, use the active AI app's provider + if self.settings.menuBarShowsActiveProvider, + self.shouldMergeIcons, + let activeProvider = ActiveAppDetector.activeProvider(), + self.store.isEnabled(activeProvider) || self.store.enabledProviders().isEmpty + { + // Update the last active provider when we detect an active AI app + self.settings.lastActiveProvider = activeProvider + // Keep menu in sync with icon when no didActivateApplication notification was received + self.selectedMenuProvider = activeProvider + return activeProvider + } + // When "show highest usage" is enabled, auto-select the provider closest to rate limit. if self.settings.menuBarShowsHighestUsage, self.shouldMergeIcons, @@ -488,12 +501,24 @@ extension StatusItemController { { return highest.provider } + + // Use manually selected provider if self.shouldMergeIcons, let selected = self.selectedMenuProvider, self.store.isEnabled(selected) { return selected } + + // Fall back to last active provider if available + if self.settings.menuBarShowsActiveProvider, + self.shouldMergeIcons, + let lastProvider = self.settings.lastActiveProvider, + self.store.isEnabled(lastProvider) + { + return lastProvider + } + for provider in UsageProvider.allCases { if self.store.isEnabled(provider), self.store.snapshot(for: provider) != nil { return provider diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index a83420ee9..bd354ae30 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -80,6 +80,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastConfigRevision: Int private var lastProviderOrder: [UsageProvider] private var lastMergeIcons: Bool + private var lastMenuBarShowsActiveProvider: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. @@ -98,6 +99,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin set { self.settings.selectedMenuProvider = newValue } } + private var activeAppObserver: NSObjectProtocol? + struct BlinkState { var nextBlink: Date var blinkStart: Date? @@ -167,6 +170,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastConfigRevision = settings.configRevision self.lastProviderOrder = settings.providerOrder self.lastMergeIcons = settings.mergeIcons + self.lastMenuBarShowsActiveProvider = settings.menuBarShowsActiveProvider self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed @@ -195,6 +199,37 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleProviderConfigDidChange), name: .codexbarProviderConfigDidChange, object: nil) + + // Observe active app changes for dynamic menu bar icon + self.activeAppObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main) + { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleActiveAppChanged() + } + } + } + + @objc private func handleActiveAppChanged() { + guard self.settings.menuBarShowsActiveProvider, self.shouldMergeIcons else { return } + self.updateActiveProviderTracking() + self.updateIcons() + } + + private func updateActiveProviderTracking() { + guard self.settings.menuBarShowsActiveProvider, self.shouldMergeIcons else { return } + + if let activeProvider = ActiveAppDetector.activeProvider() { + // Update last active provider when switching to an AI app + self.settings.lastActiveProvider = activeProvider + // Update the selected menu provider to reflect active app + self.selectedMenuProvider = activeProvider + } else if self.settings.lastActiveProvider != nil { + // When no AI app is active, keep showing the last active provider + // (don't change selectedMenuProvider here - let primaryProviderForUnifiedIcon handle fallback) + } } private func wireBindings() { @@ -321,6 +356,17 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() + + // Track setting changes for menuBarShowsActiveProvider + let showActiveProviderChanged = self.settings.menuBarShowsActiveProvider != self.lastMenuBarShowsActiveProvider + if showActiveProviderChanged { + self.lastMenuBarShowsActiveProvider = self.settings.menuBarShowsActiveProvider + // When enabled, immediately check for active app + if self.settings.menuBarShowsActiveProvider, self.shouldMergeIcons { + self.updateActiveProviderTracking() + } + } + self.invalidateMenus() if orderChanged || configChanged { self.rebuildProviderStatusItems() @@ -484,6 +530,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin deinit { self.blinkTask?.cancel() self.loginTask?.cancel() + // Note: activeAppObserver will be cleaned up by the system when the app terminates NotificationCenter.default.removeObserver(self) } } diff --git a/Tests/CodexBarTests/ActiveAppDetectorTests.swift b/Tests/CodexBarTests/ActiveAppDetectorTests.swift new file mode 100644 index 000000000..b6b10af8a --- /dev/null +++ b/Tests/CodexBarTests/ActiveAppDetectorTests.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct ActiveAppDetectorTests { + @Test + func `provider maps expected exact and prefix bundle identifiers`() { + let cases: [(bundleID: String, expected: UsageProvider?)] = [ + // Codex + ("com.openai.codex", .codex), + ("com.openai.codex.desktop", .codex), + // Claude + ("com.anthropic.claude", .claude), + ("com.anthropic.claude-2", .claude), + // Copilot (VS Code and JetBrains) + ("com.microsoft.VSCode", .copilot), + ("com.microsoft.VSCodeInsiders", .copilot), + ("com.jetbrains.intellij", .copilot), + // Ollama + ("com.electron.ollama", .ollama), + ("com.ollama", .ollama), + ("com.ollama.desktop", .ollama), + // Unknown + ("com.apple.Safari", nil), + ] + + for testCase in cases { + #expect(ActiveAppDetector.provider(for: testCase.bundleID) == testCase.expected) + } + } + + @Test + func `provider returns nil for unknown bundle identifier`() { + #expect(ActiveAppDetector.provider(for: "com.example.unknown-ai-app") == nil) + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index b71448ef1..6bbdcefd9 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -25,6 +25,7 @@ struct PreferencesPaneSmokeTests { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-toggled") settings.menuBarShowsBrandIconWithPercent = true settings.menuBarShowsHighestUsage = true + settings.menuBarShowsActiveProvider = true settings.showAllTokenAccountsInMenu = true settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true diff --git a/Tests/CodexBarTests/SettingsStoreActiveProviderTests.swift b/Tests/CodexBarTests/SettingsStoreActiveProviderTests.swift new file mode 100644 index 000000000..3c13badc5 --- /dev/null +++ b/Tests/CodexBarTests/SettingsStoreActiveProviderTests.swift @@ -0,0 +1,83 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SettingsStoreActiveProviderTests { + @Test + func `menu bar shows active provider defaults to false`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreActiveProviderTests-default") + + #expect(settings.menuBarShowsActiveProvider == false) + } + + @Test + func `menu bar shows active provider persists across instances`() throws { + let suite = "SettingsStoreActiveProviderTests-persist-active-toggle" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = Self.makeSettingsStore(userDefaults: defaultsA, configStore: configStore) + + storeA.menuBarShowsActiveProvider = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = Self.makeSettingsStore(userDefaults: defaultsB, configStore: configStore) + + #expect(storeB.menuBarShowsActiveProvider == true) + } + + @Test + func `last active provider persists across instances`() throws { + let suite = "SettingsStoreActiveProviderTests-persist-last-provider" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = Self.makeSettingsStore(userDefaults: defaultsA, configStore: configStore) + + storeA.lastActiveProvider = .claude + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = Self.makeSettingsStore(userDefaults: defaultsB, configStore: configStore) + + #expect(storeB.lastActiveProvider == .claude) + } + + @Test + func `last active provider invalid raw resolves nil and clearing removes persisted value`() throws { + let suite = "SettingsStoreActiveProviderTests-invalid-and-clear" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("invalid-provider", forKey: "lastActiveProvider") + + let configStore = testConfigStore(suiteName: suite) + let store = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(store.lastActiveProvider == nil) + + store.lastActiveProvider = .codex + #expect(defaults.string(forKey: "lastActiveProvider") == UsageProvider.codex.rawValue) + + store.lastActiveProvider = nil + #expect(defaults.string(forKey: "lastActiveProvider") == nil) + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + } + + private static func makeSettingsStore( + userDefaults: UserDefaults, + configStore: CodexBarConfigStore) -> SettingsStore + { + SettingsStore( + userDefaults: userDefaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +}