Skip to content

Commit 35fbd30

Browse files
Add Codex reset credit menu
1 parent 3f3e2f4 commit 35fbd30

30 files changed

Lines changed: 818 additions & 112 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import CodexBarCore
2+
import Foundation
3+
4+
@MainActor
5+
struct CodexResetCreditExpiryNotifier {
6+
static let expiryWindow: TimeInterval = 3 * 24 * 60 * 60
7+
8+
var userDefaults: UserDefaults = .standard
9+
var notificationPoster: (String, String, String) -> Void = { id, title, body in
10+
AppNotifications.shared.post(idPrefix: id, title: title, body: body)
11+
}
12+
13+
func postExpiringCreditsIfNeeded(snapshot: CodexRateLimitResetCreditsSnapshot, now: Date = Date()) {
14+
let key = "codexResetCreditExpiryNotificationsPosted"
15+
var posted = Set(self.userDefaults.stringArray(forKey: key) ?? [])
16+
var changed = false
17+
18+
for credit in snapshot.credits {
19+
guard credit.status == .available,
20+
let expiresAt = credit.expiresAt,
21+
expiresAt > now,
22+
expiresAt.timeIntervalSince(now) <= Self.expiryWindow,
23+
!posted.contains(credit.id)
24+
else {
25+
continue
26+
}
27+
28+
posted.insert(credit.id)
29+
changed = true
30+
self.notificationPoster(
31+
"codex-reset-credit-expiring-\(credit.id)",
32+
L("Codex reset expires soon"),
33+
String(
34+
format: L("A Codex reset credit expires %@."),
35+
UsageFormatter.resetDescription(from: expiresAt, now: now)))
36+
}
37+
38+
if changed {
39+
self.userDefaults.set(Array(posted).sorted(), forKey: key)
40+
}
41+
}
42+
}

Sources/CodexBar/MenuCardHeightFingerprint.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ extension UsageMenuCardView.Model {
1919
"creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")",
2020
MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText),
2121
MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText),
22-
MenuCardHeightFingerprint.field("codexResetCredits", self.codexResetCreditsText),
23-
MenuCardHeightFingerprint.field("codexResetCreditsDetail", self.codexResetCreditsDetailText),
2422
"metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))",
2523
"notes=\(notesFingerprint)",
2624
"dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")",
Lines changed: 104 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,114 @@
11
import CodexBarCore
22
import SwiftUI
33

4-
struct CodexResetCreditsContent: View {
4+
struct CodexResetCreditsPresentation: Equatable {
55
let text: String
66
let detailText: String?
7+
let helpText: String?
8+
let creditToConsume: CodexRateLimitResetCredit?
9+
}
10+
11+
struct CodexResetCreditsContent: View {
12+
let model: CodexResetCreditsPresentation
13+
14+
@State private var showsDetails = false
15+
716
@Environment(\.menuItemHighlighted) private var isHighlighted
17+
@Environment(\.codexResetCreditConsumer) private var consumeCredit
818

919
var body: some View {
10-
VStack(alignment: .leading, spacing: 4) {
11-
HStack(alignment: .firstTextBaseline) {
12-
Text(L("Limit Reset Credits"))
13-
.font(.body)
14-
.fontWeight(.medium)
15-
.lineLimit(1)
16-
Spacer()
17-
Text(self.text)
18-
.font(.footnote)
19-
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
20-
.lineLimit(1)
20+
VStack(alignment: .leading, spacing: 6) {
21+
Button {
22+
self.showsDetails.toggle()
23+
} label: {
24+
HStack(alignment: .firstTextBaseline) {
25+
Text(self.model.text)
26+
.font(.body)
27+
.fontWeight(.medium)
28+
.lineLimit(1)
29+
Spacer()
30+
Image(systemName: self.showsDetails ? "chevron.down" : "chevron.right")
31+
.font(.caption)
32+
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
33+
}
2134
}
22-
if let detailText, !detailText.isEmpty {
23-
Text(detailText)
24-
.font(.footnote)
25-
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
26-
.lineLimit(1)
35+
.buttonStyle(.plain)
36+
.help(self.model.helpText ?? self.model.text)
37+
38+
if self.showsDetails {
39+
ForEach(self.detailLines, id: \.self) { line in
40+
Text(line)
41+
.font(.footnote)
42+
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
43+
.lineLimit(2)
44+
}
45+
}
46+
47+
HStack(alignment: .firstTextBaseline, spacing: 8) {
48+
if let detailText = self.model.detailText, !detailText.isEmpty {
49+
Text(detailText)
50+
.font(.footnote)
51+
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
52+
.lineLimit(1)
53+
}
54+
Spacer()
55+
if let creditToConsume = self.model.creditToConsume, let consumeCredit {
56+
Button(L("Use Reset")) {
57+
consumeCredit(creditToConsume)
58+
}
59+
.buttonStyle(.borderless)
60+
.controlSize(.small)
61+
.help(L("Use the next expiring Codex reset credit"))
62+
}
2763
}
2864
}
2965
.frame(maxWidth: .infinity, alignment: .leading)
66+
.help(self.model.helpText ?? self.model.text)
3067
.accessibilityElement(children: .combine)
3168
.accessibilityLabel([
32-
L("Limit Reset Credits"),
33-
self.text,
34-
self.detailText,
69+
self.model.text,
70+
self.model.detailText,
3571
].compactMap(\.self).joined(separator: ", "))
3672
}
73+
74+
private var detailLines: [String] {
75+
self.model.helpText?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? []
76+
}
3777
}
3878

3979
extension UsageMenuCardView.Model {
80+
static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? {
81+
guard let text = codexResetCreditsText(input: input) else { return nil }
82+
return CodexResetCreditsPresentation(
83+
text: text,
84+
detailText: Self.codexResetCreditsDetailText(input: input),
85+
helpText: Self.codexResetCreditsHelpText(input: input),
86+
creditToConsume: Self.codexResetCreditToConsume(input: input))
87+
}
88+
4089
static func codexResetCreditsText(input: Input) -> String? {
4190
guard input.provider == .codex,
42-
input.showOptionalCreditsAndExtraUsage,
4391
let resetCredits = input.snapshot?.codexResetCredits,
4492
resetCredits.availableCount > 0
4593
else {
4694
return nil
4795
}
48-
let count = resetCredits.availableCount
49-
if count == 1 {
96+
if resetCredits.availableCount == 1 {
5097
return L("1 manual reset available")
5198
}
52-
return String(format: L("%d manual resets available"), count)
99+
return String(format: L("%d manual resets available"), resetCredits.availableCount)
100+
}
101+
102+
static func codexResetCreditToConsume(input: Input) -> CodexRateLimitResetCredit? {
103+
guard input.provider == .codex
104+
else {
105+
return nil
106+
}
107+
return input.snapshot?.codexResetCredits?.nextExpiringAvailableCredit
53108
}
54109

55110
static func codexResetCreditsDetailText(input: Input) -> String? {
56111
guard input.provider == .codex,
57-
input.showOptionalCreditsAndExtraUsage,
58112
let resetCredits = input.snapshot?.codexResetCredits,
59113
let expiresAt = resetCredits.nextExpiringAvailableCredit?.expiresAt
60114
else {
@@ -70,4 +124,29 @@ extension UsageMenuCardView.Model {
70124
}
71125
return String(format: L("Next expires %@"), timeText)
72126
}
127+
128+
static func codexResetCreditsHelpText(input: Input) -> String? {
129+
guard input.provider == .codex,
130+
let resetCredits = input.snapshot?.codexResetCredits
131+
else {
132+
return nil
133+
}
134+
let lines = resetCredits.credits.map { credit in
135+
let expires = Self.codexResetCreditExpiryText(credit, now: input.now)
136+
return "\(credit.status.rawValue), \(expires)"
137+
}
138+
return lines.isEmpty ? nil : lines.joined(separator: "\n")
139+
}
140+
141+
private static func codexResetCreditExpiryText(
142+
_ credit: CodexRateLimitResetCredit,
143+
now: Date)
144+
-> String
145+
{
146+
guard let expiresAt = credit.expiresAt else { return L("No expiry") }
147+
let absolute = UsageFormatter.resetDescription(from: expiresAt, now: now)
148+
guard credit.status == .available, expiresAt > now else { return absolute }
149+
let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now)
150+
return "\(countdown) (\(absolute))"
151+
}
73152
}

Sources/CodexBar/MenuCardView+ModelHelpers.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,12 @@ extension UsageMenuCardView.Model {
6666
!self.usageNotes.isEmpty ||
6767
self.openAIAPIUsage != nil ||
6868
self.inlineUsageDashboard != nil ||
69-
self.codexResetCreditsText != nil ||
7069
self.placeholder != nil
7170
}
7271

7372
var usesStackedDetailLayout: Bool {
7473
!self.metrics.isEmpty ||
7574
self.creditsText != nil ||
76-
self.codexResetCreditsText != nil ||
7775
self.providerCost != nil ||
7876
self.tokenUsage != nil
7977
}
@@ -108,8 +106,7 @@ extension UsageMenuCardView.Model {
108106
candidateText: candidate.creditsText,
109107
candidateRemaining: candidate.creditsRemaining),
110108
self.creditsHintText == candidate.creditsHintText,
111-
self.codexResetCreditsText == candidate.codexResetCreditsText,
112-
self.codexResetCreditsDetailText == candidate.codexResetCreditsDetailText,
109+
self.codexResetCredits == candidate.codexResetCredits,
113110
self.placeholder == candidate.placeholder,
114111
Self.hasCompatibleDashboardLayout(self.inlineUsageDashboard, candidate.inlineUsageDashboard),
115112
Self.hasCompatibleProviderCostLayout(self.providerCost, candidate.providerCost),

Sources/CodexBar/MenuCardView.swift

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,7 @@ struct UsageMenuCardView: View {
120120
let creditsRemaining: Double?
121121
let creditsHintText: String?
122122
let creditsHintCopyText: String?
123-
var codexResetCreditsText: String?
124-
var codexResetCreditsDetailText: String?
123+
var codexResetCredits: CodexResetCreditsPresentation?
125124
let providerCost: ProviderCostSection?
126125
let tokenUsage: TokenUsageSection?
127126
let placeholder: String?
@@ -175,12 +174,6 @@ struct UsageMenuCardView: View {
175174
title: Self.popupMetricTitle(provider: liveModel.provider, metric: metric),
176175
progressColor: liveModel.progressColor)
177176
}
178-
if let resetCredits = liveModel.codexResetCreditsText {
179-
Divider()
180-
CodexResetCreditsContent(
181-
text: resetCredits,
182-
detailText: liveModel.codexResetCreditsDetailText)
183-
}
184177
if let dashboard = liveModel.inlineUsageDashboard {
185178
InlineUsageDashboardContent(model: dashboard)
186179
} else if !liveModel.usageNotes.isEmpty {
@@ -571,20 +564,12 @@ struct UsageMenuCardUsageSectionView: View {
571564
title: UsageMenuCardView.popupMetricTitle(provider: liveModel.provider, metric: metric),
572565
progressColor: liveModel.progressColor)
573566
}
574-
if let resetCredits = liveModel.codexResetCreditsText {
575-
if !liveModel.metrics.isEmpty {
576-
Divider()
577-
}
578-
CodexResetCreditsContent(
579-
text: resetCredits,
580-
detailText: liveModel.codexResetCreditsDetailText)
581-
}
582567
if let dashboard = liveModel.inlineUsageDashboard {
583568
InlineUsageDashboardContent(model: dashboard)
584569
} else if !liveModel.usageNotes.isEmpty {
585570
UsageNotesContent(notes: liveModel.usageNotes)
586571
} else if let placeholder = liveModel.placeholder, liveModel.metrics.isEmpty,
587-
liveModel.codexResetCreditsText == nil
572+
liveModel.codexResetCredits == nil
588573
{
589574
Text(placeholder)
590575
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
@@ -943,8 +928,7 @@ extension UsageMenuCardView.Model {
943928
creditsRemaining: input.credits?.remaining,
944929
creditsHintText: redacted.creditsHintText,
945930
creditsHintCopyText: redacted.creditsHintCopyText,
946-
codexResetCreditsText: Self.codexResetCreditsText(input: input),
947-
codexResetCreditsDetailText: Self.codexResetCreditsDetailText(input: input),
931+
codexResetCredits: Self.codexResetCredits(input: input),
948932
providerCost: providerCost,
949933
tokenUsage: tokenUsage,
950934
placeholder: placeholder,

Sources/CodexBar/MenuHighlightStyle.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CodexBarCore
12
import SwiftUI
23

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

1113
enum MenuHighlightStyle {

0 commit comments

Comments
 (0)