Skip to content
59 changes: 59 additions & 0 deletions Sources/CodexBar/ActiveAppDetector.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 6 additions & 0 deletions Sources/CodexBar/PreferencesDisplayPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -258,6 +259,7 @@ extension SettingsStore {
openAIWebAccessEnabled: openAIWebAccessEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
mergeIcons: mergeIcons,
menuBarShowsActiveProvider: menuBarShowsActiveProvider,
switcherShowsIcons: switcherShowsIcons,
mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview,
mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,19 +481,44 @@ 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,
let highest = self.store.providerWithHighestUsage()
{
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
Expand Down
47 changes: 47 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
36 changes: 36 additions & 0 deletions Tests/CodexBarTests/ActiveAppDetectorTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions Tests/CodexBarTests/SettingsStoreActiveProviderTests.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}