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
27 changes: 20 additions & 7 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ final class AppStore {
private var lastErrorByKey: [PayloadCacheKey: String] = [:]
var subscription: SubscriptionUsage?
var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
var capacityEstimates: [String: CapacityEstimate] = [:]

var codexUsage: CodexUsage?
var codexError: String?
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped

/// Generation tokens for the in-flight refresh tasks. Incremented on every
/// disconnect / reset so a fetch that started before the disconnect cannot
Expand Down Expand Up @@ -405,8 +405,18 @@ final class AppStore {
}

/// User-initiated. Reads Claude's source (this is what triggers the macOS keychain
/// prompt for `Claude Code-credentials`). Once successful, subsequent background
/// refreshes go through our own keychain item without prompting.
func activateClaudeFromDormant() async {
guard case .dormant = subscriptionLoadState else { return }
subscriptionLoadState = .loading
_ = await refreshSubscriptionReportingSuccess()
}

func activateCodexFromDormant() async {
guard case .dormant = codexLoadState else { return }
codexLoadState = .loading
_ = await refreshCodexReportingSuccess()
}

func bootstrapSubscription() async {
subscriptionLoadState = .bootstrapping
do {
Expand Down Expand Up @@ -434,6 +444,7 @@ final class AppStore {
/// rather than every attempt.
@discardableResult
func refreshSubscriptionReportingSuccess() async -> Bool {
if case .dormant = subscriptionLoadState { return false }
guard ClaudeCredentialStore.isBootstrapCompleted else {
if subscriptionLoadState != .notBootstrapped {
subscriptionLoadState = .notBootstrapped
Expand Down Expand Up @@ -511,6 +522,7 @@ final class AppStore {

@discardableResult
func refreshCodexReportingSuccess() async -> Bool {
if case .dormant = codexLoadState { return false }
guard CodexCredentialStore.isBootstrapCompleted else {
if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped }
return false
Expand Down Expand Up @@ -640,7 +652,7 @@ final class AppStore {

private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool {
switch loadState {
case .notBootstrapped, .bootstrapping, .noCredentials:
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials:
return false
case .loading, .loaded, .failed, .terminalFailure, .transientFailure:
return true
Expand All @@ -662,7 +674,7 @@ final class AppStore {

let connection: QuotaSummary.Connection = {
switch subscriptionLoadState {
case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
case .loading: return subscription == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return subscription == nil ? .loading : .stale
Expand Down Expand Up @@ -700,7 +712,7 @@ final class AppStore {

let connection: QuotaSummary.Connection = {
switch codexLoadState {
case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
case .loading: return codexUsage == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return codexUsage == nil ? .loading : .stale
Expand Down Expand Up @@ -914,6 +926,7 @@ extension Notification.Name {

enum SubscriptionLoadState: Sendable, Equatable {
case notBootstrapped // no Keychain access yet — waiting for user to click Connect
case dormant // previously bootstrapped; keychain not yet accessed this session
case bootstrapping // user clicked Connect; reading Claude's keychain (PROMPTS)
case loading // background fetch in progress (subscription may already be populated)
case loaded // success; subscription is populated
Expand Down
4 changes: 2 additions & 2 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ private struct PlanInsight: View {
var body: some View {
Group {
switch store.subscriptionLoadState {
case .notBootstrapped:
case .notBootstrapped, .dormant:
PlanConnectView { Task { await store.bootstrapSubscription() } }
case .bootstrapping:
PlanLoadingView()
Expand Down Expand Up @@ -1174,7 +1174,7 @@ private struct CodexPlanInsight: View {
var body: some View {
Group {
switch store.codexLoadState {
case .notBootstrapped:
case .notBootstrapped, .dormant:
PlanConnectView { Task { await store.bootstrapCodex() } }
case .bootstrapping:
PlanLoadingView()
Expand Down
14 changes: 12 additions & 2 deletions mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ private struct ClaudeConnectionRow: View {
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .noCredentials: return "link.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
Expand All @@ -154,6 +154,7 @@ private struct ClaudeConnectionRow: View {
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load plan data"
}
Expand All @@ -170,6 +171,7 @@ private struct ClaudeConnectionRow: View {
case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying."
case .bootstrapping: return "macOS may ask permission to read your credentials."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from Anthropic."
case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota."
case .failed: return store.subscriptionError ?? ""
}
Expand All @@ -194,6 +196,9 @@ private struct ClaudeConnectionRow: View {
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.activateClaudeFromDormant() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
Expand Down Expand Up @@ -256,7 +261,7 @@ private struct CodexConnectionRow: View {
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .noCredentials: return "link.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
Expand All @@ -277,6 +282,7 @@ private struct CodexConnectionRow: View {
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Codex quota"
}
Expand All @@ -300,6 +306,7 @@ private struct CodexConnectionRow: View {
case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.codex/auth.json."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from chatgpt.com."
case .notBootstrapped, .noCredentials:
return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json."
case .failed: return store.codexError ?? ""
Expand All @@ -325,6 +332,9 @@ private struct CodexConnectionRow: View {
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.activateCodexFromDormant() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
Expand Down
2 changes: 1 addition & 1 deletion tests/cli-status-menubar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('codeburn status --format menubar-json', () => {

const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 60_000)
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
Expand Down
Loading