Skip to content
30 changes: 26 additions & 4 deletions Sources/CodexBar/IconRemainingResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ enum IconRemainingResolver {

static func resolvedWindows(
snapshot: UsageSnapshot,
style: IconStyle)
style: IconStyle,
secondaryOverrideWindowID: String? = nil)
-> (primary: RateWindow?, secondary: RateWindow?)
{
if style == .perplexity {
Expand All @@ -44,14 +45,23 @@ enum IconRemainingResolver {
primary: windows.first,
secondary: windows.dropFirst().first)
}
if style == .copilot,
let secondaryOverrideWindowID,
let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window
{
return (
primary: snapshot.primary,
secondary: extraWindow)
}
return (
primary: snapshot.primary,
secondary: snapshot.secondary)
}

static func resolvedRemaining(
snapshot: UsageSnapshot,
style: IconStyle)
style: IconStyle,
secondaryOverrideWindowID: String? = nil)
-> (primary: Double?, secondary: Double?)
{
if style == .perplexity {
Expand All @@ -72,6 +82,14 @@ enum IconRemainingResolver {
primary: windows.first?.remainingPercent,
secondary: windows.dropFirst().first?.remainingPercent)
}
if style == .copilot,
let secondaryOverrideWindowID,
let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window
{
return (
primary: snapshot.primary?.remainingPercent,
secondary: extraWindow.remainingPercent)
}
return (
primary: snapshot.primary?.remainingPercent,
secondary: snapshot.secondary?.remainingPercent)
Expand All @@ -80,10 +98,14 @@ enum IconRemainingResolver {
static func resolvedPercents(
snapshot: UsageSnapshot,
style: IconStyle,
showUsed: Bool)
showUsed: Bool,
secondaryOverrideWindowID: String? = nil)
-> (primary: Double?, secondary: Double?)
{
let windows = Self.resolvedWindows(snapshot: snapshot, style: style)
let windows = Self.resolvedWindows(
snapshot: snapshot,
style: style,
secondaryOverrideWindowID: secondaryOverrideWindowID)
return (
primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent,
secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent)
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ extension UsageMenuCardView.Model {
if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {
return []
}
if input.provider == .copilot, !input.copilotBudgetExtrasEnabled {
return []
}
return extraRateWindows.map { namedWindow in
let paceDetail = Self.extraRateWindowPaceDetail(
provider: input.provider,
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ extension UsageMenuCardView.Model {
let resetTimeDisplayStyle: ResetTimeDisplayStyle
let tokenCostUsageEnabled: Bool
let showOptionalCreditsAndExtraUsage: Bool
let copilotBudgetExtrasEnabled: Bool
let sourceLabel: String?
let kiloAutoMode: Bool
let hidePersonalInfo: Bool
Expand All @@ -744,6 +745,7 @@ extension UsageMenuCardView.Model {
resetTimeDisplayStyle: ResetTimeDisplayStyle,
tokenCostUsageEnabled: Bool,
showOptionalCreditsAndExtraUsage: Bool,
copilotBudgetExtrasEnabled: Bool = false,
sourceLabel: String? = nil,
kiloAutoMode: Bool = false,
hidePersonalInfo: Bool,
Expand All @@ -769,6 +771,7 @@ extension UsageMenuCardView.Model {
self.resetTimeDisplayStyle = resetTimeDisplayStyle
self.tokenCostUsageEnabled = tokenCostUsageEnabled
self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage
self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled
self.sourceLabel = sourceLabel
self.kiloAutoMode = kiloAutoMode
self.hidePersonalInfo = hidePersonalInfo
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ struct ProvidersPane: View {
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider),
showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage,
copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled,
hidePersonalInfo: self.settings.hidePersonalInfo,
weeklyPace: weeklyPace,
quotaWarningThresholds: [
Expand Down
147 changes: 147 additions & 0 deletions Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ struct CopilotProviderImplementation: ProviderImplementation {
func observeSettings(_ settings: SettingsStore) {
_ = settings.copilotAPIToken
_ = settings.copilotEnterpriseHost
_ = settings.copilotBudgetExtrasEnabled
_ = settings.copilotBudgetCookieSource
_ = settings.copilotBudgetCookieHeader
}

@MainActor
Expand All @@ -31,9 +34,153 @@ struct CopilotProviderImplementation: ProviderImplementation {
("Add Account...", .addProviderAccount(.copilot))
}

@MainActor
func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] {
let budgetExtrasBinding = Binding(
get: { context.settings.copilotBudgetExtrasEnabled },
set: { enabled in
context.settings.copilotBudgetExtrasEnabled = enabled
})
let budgetExtrasStatus: () -> String? = {
if context.store.snapshot(for: .copilot)?.extraRateWindows?.isEmpty == false {
return nil
}
if context.settings.copilotBudgetCookieSource == .manual,
context.settings.copilotBudgetCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return [
"Paste a github.com Cookie header, then refresh Copilot.",
"Copilot reauth does not provide the GitHub web cookie used for budgets.",
].joined(separator: " ")
}
return [
"Refresh Copilot to load budget bars.",
"Budget extras require a logged-in github.com browser session or a manual Cookie header.",
].joined(separator: " ")
}

return [
ProviderSettingsToggleDescriptor(
id: "copilot-budget-extras",
title: "Budget extras",
subtitle: [
"Optional.",
"Turn this on to fetch configured GitHub Copilot budget limits and show them as extra bars.",
].joined(separator: " "),
binding: budgetExtrasBinding,
statusText: budgetExtrasStatus,
actions: [],
isVisible: nil,
onChange: { enabled in
if enabled {
await context.store.refreshProvider(.copilot, allowDisabled: true)
} else {
context.store.clearCopilotBudgetExtras()
}
},
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
]
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
let extraWindows = context.store.snapshot(for: .copilot)?.extraRateWindows ?? []
let cookieBinding = Binding(
get: { context.settings.copilotBudgetCookieSource.rawValue },
set: { raw in
context.settings.copilotBudgetCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
})
let cookieOptions = ProviderCookieSourceUI.options(
allowsOff: false,
keychainDisabled: context.settings.debugDisableKeychainAccess)
let cookieSubtitle: () -> String? = {
ProviderCookieSourceUI.subtitle(
source: context.settings.copilotBudgetCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
auto: "Automatically imports browser cookies for github.com budget extras.",
manual: "Paste a Cookie header from github.com.",
off: "GitHub cookies are disabled.")
}
let options = [
ProviderSettingsPickerOption(
id: CopilotIconSecondaryWindowSelection.chat,
title: "Chat"),
] + extraWindows.map { window in
ProviderSettingsPickerOption(id: window.id, title: window.title)
}

return [
ProviderSettingsPickerDescriptor(
id: "copilot-icon-secondary-window",
title: "Menu bar secondary metric",
subtitle: "Choose the second meter shown in the menu bar icon.",
dynamicSubtitle: {
extraWindows.isEmpty
? "Budget options appear after a refresh finds configured Copilot budgets."
: nil
},
binding: Binding(
get: {
let selected = context.settings.copilotIconSecondaryWindowID
if selected == CopilotIconSecondaryWindowSelection.chat {
return selected
}
return extraWindows.contains(where: { $0.id == selected })
? selected
: CopilotIconSecondaryWindowSelection.chat
},
set: { selection in
context.settings.copilotIconSecondaryWindowID = selection
}),
options: options,
isVisible: { context.settings.copilotBudgetExtrasEnabled },
onChange: nil),
ProviderSettingsPickerDescriptor(
id: "copilot-budget-cookie-source",
title: "GitHub cookies",
subtitle: "Automatically imports browser cookies for budget extras.",
dynamicSubtitle: cookieSubtitle,
binding: cookieBinding,
options: cookieOptions,
isVisible: { context.settings.copilotBudgetExtrasEnabled },
onChange: { _ in
await context.store.refreshProvider(.copilot, allowDisabled: true)
},
trailingText: {
guard context.settings.copilotBudgetCookieSource != .manual else { return nil }
guard let entry = CookieHeaderCache.load(provider: .copilot) else { return nil }
let when = entry.storedAt.relativeDescription()
return "Cached: \(entry.sourceLabel) • \(when)"
}),
]
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "copilot-budget-cookie-header",
title: "Manual GitHub Cookie header",
subtitle: "Paste a github.com Cookie header. Treat this value like a password.",
kind: .secure,
placeholder: "Cookie: ...",
binding: context.stringBinding(\.copilotBudgetCookieHeader),
actions: [
ProviderSettingsActionDescriptor(
id: "refresh-copilot-budget-cookie",
title: "Refresh budgets",
style: .bordered,
isVisible: nil,
perform: {
await context.store.refreshProvider(.copilot, allowDisabled: true)
}),
],
isVisible: {
context.settings.copilotBudgetExtrasEnabled &&
context.settings.copilotBudgetCookieSource == .manual
},
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "copilot-enterprise-host",
title: "Enterprise host",
Expand Down
51 changes: 50 additions & 1 deletion Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import CodexBarCore
import Foundation

enum CopilotIconSecondaryWindowSelection {
static let chat = "chat"
}

extension SettingsStore {
var copilotAPIToken: String {
get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedAPIKey ?? "" }
Expand All @@ -21,7 +25,48 @@ extension SettingsStore {
}
}

var copilotBudgetCookieHeader: String {
get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedCookieHeader ?? "" }
set {
self.updateProviderConfig(provider: .copilot) { entry in
entry.cookieHeader = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .copilot, field: "cookieHeader", value: newValue)
}
}

var copilotBudgetCookieSource: ProviderCookieSource {
get { self.resolvedCookieSource(provider: .copilot, fallback: .auto) }
set {
self.updateProviderConfig(provider: .copilot) { entry in
entry.cookieSource = newValue
}
self.logProviderModeChange(provider: .copilot, field: "cookieSource", value: newValue.rawValue)
}
}

func ensureCopilotAPITokenLoaded() {}

var copilotIconSecondaryWindowID: String {
get {
let raw = self.copilotIconSecondaryWindowIDRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return raw.isEmpty ? CopilotIconSecondaryWindowSelection.chat : raw
}
set {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.copilotIconSecondaryWindowIDRaw = trimmed.isEmpty
? CopilotIconSecondaryWindowSelection.chat
: trimmed
}
}

func copilotIconSecondaryWindowOverrideID(snapshot: UsageSnapshot?) -> String? {
guard self.copilotBudgetExtrasEnabled else { return nil }
let selected = self.copilotIconSecondaryWindowID
guard selected != CopilotIconSecondaryWindowSelection.chat else { return nil }
guard snapshot?.extraRateWindows?.contains(where: { $0.id == selected }) == true else { return nil }
return selected
}
}

extension SettingsStore {
Expand All @@ -36,6 +81,10 @@ extension SettingsStore {
let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost)
return ProviderSettingsSnapshot.CopilotProviderSettings(
apiToken: self.normalizedConfigValue(token),
enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host)
enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host,
selectedAccountExternalIdentifier: account?.externalIdentifier.flatMap(self.normalizedConfigValue),
budgetExtrasEnabled: self.copilotBudgetExtrasEnabled,
budgetCookieSource: self.copilotBudgetCookieSource,
manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader))
}
}
19 changes: 19 additions & 0 deletions Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import CodexBarCore
import Foundation

@MainActor
extension UsageStore {
func clearCopilotBudgetExtras() {
if let snapshot = self.snapshots[.copilot],
snapshot.extraRateWindows?.isEmpty == false
{
let updated = snapshot.with(extraRateWindows: nil)
self.snapshots[.copilot] = updated
self.lastKnownResetSnapshots[.copilot] = updated
} else if let resetSnapshot = self.lastKnownResetSnapshots[.copilot],
resetSnapshot.extraRateWindows?.isEmpty == false
{
self.lastKnownResetSnapshots[.copilot] = resetSnapshot.with(extraRateWindows: nil)
}
}
}
19 changes: 19 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ extension SettingsStore {
}
}

var copilotIconSecondaryWindowIDRaw: String {
get { self.defaultsState.copilotIconSecondaryWindowIDRaw }
set {
self.defaultsState.copilotIconSecondaryWindowIDRaw = newValue
self.userDefaults.set(newValue, forKey: "copilotIconSecondaryWindowID")
}
}

var costUsageEnabled: Bool {
get { self.defaultsState.costUsageEnabled }
set {
Expand Down Expand Up @@ -375,6 +383,17 @@ extension SettingsStore {
set { self.claudeWebExtrasEnabledRaw = newValue }
}

var copilotBudgetExtrasEnabled: Bool {
get { self.defaultsState.copilotBudgetExtrasEnabled }
set {
self.defaultsState.copilotBudgetExtrasEnabled = newValue
self.userDefaults.set(newValue, forKey: "copilotBudgetExtrasEnabled")
CodexBarLog.logger(LogCategories.settings).info(
"Copilot budget extras updated",
metadata: ["enabled": newValue ? "1" : "0"])
}
}

private var claudeWebExtrasEnabledRaw: Bool {
get { self.defaultsState.claudeWebExtrasEnabledRaw }
set {
Expand Down
Loading