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: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.48.2 — Unreleased

### Added
- Copilot: add a per-seat "Credits used" row with a user-entered credit entitlement that turns the row into a usage bar — GitHub publishes no credit entitlement on any documented endpoint, so a row without one stays plain text, and the row only appears when it carries real signal (token-based billing, unlimited quota, or actual consumption) (#2593). Thanks @KSEGIT!

### Fixed
- Codex: SQLite cost saves no longer rescan every stored row and snapshot per file — baseline counts and file lookups are precomputed once, cutting a large-corpus (1,700+ sessions) save pass from minutes of CPU to seconds (refs #2760).
- Codex: restore JSON-cache retention semantics lost in the SQLite cutover — discovery pruning now reaches the scanner's round-tripped payload so deleted files stop resurfacing, the row budget never sacrifices in-window or recently active sessions, and fork-parent protection again drops stale lineage-only parents (refs #2760).
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/MenuCardHeightFingerprint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ extension [ProviderDetailSection] {
MenuCardHeightFingerprint.field("title", section.title),
MenuCardHeightFingerprint.join(section.rows.map { row in
MenuCardHeightFingerprint.join([
MenuCardHeightFingerprint.field("id", row.id),
MenuCardHeightFingerprint.field("label", row.label),
MenuCardHeightFingerprint.field("value", row.value),
MenuCardHeightFingerprint.field("secondary", row.secondaryValue),
"progress=\(row.progress == nil ? "0" : "1")",
])
}),
section.chart.map { chart in
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1009,11 +1009,13 @@ extension UsageMenuCardView.Model {
return details.compactMap { section in
let rows = section.rows.compactMap { row in
try? ProviderDetailSection.Row(
id: row.id,
label: PersonalInfoRedactor.redactEmails(in: row.label, isEnabled: true) ?? row.label,
value: PersonalInfoRedactor.redactEmails(in: row.value, isEnabled: true) ?? row.value,
secondaryValue: PersonalInfoRedactor.redactEmails(
in: row.secondaryValue,
isEnabled: true))
isEnabled: true),
progress: row.progress)
}
let chart = section.chart.flatMap { chart in
let points = chart.points.compactMap { point in
Expand Down
38 changes: 24 additions & 14 deletions Sources/CodexBar/ProviderDetailSectionsContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,33 @@ struct ProviderDetailSectionsContent: View {
.lineLimit(1)
}
ForEach(Array(section.rows.enumerated()), id: \.offset) { _, row in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.label)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 1) {
Text(row.value)
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.fontWeight(.medium)
if let secondaryValue = row.secondaryValue {
Text(secondaryValue)
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.label)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 1) {
Text(row.value)
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.fontWeight(.medium)
if let secondaryValue = row.secondaryValue {
Text(secondaryValue)
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
}
}
.font(.caption)
.lineLimit(1)
if let progress = row.progress {
// The ratio is provider data left unclamped by contract; only the bar's fill
// is clamped here, the "used / total" caption keeps the raw numbers.
UsageProgressBar(
percent: min(100, max(0, progress.usedPercent)),
tint: self.chartColor,
accessibilityLabel: row.label)
}
}
.font(.caption)
.lineLimit(1)
}
if let chart = section.chart {
ProviderDetailChartContent(chart: chart, color: self.chartColor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct CopilotProviderImplementation: ProviderImplementation {
_ = settings.copilotBudgetExtrasEnabled
_ = settings.copilotBudgetCookieSource
_ = settings.copilotBudgetCookieHeader
_ = settings.copilotSeatCreditEntitlementRaw
}

@MainActor
Expand Down Expand Up @@ -155,7 +156,15 @@ struct CopilotProviderImplementation: ProviderImplementation {

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
let seatEntitlementBinding = Binding(
get: { context.settings.copilotEffectiveSeatCreditEntitlementRaw },
set: { newValue in
context.settings.copilotEffectiveSeatCreditEntitlementRaw = newValue
// Rewrite the cached row locally so a stale denominator/bar never survives a failed
// (or offline) refresh; no network call here.
context.store.updateCopilotSeatCreditEntitlement(CopilotCreditEntitlementParser.parse(newValue))
})
return [
ProviderSettingsFieldDescriptor(
id: "copilot-budget-cookie-header",
title: "Manual GitHub Cookie header",
Expand Down Expand Up @@ -189,6 +198,17 @@ struct CopilotProviderImplementation: ProviderImplementation {
actions: [],
isVisible: nil,
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "copilot-seat-credit-entitlement",
title: "Included AI credits (per seat)",
subtitle: "GitHub does not publish this value. Enter it to show a usage bar. " +
"Applies to the selected GitHub account.",
kind: .plain,
placeholder: "e.g. 3000",
binding: seatEntitlementBinding,
actions: [],
isVisible: nil,
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "copilot-add-account",
title: "GitHub Login",
Expand Down
26 changes: 25 additions & 1 deletion Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ extension SettingsStore {
}

extension SettingsStore {
/// Effective per-seat AI credit allowance raw value: the selected account's override when set,
/// otherwise the global fallback. Writes go to the selected account when one is configured,
/// else to the global fallback so users without saved accounts are unaffected.
var copilotEffectiveSeatCreditEntitlementRaw: String {
get {
self.effectiveSelectedTokenAccount(for: .copilot)?.sanitizedSeatCreditEntitlement
?? self.copilotSeatCreditEntitlementRaw
}
set {
if let account = self.effectiveSelectedTokenAccount(for: .copilot) {
self.updateTokenAccount(
provider: .copilot,
accountID: account.id,
seatCreditEntitlement: newValue)
} else {
self.copilotSeatCreditEntitlementRaw = newValue
}
}
}

func copilotSettingsSnapshot(
tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CopilotProviderSettings
{
Expand All @@ -79,12 +99,16 @@ extension SettingsStore {
override: tokenOverride)
let token = account?.token ?? self.copilotAPIToken
let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost)
// Per-account allowances win; the global UserDefaults values remain the fallback for
// legacy installs and accounts that never set one (migration path, keys kept on purpose).
let seatEntitlementRaw = account?.sanitizedSeatCreditEntitlement ?? self.copilotSeatCreditEntitlementRaw
return ProviderSettingsSnapshot.CopilotProviderSettings(
apiToken: self.normalizedConfigValue(token),
enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host,
selectedAccountExternalIdentifier: account?.externalIdentifier.flatMap(self.normalizedConfigValue),
budgetExtrasEnabled: self.copilotBudgetExtrasEnabled,
budgetCookieSource: self.copilotBudgetCookieSource,
manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader))
manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader),
seatCreditEntitlement: CopilotCreditEntitlementParser.parse(seatEntitlementRaw))
}
}
65 changes: 65 additions & 0 deletions Sources/CodexBar/Providers/Copilot/UsageStore+CopilotCredits.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import CodexBarCore
import Foundation

@MainActor
extension UsageStore {
/// Rewrites the seat "Credits used" row when the user changes or clears the per-seat entitlement,
/// mirroring `clearCopilotBudgetExtras()`. Called synchronously from the settings field's binding
/// setter so editing the value cannot leave a stale denominator/bar on the card if the follow-up
/// refresh never lands (offline, token lost, 401) and the last-good snapshot is retained.
func updateCopilotSeatCreditEntitlement(_ entitlement: Double?) {
if let snapshot = self.snapshots[.copilot],
let updated = snapshot.updatingCopilotSeatCreditEntitlement(entitlement)
{
self.snapshots[.copilot] = updated
self.lastKnownResetSnapshots[.copilot] = updated
} else if let resetSnapshot = self.lastKnownResetSnapshots[.copilot],
let updated = resetSnapshot.updatingCopilotSeatCreditEntitlement(entitlement)
{
self.lastKnownResetSnapshots[.copilot] = updated
}
}
}

extension UsageSnapshot {
/// Returns a copy with the seat credits row rebuilt for `entitlement`, or `nil` when nothing
/// changed (no row, or a row that carries no numeric usage at all — the next refresh must
/// rebuild it). The numerator comes from `row.progress.used`, falling back to the retained
/// `row.usageValue` on text-only rows, never re-parsed from the display string. That fallback
/// is what lets a cached text-only row grow a bar the moment an entitlement is entered, even
/// when the follow-up refresh never lands (offline, token lost, 401).
func updatingCopilotSeatCreditEntitlement(_ entitlement: Double?) -> UsageSnapshot? {
guard self.details.lazy.flatMap(\.rows)
.contains(where: {
$0.id == CopilotCreditDetailRows.seatRowID && ($0.progress != nil || $0.usageValue != nil)
})
else { return nil }
let details = self.details.map { section -> ProviderDetailSection in
let rows = section.rows.map { row -> ProviderDetailSection.Row in
guard row.id == CopilotCreditDetailRows.seatRowID, let used = row.progress?.used ?? row.usageValue
else { return row }
let value: String
let progress: ProviderDetailSection.Row.Progress?
if let entitlement {
guard let rebuilt = try? ProviderDetailSection.Row.Progress(used: used, total: entitlement)
else { return row }
value = "\(UsageFormatter.creditsNumberString(from: used)) / " +
UsageFormatter.creditsNumberString(from: entitlement)
progress = rebuilt
} else {
value = UsageFormatter.creditsNumberString(from: used)
progress = nil
}
return (try? ProviderDetailSection.Row(
id: row.id,
label: row.label,
value: value,
secondaryValue: row.secondaryValue,
progress: progress,
usageValue: used)) ?? row
}
return (try? ProviderDetailSection(title: section.title, rows: rows, chart: section.chart)) ?? section
}
return self.with(details: details)
}
}
9 changes: 9 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,15 @@ extension SettingsStore {
}
}

var copilotSeatCreditEntitlementRaw: String {
get { self.defaultsState.copilotSeatCreditEntitlementRaw }
set {
self.defaultsState.copilotSeatCreditEntitlementRaw = newValue
self.userDefaults.set(newValue, forKey: "copilotSeatCreditEntitlement")
self.noteBackgroundWorkSettingsChanged()
}
}

private var claudeWebExtrasEnabledRaw: Bool {
get { self.defaultsState.claudeWebExtrasEnabledRaw }
set {
Expand Down
13 changes: 11 additions & 2 deletions Sources/CodexBar/SettingsStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ extension SettingsStore {
externalIdentifier: String?? = nil,
usageScope: String?? = nil,
organizationID: String?? = nil,
workspaceID: String?? = nil)
workspaceID: String?? = nil,
seatCreditEntitlement: String?? = nil)
{
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return }
guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return }
Expand Down Expand Up @@ -151,6 +152,13 @@ extension SettingsStore {
} else {
resolvedWorkspaceID = existing.workspaceID
}
let resolvedSeatCreditEntitlement: String?
if let seatCreditEntitlement {
let trimmed = seatCreditEntitlement?.trimmingCharacters(in: .whitespacesAndNewlines)
resolvedSeatCreditEntitlement = (trimmed?.isEmpty ?? true) ? nil : trimmed
} else {
resolvedSeatCreditEntitlement = existing.seatCreditEntitlement
}
let updatedAccount = ProviderTokenAccount(
id: existing.id,
label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label,
Expand All @@ -160,7 +168,8 @@ extension SettingsStore {
externalIdentifier: resolvedIdentifier,
usageScope: resolvedUsageScope,
organizationID: resolvedOrganizationID,
workspaceID: resolvedWorkspaceID)
workspaceID: resolvedWorkspaceID,
seatCreditEntitlement: resolvedSeatCreditEntitlement)

var accounts = data.accounts
accounts[index] = updatedAccount
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ extension SettingsStore {
?? MenuBarLayoutGap.regular.rawValue
let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false
let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults)
let copilotSeatCreditEntitlementRaw = userDefaults.object(
forKey: "copilotSeatCreditEntitlement") as? String ?? ""
let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false
let codexLocalSessionCostLedgerEnabled = userDefaults.object(
forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false
Expand Down Expand Up @@ -593,6 +595,7 @@ extension SettingsStore {
menuBarLayoutGapRaw: menuBarLayoutGapRaw,
copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled,
copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw,
copilotSeatCreditEntitlementRaw: copilotSeatCreditEntitlementRaw,
costUsageEnabled: costUsageEnabled,
codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled,
costUsageHistoryDays: costUsageHistoryDays,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ struct SettingsDefaultsState {
var menuBarLayoutGapRaw: String
var copilotBudgetExtrasEnabled: Bool
var copilotIconSecondaryWindowIDRaw: String
var copilotSeatCreditEntitlementRaw: String
var costUsageEnabled: Bool
var codexLocalSessionCostLedgerEnabled: Bool
var costUsageHistoryDays: Int
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ struct TokenAccountCLIContext {
externalIdentifier: existing.externalIdentifier,
usageScope: existing.usageScope,
organizationID: existing.organizationID,
workspaceID: existing.workspaceID)
workspaceID: existing.workspaceID,
seatCreditEntitlement: existing.seatCreditEntitlement)
providerConfig.tokenAccounts = ProviderTokenAccountData(
version: data.version,
accounts: accounts,
Expand Down
Loading