Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
### Fixed
- Overview: stop the infinite menu flicker when hovering between provider chart submenus with Agent Sessions enabled — no-op session rescans no longer invalidate menus, submenu hovers no longer trigger rescans, and session updates defer tracked-parent rebuilds like other data refreshes (#2652). Thanks @qazi0!
- Widgets: bound widget-snapshot file I/O with a defensive timeout and skip further container access once it wedges, so a blocked macOS 26 app-group open() can no longer beachball the app or hang the widget (#2267 follow-up).
- Codex: show the credit-limit percentage in automatic menu-bar layouts when session and weekly usage are unavailable.
- Command Code: parse and display 5-hour and weekly rolling limits alongside monthly credits and reset times (#2466). Thanks @derekszen!
- OpenCode Go: include Zen balance in CLI usage reads without waiting beyond five seconds (#2583). Thanks @Yuxin-Qiao!
- Usage & Spend: keep validated Codex totals visible while the local scanner catches up, with refresh indicators in the dashboard and menu cost rows (#2397). Thanks @hhh2210!
Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/MenuBarLayoutEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,11 @@ private struct MenuBarLayoutPreview: View {
scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window),
scopedWeeklyTitle: scopedNamed?.title,
automatic: MenuBarLayoutRenderWindow(automatic),
automaticFallbackUsedPercent: self.store.codexConsumerProjectionIfNeeded(
for: provider,
surface: .menuBar,
snapshotOverride: snapshot,
now: now)?.menuBarFallbackUsedPercent,
sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: session, now: now),
weeklyPace: self.store.menuBarLayoutPaceText(provider: provider, window: weekly, now: now),
automaticPace: self.store.menuBarLayoutPaceText(provider: provider, window: automatic, now: now),
Expand Down Expand Up @@ -734,6 +739,7 @@ private struct MenuBarLayoutPreview: View {
scopedWeekly: MenuBarLayoutRenderWindow(scopedWeekly),
scopedWeeklyTitle: "Fable only",
automatic: MenuBarLayoutRenderWindow(session),
automaticFallbackUsedPercent: nil,
sessionPace: samplePace(session),
weeklyPace: samplePace(weekly),
automaticPace: samplePace(session),
Expand Down
11 changes: 8 additions & 3 deletions Sources/CodexBar/MenuBarLayoutRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ struct MenuBarLayoutRenderData: Hashable {
/// `.scopedWeekly` token with the real model rather than assuming Fable.
let scopedWeeklyTitle: String?
let automatic: MenuBarLayoutRenderWindow?
/// Used percentage for a non-rate-window quota that can stand in for the automatic metric.
/// Codex credit limits use this when the account exposes credits but no session/weekly lanes.
let automaticFallbackUsedPercent: Double?
/// Signed pace deltas per window, already formatted (`+11%`, `-8%`, `0%`). Pace needs the store's
/// historical dataset and work-day setting, so it is resolved upstream like `runsOut` rather than
/// derived from the render windows here.
Expand Down Expand Up @@ -239,7 +242,9 @@ final class MenuBarLayoutRenderer {
attributes: style.attributes)
case let .percent(window):
let rateWindow = Self.window(window, data: data)
let percent = rateWindow.map { options.showUsed ? $0.usedPercent : $0.remainingPercent }
let fallbackUsedPercent = window == .automatic ? data.automaticFallbackUsedPercent : nil
let percent = fallbackUsedPercent.map { options.showUsed ? $0 : max(0, 100 - $0) }
?? rateWindow.map { options.showUsed ? $0.usedPercent : $0.remainingPercent }
let value = percent.map(UsageFormatter.percentString) ?? Self.missingValue
let prefix: String
let accessibilityPrefix: String
Expand Down Expand Up @@ -269,13 +274,13 @@ final class MenuBarLayoutRenderer {
accessibilityPrefix: Self.paceAccessibilityPrefix(window),
attributes: style.attributes)
case .usageBar:
guard let window = data.automatic else {
guard let usedPercent = data.automaticFallbackUsedPercent ?? data.automatic?.usedPercent else {
return self.textToken(
self.missingValue,
accessibilityText: L("Usage bar unavailable"),
attributes: style.attributes)
}
let displayedPercent = options.showUsed ? window.usedPercent : window.remainingPercent
let displayedPercent = options.showUsed ? usedPercent : max(0, 100 - usedPercent)
let filled = Int((displayedPercent.clamped(to: 0...100) / 100 * 3).rounded())
let value = String(repeating: "▮", count: filled) + String(repeating: "▯", count: 3 - filled)
return self.textToken(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,11 @@ struct CodexConsumerProjection {
$0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > self.evaluationTime } ?? true)
}
}

var menuBarFallbackUsedPercent: Double? {
guard self.menuBarFallback == .creditsBalance else { return nil }
return self.credits?.snapshot?.codexCreditLimit?.usedPercent
}
}

extension UsageStore {
Expand Down
17 changes: 17 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuBarLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ extension StatusItemController {
scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window),
scopedWeeklyTitle: scopedNamed?.title,
automatic: MenuBarLayoutRenderWindow(windows.automatic),
automaticFallbackUsedPercent: self.menuBarLayoutAutomaticFallbackUsedPercent(
provider: provider,
snapshot: snapshot,
now: now),
sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: windows.session, now: now),
weeklyPace: self.store.menuBarLayoutPaceText(provider: provider, window: windows.weekly, now: now),
automaticPace: self.store.menuBarLayoutPaceText(
Expand All @@ -85,6 +89,19 @@ extension StatusItemController {
cost30d: costStrings.last30Days)
}

func menuBarLayoutAutomaticFallbackUsedPercent(
provider: UsageProvider,
snapshot: UsageSnapshot?,
now: Date)
-> Double?
{
self.store.codexConsumerProjectionIfNeeded(
for: provider,
surface: .menuBar,
snapshotOverride: snapshot,
now: now)?.menuBarFallbackUsedPercent
}

func menuBarLayoutAccountLabel(provider: UsageProvider, snapshot: UsageSnapshot?) -> String? {
let rawAccountLabel = snapshot?.accountEmail(for: provider)?
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
49 changes: 43 additions & 6 deletions Tests/CodexBarTests/MenuBarLayoutRendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ struct MenuBarLayoutRendererTests {
scopedWeekly: nil,
scopedWeeklyTitle: nil,
automatic: nil,
automaticFallbackUsedPercent: nil,
sessionPace: nil,
weeklyPace: nil,
automaticPace: nil,
Expand Down Expand Up @@ -159,6 +160,7 @@ struct MenuBarLayoutRendererTests {
scopedWeekly: nil,
scopedWeeklyTitle: nil,
automatic: nil,
automaticFallbackUsedPercent: nil,
// Pace is suppressed below 3% of window elapsed; the percent token must survive that.
sessionPace: nil,
weeklyPace: nil,
Expand Down Expand Up @@ -294,6 +296,33 @@ struct MenuBarLayoutRendererTests {
#expect(output.attributedTitle.string == "▮▮▮")
}

@Test
func `automatic percentage uses quota fallback in either display direction`() {
let renderer = MenuBarLayoutRenderer()
let data = self.data(automaticUsedPercent: 100, automaticFallbackUsedPercent: 3)
let layout = MenuBarLayout(lines: [[
.percent(window: .automatic),
.separatorDot,
.percent(window: .session),
]])

let used = renderer.render(layout: layout, data: data, icon: nil, options: self.options())
let remaining = renderer.render(
layout: layout,
data: data,
icon: nil,
options: MenuBarLayoutRenderOptions(
size: .regular,
highContrast: false,
showUsed: false,
appearanceName: "aqua",
isDebugApp: false,
now: self.now))

#expect(used.attributedTitle.string == "3%\u{2009}·\u{2009}5h 25%")
#expect(remaining.attributedTitle.string == "97%\u{2009}·\u{2009}5h 75%")
}

@Test
func `absolute reset falls back to provider text`() {
let renderer = MenuBarLayoutRenderer()
Expand All @@ -311,6 +340,7 @@ struct MenuBarLayoutRendererTests {
scopedWeekly: nil,
scopedWeeklyTitle: nil,
automatic: textOnlyWindow,
automaticFallbackUsedPercent: nil,
sessionPace: nil,
weeklyPace: nil,
automaticPace: nil,
Expand Down Expand Up @@ -351,7 +381,11 @@ struct MenuBarLayoutRendererTests {
.attribute(.foregroundColor, at: textIndex, effectiveRange: nil) as? NSColor == .labelColor)
}

private func data(automaticUsedPercent: Double = 50) -> MenuBarLayoutRenderData {
private func data(
automaticUsedPercent: Double? = 50,
automaticFallbackUsedPercent: Double? = nil)
-> MenuBarLayoutRenderData
{
MenuBarLayoutRenderData(
iconKey: "codex",
providerName: "Codex",
Expand All @@ -372,11 +406,14 @@ struct MenuBarLayoutRendererTests {
resetsAt: self.now.addingTimeInterval(24 * 60 * 60),
resetDescription: nil)),
scopedWeeklyTitle: "Fable only",
automatic: MenuBarLayoutRenderWindow(RateWindow(
usedPercent: automaticUsedPercent,
windowMinutes: 300,
resetsAt: self.now.addingTimeInterval(2 * 60 * 60),
resetDescription: nil)),
automatic: automaticUsedPercent.flatMap {
MenuBarLayoutRenderWindow(RateWindow(
usedPercent: $0,
windowMinutes: 300,
resetsAt: self.now.addingTimeInterval(2 * 60 * 60),
resetDescription: nil))
},
automaticFallbackUsedPercent: automaticFallbackUsedPercent,
sessionPace: "-8%",
weeklyPace: "+11%",
automaticPace: "0%",
Expand Down
46 changes: 46 additions & 0 deletions Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,52 @@ import Testing
@Suite(.serialized)
@MainActor
struct StatusItemAnimationCodexCreditsTests {
@Test
func `codex automatic percentage uses credit limit when usage is missing`() {
let settings = testSettingsStore(suiteName: "StatusItemAnimationTests-credit-limit-layout")
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.menuBarShowsBrandIconWithPercent = true
settings.menuBarLayout = .defaultLayout
settings.usageBarsShowUsed = true

let registry = ProviderRegistry.shared
if let codexMeta = registry.metadata[.codex] {
settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
}

let now = Date()
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
store._setSnapshotForTesting(nil, provider: .codex)
store.credits = CreditsSnapshot(
remaining: 41556.4,
events: [],
updatedAt: now,
codexCreditLimit: CodexCreditLimitSnapshot(
used: 1193.6,
limit: 42750,
remainingPercent: 97.208,
resetsAt: nil,
updatedAt: now))

let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }

controller.applyIcon(for: .codex, phase: nil)

let title = controller.statusItems[.codex]?.button?.attributedTitle.string
#expect(title?.contains("3%") == true)
#expect(title?.contains("–") == false)
}

@Test
func `codex icon keeps credits only rendering when usage is missing`() {
let settings = testSettingsStore(suiteName: "StatusItemAnimationTests-credits-only-icon")
Expand Down