Skip to content
Open
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
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuBarDisplayText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum MenuBarDisplayText {
percentWindow: RateWindow?,
paceWindow: RateWindow?,
showUsed: Bool,
separatorStyle: MenuBarSeparatorStyle = .dot,
now: Date = .init()) -> String?
{
switch mode {
Expand All @@ -33,7 +34,7 @@ enum MenuBarDisplayText {
case .both:
guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil }
guard let pace = Self.paceText(provider: provider, window: paceWindow, now: now) else { return nil }
return "\(percent) · \(pace)"
return "\(percent)\(separatorStyle.separator)\(pace)"
}
}
}
25 changes: 25 additions & 0 deletions Sources/CodexBar/MenuBarSeparatorStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Controls the separator character between percent and pace in the menu bar.
enum MenuBarSeparatorStyle: String, CaseIterable, Identifiable {
case dot
case pipe

var id: String {
self.rawValue
}

var separator: String {
switch self {
case .dot: " · "
case .pipe: " | "
}
}

var label: String {
switch self {
case .dot: "Dot (·)"
case .pipe: "Pipe (|)"
}
}
}
4 changes: 4 additions & 0 deletions Sources/CodexBar/PreferencesAdvancedPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ struct AdvancedPane: View {
title: "Surprise me",
subtitle: "Check if you like your agents having some fun up there.",
binding: self.$settings.randomBlinkEnabled)
PreferenceToggleRow(
title: "Color-coded icons",
subtitle: "Tint menu bar icons green, yellow, or red based on session usage.",
binding: self.$settings.colorCodedIcons)
}

Divider()
Expand Down
22 changes: 22 additions & 0 deletions Sources/CodexBar/PreferencesDisplayPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ struct DisplayPane: View {
}
.disabled(!self.settings.menuBarShowsBrandIconWithPercent)
.opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5)
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Separator")
.font(.body)
Text("Character between percent and pace (e.g. 45% | +5%).")
.font(.footnote)
.foregroundStyle(.tertiary)
}
Spacer()
Picker("Separator", selection: self.$settings.menuBarSeparatorStyle) {
ForEach(MenuBarSeparatorStyle.allCases) { style in
Text(style.label).tag(style)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(maxWidth: 200)
}
.disabled(!self.settings.menuBarShowsBrandIconWithPercent ||
self.settings.menuBarDisplayMode != .both)
.opacity(self.settings.menuBarShowsBrandIconWithPercent &&
self.settings.menuBarDisplayMode == .both ? 1 : 0.5)
}

Divider()
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ extension SettingsStore {
set { self.menuBarDisplayModeRaw = newValue.rawValue }
}

private var menuBarSeparatorStyleRaw: String? {
get { self.defaultsState.menuBarSeparatorStyleRaw }
set {
self.defaultsState.menuBarSeparatorStyleRaw = newValue
if let raw = newValue {
self.userDefaults.set(raw, forKey: "menuBarSeparatorStyle")
} else {
self.userDefaults.removeObject(forKey: "menuBarSeparatorStyle")
}
}
}

var menuBarSeparatorStyle: MenuBarSeparatorStyle {
get { MenuBarSeparatorStyle(rawValue: self.menuBarSeparatorStyleRaw ?? "") ?? .dot }
set { self.menuBarSeparatorStyleRaw = newValue.rawValue }
}

var showAllTokenAccountsInMenu: Bool {
get { self.defaultsState.showAllTokenAccountsInMenu }
set {
Expand Down Expand Up @@ -231,6 +248,14 @@ extension SettingsStore {
}
}

var colorCodedIcons: Bool {
get { self.defaultsState.colorCodedIcons }
set {
self.defaultsState.colorCodedIcons = newValue
self.userDefaults.set(newValue, forKey: "colorCodedIcons")
}
}

var mergeIcons: Bool {
get { self.defaultsState.mergeIcons }
set {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ extension SettingsStore {
_ = self.kimiCookieSource
_ = self.augmentCookieSource
_ = self.ampCookieSource
_ = self.colorCodedIcons

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track separator-style changes in menu observation token

StatusItemController only refreshes the menu bar when settings.menuObservationToken changes, but this token never reads menuBarSeparatorStyle even though rendering now depends on it (StatusItemController+Animation.swift calls self.settings.menuBarSeparatorStyle in menuBarDisplayText). As a result, changing the Separator picker can leave the menu bar text unchanged until some unrelated setting/store update happens, making the new preference appear broken.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with CC... this appears to be a false positive of a comment...

The very next line references self.menuBarSeparatorStyle.

_ = self.menuBarSeparatorStyle
_ = self.mergeIcons
_ = self.switcherShowsIcons
_ = self.zaiAPIToken
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ extension SettingsStore {
forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false
let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode")
?? MenuBarDisplayMode.percent.rawValue
let menuBarSeparatorStyleRaw = userDefaults.string(forKey: "menuBarSeparatorStyle")
?? MenuBarSeparatorStyle.dot.rawValue
let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false
let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:]
var resolvedPreferences = storedPreferences
Expand All @@ -211,6 +213,7 @@ extension SettingsStore {
let openAIWebAccessEnabled = openAIWebAccessDefault ?? true
if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") }
let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? ""
let colorCodedIcons = userDefaults.object(forKey: "colorCodedIcons") as? Bool ?? true
let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true
let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true
let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider")
Expand All @@ -231,6 +234,7 @@ extension SettingsStore {
resetTimesShowAbsolute: resetTimesShowAbsolute,
menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent,
menuBarDisplayModeRaw: menuBarDisplayModeRaw,
menuBarSeparatorStyleRaw: menuBarSeparatorStyleRaw,
showAllTokenAccountsInMenu: showAllTokenAccountsInMenu,
menuBarMetricPreferencesRaw: resolvedPreferences,
costUsageEnabled: costUsageEnabled,
Expand All @@ -241,6 +245,7 @@ extension SettingsStore {
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
openAIWebAccessEnabled: openAIWebAccessEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
colorCodedIcons: colorCodedIcons,
mergeIcons: mergeIcons,
switcherShowsIcons: switcherShowsIcons,
selectedMenuProviderRaw: selectedMenuProviderRaw,
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct SettingsDefaultsState: Sendable {
var resetTimesShowAbsolute: Bool
var menuBarShowsBrandIconWithPercent: Bool
var menuBarDisplayModeRaw: String?
var menuBarSeparatorStyleRaw: String?
var showAllTokenAccountsInMenu: Bool
var menuBarMetricPreferencesRaw: [String: String]
var costUsageEnabled: Bool
Expand All @@ -25,6 +26,7 @@ struct SettingsDefaultsState: Sendable {
var showOptionalCreditsAndExtraUsage: Bool
var openAIWebAccessEnabled: Bool
var jetbrainsIDEBasePath: String
var colorCodedIcons: Bool
var mergeIcons: Bool
var switcherShowsIcons: Bool
var selectedMenuProviderRaw: String?
Expand Down
33 changes: 28 additions & 5 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,19 +250,26 @@ extension StatusItemController {
return .none
}()

let usageColor: NSColor? = {
guard self.settings.colorCodedIcons, !needsAnimation else { return nil }
return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent)
}()

if showBrandPercent,
let brand = ProviderBrandIcon.image(for: primaryProvider)
{
let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot)
self.setButtonImage(brand, for: button)
self.setButtonTitle(displayText, for: button)
self.setButtonTintColor(usageColor, for: button)
return
}

self.setButtonTitle(nil, for: button)
if let morphProgress {
let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style)
self.setButtonImage(image, for: button)
self.setButtonTintColor(nil, for: button)
} else {
let image = IconRenderer.makeIcon(
primaryRemaining: primary,
Expand All @@ -275,6 +282,7 @@ extension StatusItemController {
tilt: tilt,
statusIndicator: statusIndicator)
self.setButtonImage(image, for: button)
self.setButtonTintColor(usageColor, for: button)
}
}

Expand All @@ -285,13 +293,20 @@ extension StatusItemController {
// user setting we pass either "percent left" or "percent used".
let showUsed = self.settings.usageBarsShowUsed
let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
let isAnimating = phase != nil && self.shouldAnimate(provider: provider)

let usageColor: NSColor? = {
guard self.settings.colorCodedIcons, !isAnimating else { return nil }
return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent)
}()

if showBrandPercent,
let brand = ProviderBrandIcon.image(for: provider)
{
let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot)
self.setButtonImage(brand, for: button)
self.setButtonTitle(displayText, for: button)
self.setButtonTintColor(usageColor, for: button)
return
}
var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent
Expand All @@ -317,21 +332,21 @@ extension StatusItemController {
var stale = self.store.isStale(provider: provider)
var morphProgress: Double?

if let phase, self.shouldAnimate(provider: provider) {
if isAnimating {
var pattern = self.animationPattern
if provider == .claude, pattern == .unbraid {
pattern = .cylon
}
if pattern == .unbraid {
morphProgress = pattern.value(phase: phase) / 100
morphProgress = pattern.value(phase: phase!) / 100
primary = nil
weekly = nil
credits = nil
stale = false
} else {
// Keep loading animation layout stable: IconRenderer switches layouts at `weeklyRemaining == 0`.
primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon)
weekly = max(pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon)
primary = max(pattern.value(phase: phase!), Self.loadingPercentEpsilon)
weekly = max(pattern.value(phase: phase! + pattern.secondaryOffset), Self.loadingPercentEpsilon)
credits = nil
stale = false
}
Expand All @@ -351,6 +366,7 @@ extension StatusItemController {
if let morphProgress {
let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style)
self.setButtonImage(image, for: button)
self.setButtonTintColor(nil, for: button)
} else {
self.setButtonTitle(nil, for: button)
let image = IconRenderer.makeIcon(
Expand All @@ -364,6 +380,7 @@ extension StatusItemController {
tilt: tilt,
statusIndicator: self.store.statusIndicator(for: provider))
self.setButtonImage(image, for: button)
self.setButtonTintColor(usageColor, for: button)
}
}

Expand All @@ -372,6 +389,11 @@ extension StatusItemController {
button.image = image
}

private func setButtonTintColor(_ color: NSColor?, for button: NSStatusBarButton) {
if button.contentTintColor == color { return }
button.contentTintColor = color
}

private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) {
let value = title ?? ""
if button.title != value {
Expand All @@ -389,7 +411,8 @@ extension StatusItemController {
provider: provider,
percentWindow: self.menuBarPercentWindow(for: provider, snapshot: snapshot),
paceWindow: snapshot?.secondary,
showUsed: self.settings.usageBarsShowUsed)
showUsed: self.settings.usageBarsShowUsed,
separatorStyle: self.settings.menuBarSeparatorStyle)
}

private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? {
Expand Down
23 changes: 23 additions & 0 deletions Sources/CodexBar/UsageColorLevel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import AppKit

enum UsageColorLevel: Sendable {
/// Returns a smoothly interpolated tint color based on usage percentage.
/// - 0-70%: green blending toward orange
/// - 70-90%: orange blending toward red
/// - >= 90%: red
/// - nil usage: returns nil (monochrome fallback)
static func tintColor(for usedPercent: Double?) -> NSColor? {
guard let pct = usedPercent else { return nil }
let clamped = min(max(pct, 0), 100)

if clamped < 70 {
let fraction = CGFloat(clamped / 70)
return NSColor.systemGreen.blended(withFraction: fraction, of: .systemOrange)
} else if clamped < 90 {
let fraction = CGFloat((clamped - 70) / 20)
return NSColor.systemOrange.blended(withFraction: fraction, of: .systemRed)
} else {
return .systemRed
}
}
}