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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
## Testing Guidelines
- Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods).
- Always run `swift test` (or `./Scripts/compile_and_run.sh`) before handoff; add fixtures for new parsing/formatting scenarios.
- After any code change, run `pnpm check` and fix all reported format/lint issues before handoff.
- After any code change, run `make check` and fix all reported format/lint issues before handoff.
- macOS CI is brittle around headless AppKit status/menu tests. Prefer covering menu behavior through stable state/model seams (`MenuDescriptor`, `ProvidersPane`, `CodexAccountsSectionState`, etc.) instead of constructing live `NSStatusBar`/`NSMenu` flows unless the AppKit wiring itself is the thing under test.

## Commit & PR Guidelines
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Providers & Usage
- Cost history: add an additive models.dev pricing metadata parser/cache pipeline for future provider-scoped cost lookups (#863). Thanks @iam-brain!
- Notifications: add opt-in quota warning notifications, warning markers, and provider-level thresholds for session and weekly quota windows (#852). Thanks @Alekstodo!
- Venice: add API-key balance provider support with DIEM/USD balance display and token-account CLI wiring (#865). Thanks @clawSean!
- Factory/Droid: add token-rate-limit billing windows, Core fallback buckets, and extra usage balance display (#878). Thanks @dantemoon1!
- Usage pace: compute pace for any explicit reset window instead of a provider allowlist (#875). Thanks @ViperThanks!
Expand Down
43 changes: 43 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
SHELL := /bin/bash

.PHONY: build check docs-list format lint release restart start start-debug start-release stop test test-live test-tty

start:
./Scripts/compile_and_run.sh

start-debug:
./Scripts/compile_and_run.sh

start-release:
./Scripts/package_app.sh release
pkill -x CodexBar || pkill -f CodexBar.app || true
cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app

restart: start

stop:
pkill -x CodexBar || pkill -f CodexBar.app || true

check lint:
./Scripts/lint.sh lint

format:
./Scripts/lint.sh format

docs-list:
node Scripts/docs-list.mjs

build:
swift build

test:
swift test

test-tty:
swift test --filter TTYIntegrationTests

test-live:
LIVE_TEST=1 swift test --filter LiveAccountTests

release:
./Scripts/package_app.sh release
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ Dev loop:
```bash
./Scripts/compile_and_run.sh
./Scripts/compile_and_run.sh --test # also run swift test before packaging/relaunching
pnpm check # SwiftFormat + SwiftLint
pnpm docs:list # list docs with frontmatter summaries
make check # SwiftFormat + SwiftLint
make docs-list # list docs with frontmatter summaries
```

CLI install:
Expand Down
10 changes: 8 additions & 2 deletions Sources/CodexBar/AppNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ final class AppNotifications {
_ = self.ensureAuthorizationTask()
}

func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) {
func post(
idPrefix: String,
title: String,
body: String,
badge: NSNumber? = nil,
soundEnabled: Bool = true)
{
guard !Self.isRunningUnderTests else { return }
let center = self.centerProvider()
let logger = self.logger
Expand All @@ -34,7 +40,7 @@ final class AppNotifications {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.sound = soundEnabled ? .default : nil
content.badge = badge

let request = UNNotificationRequest(
Expand Down
21 changes: 21 additions & 0 deletions Sources/CodexBar/MenuCardQuotaWarningMarkers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import CodexBarCore

extension CodexConsumerProjection.RateLane {
var quotaWarningWindow: QuotaWarningWindow {
switch self {
case .session:
.session
case .weekly:
.weekly
}
}
}

extension UsageMenuCardView.Model {
static func warningMarkerPercents(thresholds: [Int]?, showUsed: Bool) -> [Double] {
guard let thresholds, !thresholds.isEmpty else { return [] }
return QuotaWarningThresholds.active(thresholds)
.map { showUsed ? 100 - Double($0) : Double($0) }
.filter { $0 > 0 && $0 < 100 }
}
}
31 changes: 25 additions & 6 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct UsageMenuCardView: View {
let detailRightText: String?
let pacePercent: Double?
let paceOnTop: Bool
let warningMarkerPercents: [Double]

init(
id: String,
Expand All @@ -48,7 +49,8 @@ struct UsageMenuCardView: View {
detailLeftText: String?,
detailRightText: String?,
pacePercent: Double?,
paceOnTop: Bool)
paceOnTop: Bool,
warningMarkerPercents: [Double] = [])
{
self.id = id
self.title = title
Expand All @@ -61,6 +63,7 @@ struct UsageMenuCardView: View {
self.detailRightText = detailRightText
self.pacePercent = pacePercent
self.paceOnTop = paceOnTop
self.warningMarkerPercents = warningMarkerPercents
}

var percentLabel: String {
Expand Down Expand Up @@ -373,7 +376,8 @@ private struct MetricRow: View {
tint: self.progressColor,
accessibilityLabel: self.metric.percentStyle.accessibilityLabel,
pacePercent: self.metric.pacePercent,
paceOnTop: self.metric.paceOnTop)
paceOnTop: self.metric.paceOnTop,
warningMarkerPercents: self.metric.warningMarkerPercents)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline) {
Text(self.metric.percentLabel)
Expand Down Expand Up @@ -677,6 +681,7 @@ extension UsageMenuCardView.Model {
let hidePersonalInfo: Bool
let claudePeakHoursEnabled: Bool
let weeklyPace: UsagePace?
let quotaWarningThresholds: [QuotaWarningWindow: [Int]]
let now: Date

init(
Expand All @@ -702,6 +707,7 @@ extension UsageMenuCardView.Model {
hidePersonalInfo: Bool,
claudePeakHoursEnabled: Bool = true,
weeklyPace: UsagePace? = nil,
quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:],
now: Date)
{
self.provider = provider
Expand All @@ -726,6 +732,7 @@ extension UsageMenuCardView.Model {
self.hidePersonalInfo = hidePersonalInfo
self.claudePeakHoursEnabled = claudePeakHoursEnabled
self.weeklyPace = weeklyPace
self.quotaWarningThresholds = quotaWarningThresholds
self.now = now
}
}
Expand Down Expand Up @@ -1010,7 +1017,10 @@ extension UsageMenuCardView.Model {
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
paceOnTop: true,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[.weekly],
showUsed: input.usageBarsShowUsed)))
}
if let extraRateWindows = snapshot.extraRateWindows {
metrics.append(contentsOf: extraRateWindows.map { namedWindow in
Expand Down Expand Up @@ -1175,7 +1185,10 @@ extension UsageMenuCardView.Model {
detailLeftText: primaryDetailLeft,
detailRightText: primaryDetailRight,
pacePercent: primaryPacePercent,
paceOnTop: primaryPaceOnTop)
paceOnTop: primaryPaceOnTop,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[.session],
showUsed: input.usageBarsShowUsed))
Comment on lines +1189 to +1191

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Map warning markers using provider-aware window routing

The marker mapping in this metric path assumes primary is always the session window by hard-wiring .session thresholds here (with the inverse assumption for secondary in secondaryMetric). That breaks providers where window placement differs (for example, Copilot can expose session quota via secondary fallback), causing warning markers to appear on the wrong bar or not appear for the actual session lane. This makes the new quota markers misleading even when thresholds are configured correctly.

Useful? React with 👍 / 👎.

}

private static func secondaryMetric(
Expand Down Expand Up @@ -1247,7 +1260,10 @@ extension UsageMenuCardView.Model {
detailLeftText: paceDetail?.leftLabel,
detailRightText: paceDetail?.rightLabel,
pacePercent: paceDetail?.pacePercent,
paceOnTop: paceDetail?.paceOnTop ?? true)
paceOnTop: paceDetail?.paceOnTop ?? true,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[.weekly],
showUsed: input.usageBarsShowUsed))
}

private static func codexRateMetrics(
Expand Down Expand Up @@ -1286,7 +1302,10 @@ extension UsageMenuCardView.Model {
detailLeftText: paceDetail?.leftLabel,
detailRightText: paceDetail?.rightLabel,
pacePercent: paceDetail?.pacePercent,
paceOnTop: paceDetail?.paceOnTop ?? true)
paceOnTop: paceDetail?.paceOnTop ?? true,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow],
showUsed: input.usageBarsShowUsed))
}
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/CodexBar/Notifications+CodexBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ extension Notification.Name {
static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow")
static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset")
static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange")
static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost")
}

@MainActor
Expand All @@ -22,3 +23,18 @@ final class WeeklyLimitResetEvent: NSObject {
self.usedPercent = usedPercent
}
}

@MainActor
final class QuotaWarningPostedEvent: NSObject {
let provider: UsageProvider
let window: QuotaWarningWindow
let threshold: Int
let postedAt: Date

init(provider: UsageProvider, window: QuotaWarningWindow, threshold: Int, postedAt: Date) {
self.provider = provider
self.window = window
self.threshold = threshold
self.postedAt = postedAt
}
}
7 changes: 7 additions & 0 deletions Sources/CodexBar/PreferencesGeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ struct GeneralPane: View {
subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " +
"available again.",
binding: self.$settings.sessionQuotaNotificationsEnabled)
PreferenceToggleRow(
title: "Quota warning notifications",
subtitle: "Warns when session or weekly quota remaining crosses configured thresholds.",
binding: self.$settings.quotaWarningNotificationsEnabled)
if self.settings.quotaWarningNotificationsEnabled {
GlobalQuotaWarningSettingsView(settings: self.settings)
}
}

Divider()
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodexBar/PreferencesProviderDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ struct ProviderDetailView<SupplementaryContent: View>: View {
self.supplementarySettingsContent
}

ProviderQuotaWarningSettingsView(provider: self.provider, settings: self.store.settings)

if !self.settingsToggles.isEmpty {
ProviderSettingsSection(title: "Options") {
ForEach(self.settingsToggles) { toggle in
Expand Down Expand Up @@ -430,7 +432,8 @@ private struct ProviderMetricInlineRow: View {
tint: self.progressColor,
accessibilityLabel: self.metric.percentStyle.accessibilityLabel,
pacePercent: self.metric.pacePercent,
paceOnTop: self.metric.paceOnTop)
paceOnTop: self.metric.paceOnTop,
warningMarkerPercents: self.metric.warningMarkerPercents)
.frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)

HStack(alignment: .firstTextBaseline, spacing: 8) {
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -605,10 +605,19 @@ struct ProvidersPane: View {
hidePersonalInfo: self.settings.hidePersonalInfo,
claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled,
weeklyPace: weeklyPace,
quotaWarningThresholds: [
.session: self.quotaWarningMarkerThresholds(provider: provider, window: .session),
.weekly: self.quotaWarningMarkerThresholds(provider: provider, window: .weekly),
],
now: now)
return UsageMenuCardView.Model.make(input)
}

private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] {
guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] }
return self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window)
}

private func refreshCodexProvider() async {
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refreshCodexAccountScopedState(allowDisabled: true)
Expand Down
Loading