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
42 changes: 42 additions & 0 deletions Sources/CodexBar/CodexResetCreditExpiryNotifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import CodexBarCore
import Foundation

@MainActor
struct CodexResetCreditExpiryNotifier {
static let expiryWindow: TimeInterval = 3 * 24 * 60 * 60

var userDefaults: UserDefaults = .standard
var notificationPoster: (String, String, String) -> Void = { id, title, body in
AppNotifications.shared.post(idPrefix: id, title: title, body: body)
}

func postExpiringCreditsIfNeeded(snapshot: CodexRateLimitResetCreditsSnapshot, now: Date = Date()) {
let key = "codexResetCreditExpiryNotificationsPosted"
var posted = Set(self.userDefaults.stringArray(forKey: key) ?? [])
var changed = false

for credit in snapshot.credits {
guard credit.status == .available,
let expiresAt = credit.expiresAt,
expiresAt > now,
expiresAt.timeIntervalSince(now) <= Self.expiryWindow,
!posted.contains(credit.id)
else {
continue
}

posted.insert(credit.id)
changed = true
self.notificationPoster(
"codex-reset-credit-expiring-\(credit.id)",
L("Codex reset expires soon"),
String(
format: L("A Codex reset credit expires %@."),
UsageFormatter.resetDescription(from: expiresAt, now: now)))
}

if changed {
self.userDefaults.set(Array(posted).sorted(), forKey: key)
}
}
}
14 changes: 12 additions & 2 deletions Sources/CodexBar/MenuCardHeightFingerprint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@ extension UsageMenuCardView.Model {
"creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")",
MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText),
MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText),
MenuCardHeightFingerprint.field("codexResetCredits", self.codexResetCreditsText),
MenuCardHeightFingerprint.field("codexResetCreditsDetail", self.codexResetCreditsDetailText),
"metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))",
"notes=\(notesFingerprint)",
"dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")",
"providerCost=\(self.providerCost?.heightFingerprint ?? "")",
"tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")",
"codexResetCredits=\(self.codexResetCredits?.heightFingerprint ?? "")",
"openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")",
] + additional)
}
Expand Down Expand Up @@ -119,6 +118,17 @@ extension UsageMenuCardView.Model.TokenUsageSection {
}
}

extension CodexResetCreditsPresentation {
fileprivate var heightFingerprint: String {
MenuCardHeightFingerprint.join([
MenuCardHeightFingerprint.field("text", self.text),
MenuCardHeightFingerprint.field("detail", self.detailText),
MenuCardHeightFingerprint.field("help", self.helpText),
self.creditToConsume == nil ? "consume=0" : "consume=1",
])
}
}

extension InlineUsageDashboardModel {
fileprivate var heightFingerprint: String {
MenuCardHeightFingerprint.join([
Expand Down
91 changes: 75 additions & 16 deletions Sources/CodexBar/MenuCardView+CodexResetCredits.swift
Original file line number Diff line number Diff line change
@@ -1,63 +1,87 @@
import CodexBarCore
import SwiftUI

struct CodexResetCreditsContent: View {
struct CodexResetCreditsPresentation: Equatable {
let text: String
let detailText: String?
let helpText: String?
let creditToConsume: CodexRateLimitResetCredit?
}

struct CodexResetCreditsContent: View {
let model: CodexResetCreditsPresentation

@Environment(\.menuItemHighlighted) private var isHighlighted
@Environment(\.codexResetCreditConsumer) private var consumeCredit

var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(L("Limit Reset Credits"))
.font(.body)
.fontWeight(.medium)
.lineLimit(1)
HStack(alignment: .firstTextBaseline) {
Text(self.text)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.model.text)
.font(.footnote.weight(.semibold))
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.lineLimit(1)
.layoutPriority(1)
Spacer()
if let detailText, !detailText.isEmpty {
if let detailText = self.model.detailText, !detailText.isEmpty {
Text(detailText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
if let creditToConsume = self.model.creditToConsume, let consumeCredit {
Button(L("Use Reset")) {
consumeCredit(creditToConsume)
}
.buttonStyle(.borderless)
.controlSize(.small)
.help(L("Use the next expiring Codex reset credit"))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.help(self.model.helpText ?? self.model.text)
.accessibilityElement(children: .combine)
.accessibilityLabel([
L("Limit Reset Credits"),
self.text,
self.detailText,
self.model.text,
self.model.detailText,
].compactMap(\.self).joined(separator: ", "))
}
}

extension UsageMenuCardView.Model {
static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? {
guard input.showOptionalCreditsAndExtraUsage else { return nil }
guard let text = codexResetCreditsText(input: input) else { return nil }
return CodexResetCreditsPresentation(
text: text,
detailText: Self.codexResetCreditsDetailText(input: input),
helpText: Self.codexResetCreditsHelpText(input: input),
creditToConsume: Self.codexResetCreditToConsume(input: input))
}

static func codexResetCreditsText(input: Input) -> String? {
guard input.provider == .codex,
input.showOptionalCreditsAndExtraUsage,
let resetCredits = input.snapshot?.codexResetCredits,
resetCredits.availableCount > 0
else {
return nil
}
let count = resetCredits.availableCount
guard let count = codexCurrentResetCreditCount(input: input), count > 0 else { return nil }
if count == 1 {
return L("1 available")
}
return String(format: L("%d available"), count)
}

static func codexResetCreditToConsume(input: Input) -> CodexRateLimitResetCredit? {
guard input.provider == .codex else { return nil }
return input.snapshot?.codexResetCredits?.nextExpiringAvailableCredit(at: input.now)
}

static func codexResetCreditsDetailText(input: Input) -> String? {
guard input.provider == .codex,
input.showOptionalCreditsAndExtraUsage,
let resetCredits = input.snapshot?.codexResetCredits,
let expiresAt = resetCredits.nextExpiringAvailableCredit?.expiresAt
let expiresAt = resetCredits.nextExpiringAvailableCredit(at: input.now)?.expiresAt
else {
return nil
}
Expand All @@ -71,4 +95,39 @@ extension UsageMenuCardView.Model {
}
return String(format: L("Next expires %@"), timeText)
}

static func codexResetCreditsHelpText(input: Input) -> String? {
guard input.provider == .codex,
let resetCredits = input.snapshot?.codexResetCredits
else {
return nil
}
let lines = resetCredits.credits.map { credit in
let expires = Self.codexResetCreditExpiryText(credit, now: input.now)
return "\(credit.status.rawValue), \(expires)"
}
return lines.isEmpty ? nil : lines.joined(separator: "\n")
}

private static func codexCurrentResetCreditCount(input: Input) -> Int? {
guard input.provider == .codex,
let resetCredits = input.snapshot?.codexResetCredits
else {
return nil
}
guard !resetCredits.credits.isEmpty else { return resetCredits.availableCount }
return resetCredits.availableCredits(at: input.now).count
}

private static func codexResetCreditExpiryText(
_ credit: CodexRateLimitResetCredit,
now: Date)
-> String
{
guard let expiresAt = credit.expiresAt else { return L("No expiry") }
let absolute = UsageFormatter.resetDescription(from: expiresAt, now: now)
guard credit.status == .available, expiresAt > now else { return absolute }
let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now)
return "\(countdown) (\(absolute))"
}
}
7 changes: 3 additions & 4 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,14 @@ extension UsageMenuCardView.Model {
!self.usageNotes.isEmpty ||
self.openAIAPIUsage != nil ||
self.inlineUsageDashboard != nil ||
self.codexResetCreditsText != nil ||
self.codexResetCredits != nil ||
self.placeholder != nil
}

var usesStackedDetailLayout: Bool {
!self.metrics.isEmpty ||
self.creditsText != nil ||
self.codexResetCreditsText != nil ||
self.codexResetCredits != nil ||
self.providerCost != nil ||
self.tokenUsage != nil
}
Expand Down Expand Up @@ -107,8 +107,7 @@ extension UsageMenuCardView.Model {
candidateText: candidate.creditsText,
candidateRemaining: candidate.creditsRemaining),
self.creditsHintText == candidate.creditsHintText,
self.codexResetCreditsText == candidate.codexResetCreditsText,
self.codexResetCreditsDetailText == candidate.codexResetCreditsDetailText,
self.codexResetCredits == candidate.codexResetCredits,
self.placeholder == candidate.placeholder,
Self.hasCompatibleDashboardLayout(self.inlineUsageDashboard, candidate.inlineUsageDashboard),
Self.hasCompatibleProviderCostLayout(self.providerCost, candidate.providerCost),
Expand Down
14 changes: 5 additions & 9 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,7 @@ struct UsageMenuCardView: View {
var creditsProgressPercent: Double?, creditsScaleText: String?
let creditsHintText: String?
let creditsHintCopyText: String?
var codexResetCreditsText: String?
var codexResetCreditsDetailText: String?
var codexResetCredits: CodexResetCreditsPresentation?
let providerCost: ProviderCostSection?
let tokenUsage: TokenUsageSection?
let placeholder: String?
Expand Down Expand Up @@ -549,20 +548,18 @@ private struct UsageMenuCardUsageContentView: View {
title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
}
if let resetCredits = self.model.codexResetCreditsText {
if let resetCredits = self.model.codexResetCredits {
if !self.model.metrics.isEmpty {
Divider()
}
CodexResetCreditsContent(
text: resetCredits,
detailText: self.model.codexResetCreditsDetailText)
CodexResetCreditsContent(model: resetCredits)
}
if let dashboard = self.model.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder, self.model.metrics.isEmpty,
self.model.codexResetCreditsText == nil
self.model.codexResetCredits == nil
{
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
Expand Down Expand Up @@ -859,8 +856,7 @@ extension UsageMenuCardView.Model {
creditsScaleText: creditsScaleText,
creditsHintText: codexCreditLimitDetail ?? redacted.creditsHintText,
creditsHintCopyText: codexCreditLimitDetail ?? redacted.creditsHintCopyText,
codexResetCreditsText: Self.codexResetCreditsText(input: input),
codexResetCreditsDetailText: Self.codexResetCreditsDetailText(input: input),
codexResetCredits: Self.codexResetCredits(input: input),
providerCost: providerCost,
tokenUsage: tokenUsage,
placeholder: placeholder,
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/MenuHighlightStyle.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CodexBarCore
import SwiftUI

extension EnvironmentValues {
Expand All @@ -6,6 +7,7 @@ extension EnvironmentValues {
/// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu
/// stays open, without rebuilding the menu during AppKit tracking.
@Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor?
@Entry var codexResetCreditConsumer: ((CodexRateLimitResetCredit) -> Void)?
}

enum MenuHighlightStyle {
Expand Down
Loading