Skip to content

Commit d9eb894

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

27 files changed

Lines changed: 646 additions & 100 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: 94 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,19 @@ 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 title = credit.title ?? credit.resetType
136+
let expires = credit.expiresAt
137+
.map { UsageFormatter.resetDescription(from: $0, now: input.now) } ?? L("No expiry")
138+
return "\(title): \(credit.status.rawValue), \(expires)"
139+
}
140+
return lines.isEmpty ? nil : lines.joined(separator: "\n")
141+
}
73142
}

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 {

Sources/CodexBar/Resources/ar.lproj/Localizable.strings

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,16 @@
11001100
"1 manual reset available" = "تتوفر إعادة تعيين يدوية واحدة";
11011101
"%d manual resets available" = "تتوفر %d عمليات إعادة تعيين يدوية";
11021102
"Next expires %@" = "تنتهي صلاحية التالية %@";
1103+
"Use Reset" = "استخدم إعادة التعيين";
1104+
"Use the next expiring Codex reset credit" = "استخدم رصيد إعادة تعيين Codex التالي انتهاءً";
1105+
"No expiry" = "لا انتهاء صلاحية";
1106+
"Codex reset expires soon" = "ستنتهي صلاحية إعادة تعيين Codex قريبًا";
1107+
"A Codex reset credit expires %@." = "ينتهي رصيد إعادة تعيين Codex %@.";
1108+
"Use Codex reset?" = "استخدام إعادة تعيين Codex؟";
1109+
"This spends one banked Codex reset credit now." = "سيستخدم هذا رصيد إعادة تعيين Codex محفوظًا الآن.";
1110+
"Codex reset used" = "تم استخدام إعادة تعيين Codex";
1111+
"%d window reset." = "تمت إعادة تعيين %d نافذة.";
1112+
"Codex reset failed" = "فشلت إعادة تعيين Codex";
11031113
"byte_unit_byte" = "بايت";
11041114
"byte_unit_bytes" = "بايتات";
11051115
"byte_unit_kilobyte" = "كيلوبايت";

Sources/CodexBar/Resources/en.lproj/Localizable.strings

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,9 +1097,19 @@
10971097
"language_persian" = "فارسی";
10981098
"language_thai" = "ไทย";
10991099
"Limit Reset Credits" = "Limit Reset Credits";
1100-
"1 manual reset available" = "1 manual reset available";
1101-
"%d manual resets available" = "%d manual resets available";
1100+
"1 manual reset available" = "1 reset available";
1101+
"%d manual resets available" = "%d resets available";
11021102
"Next expires %@" = "Next expires %@";
1103+
"Use Reset" = "Use Reset";
1104+
"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit";
1105+
"No expiry" = "No expiry";
1106+
"Codex reset expires soon" = "Codex reset expires soon";
1107+
"A Codex reset credit expires %@." = "A Codex reset credit expires %@.";
1108+
"Use Codex reset?" = "Use Codex reset?";
1109+
"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now.";
1110+
"Codex reset used" = "Codex reset used";
1111+
"%d window reset." = "%d window reset.";
1112+
"Codex reset failed" = "Codex reset failed";
11031113
"byte_unit_byte" = "byte";
11041114
"byte_unit_bytes" = "bytes";
11051115
"byte_unit_kilobyte" = "kilobyte";

Sources/CodexBar/Resources/fa.lproj/Localizable.strings

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,16 @@
11001100
"1 manual reset available" = "۱ بازنشانی دستی موجود است";
11011101
"%d manual resets available" = "%d بازنشانی دستی موجود است";
11021102
"Next expires %@" = "مورد بعدی در %@ منقضی می‌شود";
1103+
"Use Reset" = "استفاده از بازنشانی";
1104+
"Use the next expiring Codex reset credit" = "از اعتبار بازنشانی Codex که زودتر منقضی می‌شود استفاده کن";
1105+
"No expiry" = "بدون انقضا";
1106+
"Codex reset expires soon" = "بازنشانی Codex به‌زودی منقضی می‌شود";
1107+
"A Codex reset credit expires %@." = "یک اعتبار بازنشانی Codex در %@ منقضی می‌شود.";
1108+
"Use Codex reset?" = "از بازنشانی Codex استفاده شود؟";
1109+
"This spends one banked Codex reset credit now." = "این کار اکنون یک اعتبار بازنشانی ذخیره‌شده Codex را مصرف می‌کند.";
1110+
"Codex reset used" = "بازنشانی Codex استفاده شد";
1111+
"%d window reset." = "%d پنجره بازنشانی شد.";
1112+
"Codex reset failed" = "بازنشانی Codex ناموفق بود";
11031113
"byte_unit_byte" = "بایت";
11041114
"byte_unit_bytes" = "بایت";
11051115
"byte_unit_kilobyte" = "کیلوبایت";

Sources/CodexBar/Resources/id.lproj/Localizable.strings

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,3 +1107,14 @@
11071107
"byte_unit_megabytes" = "megabyte";
11081108
"byte_unit_gigabyte" = "gigabyte";
11091109
"byte_unit_gigabytes" = "gigabyte";
1110+
"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta macOS Keychain untuk “%@” agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan.";
1111+
"Use Reset" = "Gunakan Reset";
1112+
"Use the next expiring Codex reset credit" = "Gunakan kredit reset Codex yang paling dekat kedaluwarsa";
1113+
"No expiry" = "Tidak ada kedaluwarsa";
1114+
"Codex reset expires soon" = "Reset Codex segera kedaluwarsa";
1115+
"A Codex reset credit expires %@." = "Kredit reset Codex kedaluwarsa %@.";
1116+
"Use Codex reset?" = "Gunakan reset Codex?";
1117+
"This spends one banked Codex reset credit now." = "Ini akan memakai satu kredit reset Codex yang tersimpan sekarang.";
1118+
"Codex reset used" = "Reset Codex digunakan";
1119+
"%d window reset." = "%d jendela direset.";
1120+
"Codex reset failed" = "Reset Codex gagal";

0 commit comments

Comments
 (0)