diff --git a/Resources/Info.plist b/Resources/Info.plist
index d57f9c3..9ece575 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.15
+ 0.1.16
CFBundleVersion
- 15
+ 16
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/Sources/CodexToolbar/App/CodexToolbarApp.swift b/Sources/CodexToolbar/App/CodexToolbarApp.swift
index f9b08c8..4275513 100644
--- a/Sources/CodexToolbar/App/CodexToolbarApp.swift
+++ b/Sources/CodexToolbar/App/CodexToolbarApp.swift
@@ -949,7 +949,7 @@ enum AppVersion {
return version
}
- return "0.1.15"
+ return "0.1.16"
}()
private static func developmentVersionFromSourceInfoPlist() -> String? {
diff --git a/Sources/CodexToolbar/Models/CodexRateLimits.swift b/Sources/CodexToolbar/Models/CodexRateLimits.swift
index 843cbd5..5eb5f1f 100644
--- a/Sources/CodexToolbar/Models/CodexRateLimits.swift
+++ b/Sources/CodexToolbar/Models/CodexRateLimits.swift
@@ -194,6 +194,14 @@ enum RateLimitProgressState: Equatable, Sendable {
}
}
+enum RateLimitProjectionGraphElement: Equatable, Sendable {
+ case pace
+ case current
+ case currentAndProjectedEmpty
+ case reset
+ case projectedEmpty
+}
+
struct RateLimitProjectionViewData: Equatable, Sendable {
enum State: Equatable, Sendable {
case resetFirst
@@ -213,6 +221,10 @@ struct RateLimitProjectionViewData: Equatable, Sendable {
let projectedEmptyPosition: Double
let summaryText: String
let detailText: String
+ let paceTooltipText: String
+ let currentTooltipText: String
+ let resetTooltipText: String
+ let projectedEmptyTooltipText: String
let accessibilityLabel: String
init?(
@@ -278,6 +290,18 @@ struct RateLimitProjectionViewData: Equatable, Sendable {
locale: locale,
timeZone: timeZone
)
+ let resetText = RateLimitFormatter.absoluteResetText(
+ for: resetDate,
+ now: now,
+ calendar: calendar,
+ locale: locale,
+ timeZone: timeZone
+ )
+ let paceTooltipText = Self.paceTooltipText(
+ dailyUsePercent: dailyUsePercent,
+ windowDurationMins: windowDurationMins,
+ locale: locale
+ )
let summaryText = runsOutBeforeReset ? "Projected empty before reset" : "Reset comes first"
let detailText = runsOutBeforeReset
? "Empty \(projectedText) at pace"
@@ -295,7 +319,26 @@ struct RateLimitProjectionViewData: Equatable, Sendable {
projectedEmptyPosition = Self.clampedPosition(for: projectedEmptyDate, start: windowStartDate, durationSeconds: chartDurationSeconds)
self.summaryText = summaryText
self.detailText = detailText
- accessibilityLabel = "\(windowLabel) pace. \(summaryText). \(detailText). \(remainingPercent)% remaining."
+ self.paceTooltipText = paceTooltipText
+ currentTooltipText = "Now · \(remainingPercent)% remaining"
+ resetTooltipText = "Reset · \(resetText)"
+ projectedEmptyTooltipText = "Projected empty · \(projectedText) at current pace"
+ accessibilityLabel = "\(windowLabel) pace. \(paceTooltipText). \(summaryText). \(detailText). \(remainingPercent)% remaining."
+ }
+
+ func tooltipText(for element: RateLimitProjectionGraphElement) -> String {
+ switch element {
+ case .pace:
+ return paceTooltipText
+ case .current:
+ return currentTooltipText
+ case .currentAndProjectedEmpty:
+ return "Now · \(currentRemainingPercent)% remaining · projected empty"
+ case .reset:
+ return resetTooltipText
+ case .projectedEmpty:
+ return projectedEmptyTooltipText
+ }
}
private static func clampedPosition(for date: Date, start: Date, durationSeconds: TimeInterval) -> Double {
@@ -305,6 +348,23 @@ struct RateLimitProjectionViewData: Equatable, Sendable {
return min(max(date.timeIntervalSince(start) / durationSeconds, 0), 1)
}
+
+ private static func paceTooltipText(
+ dailyUsePercent: Double,
+ windowDurationMins: Int,
+ locale: Locale
+ ) -> String {
+ let usesHourlyPace = windowDurationMins < 1_440
+ let value = usesHourlyPace ? dailyUsePercent / 24 : dailyUsePercent
+ let formatter = NumberFormatter()
+ formatter.locale = locale
+ formatter.numberStyle = .decimal
+ formatter.minimumFractionDigits = 0
+ formatter.maximumFractionDigits = value < 1 ? 2 : 1
+ let formattedValue = formatter.string(from: NSNumber(value: value)) ?? String(Int(value.rounded()))
+ let unit = usesHourlyPace ? "hour" : "day"
+ return "Current pace · \(formattedValue)% used per \(unit)"
+ }
}
struct RateLimitCardViewData: Equatable, Sendable {
@@ -338,6 +398,7 @@ struct RateLimitCardViewData: Equatable, Sendable {
}
if let projection {
+ parts.append(projection.paceTooltipText)
parts.append(projection.summaryText)
parts.append(projection.detailText)
}
diff --git a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift
index 306a330..610b7bd 100644
--- a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift
+++ b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift
@@ -381,26 +381,31 @@ actor CodexAppServerClient: CodexRateLimitClient {
}
nonisolated static func codexPathCandidates(environmentPath: String, homeDirectory: String) -> [String] {
- let preferredAppCandidates = [
- "/Applications/ChatGPT.app/Contents/Resources/codex",
- URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex").path,
- "/Applications/Codex.app/Contents/Resources/codex",
- URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/Codex.app/Contents/Resources/codex").path
- ]
-
let pathCandidates = environmentPath.split(separator: ":").map { pathComponent in
URL(fileURLWithPath: String(pathComponent)).appendingPathComponent("codex").path
}
- let fallbackCandidates = [
+ let installedCLICandidates = [
URL(fileURLWithPath: homeDirectory).appendingPathComponent(".local/bin/codex").path,
"/opt/homebrew/bin/codex",
"/usr/local/bin/codex",
"/usr/bin/codex"
]
+ // Prefer a user-installed CLI. Executables inside another app's bundle are
+ // private implementation details and may be replaced or quarantined when
+ // that app updates, so only use them when no standalone CLI is available.
+ let appBundleCandidates = [
+ "/Applications/ChatGPT.app/Contents/Resources/codex",
+ URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex").path,
+ "/Applications/Codex.app/Contents/Resources/codex",
+ URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/Codex.app/Contents/Resources/codex").path
+ ]
+ let appBundleCandidateSet = Set(appBundleCandidates)
+ let standalonePathCandidates = pathCandidates.filter { !appBundleCandidateSet.contains($0) }
+
var seen = Set()
- let orderedCandidates = preferredAppCandidates + pathCandidates + fallbackCandidates
+ let orderedCandidates = standalonePathCandidates + installedCLICandidates + appBundleCandidates
return orderedCandidates.filter { candidate in
seen.insert(candidate).inserted
diff --git a/Sources/CodexToolbar/Support/RateLimitStore.swift b/Sources/CodexToolbar/Support/RateLimitStore.swift
index c7ae65a..730170e 100644
--- a/Sources/CodexToolbar/Support/RateLimitStore.swift
+++ b/Sources/CodexToolbar/Support/RateLimitStore.swift
@@ -435,7 +435,8 @@ final class RateLimitStore {
window: entry.window,
displayLabelOverride: displayLabelOverride(
role: entry.role,
- limitId: entry.limitId
+ limitId: entry.limitId,
+ windowDurationMins: entry.window.windowDurationMins
),
familyId: entry.limitId,
categoryLabel: entry.categoryLabel,
@@ -577,9 +578,12 @@ final class RateLimitStore {
private static func displayLabelOverride(
role: WindowRole,
- limitId: String?
+ limitId: String?,
+ windowDurationMins: Int?
) -> String? {
- guard limitId == GetAccountRateLimitsResponse.codexLimitId else {
+ guard limitId == GetAccountRateLimitsResponse.codexLimitId,
+ windowDurationMins == nil
+ else {
return nil
}
diff --git a/Sources/CodexToolbar/Views/StatusMenuContentView.swift b/Sources/CodexToolbar/Views/StatusMenuContentView.swift
index 972c31b..d42c6c0 100644
--- a/Sources/CodexToolbar/Views/StatusMenuContentView.swift
+++ b/Sources/CodexToolbar/Views/StatusMenuContentView.swift
@@ -340,6 +340,7 @@ private struct RateLimitProjectionView: View {
VStack(alignment: .leading, spacing: 3) {
RateLimitProjectionSparkline(projection: projection, palette: palette)
.frame(height: 31)
+ .zIndex(1)
Text(projection.detailText)
.font(.body)
@@ -364,62 +365,66 @@ private struct RateLimitProjectionSparkline: View {
let projection: RateLimitProjectionViewData
let palette: StatusMenuPalette
+ @State private var hoverTarget: RateLimitProjectionGraphLayout.HoverTarget?
+
var body: some View {
GeometryReader { geometry in
- let width = geometry.size.width
- let height = geometry.size.height
- let horizontalInset: CGFloat = 2
- let verticalInset: CGFloat = 2
- let chartWidth = max(width - horizontalInset * 2, 1)
- let chartHeight = max(height - verticalInset * 2, 1)
- let topY = verticalInset
- let bottomY = verticalInset + chartHeight
- let startPoint = CGPoint(x: horizontalInset, y: topY)
- let currentPoint = point(
- xPosition: projection.currentPosition,
- remainingPercent: projection.currentRemainingPercent,
- horizontalInset: horizontalInset,
- chartWidth: chartWidth,
- topY: topY,
- chartHeight: chartHeight
- )
- let emptyPoint = CGPoint(
- x: horizontalInset + chartWidth * CGFloat(projection.projectedEmptyPosition),
- y: bottomY
- )
- let resetX = horizontalInset + chartWidth * CGFloat(projection.resetPosition)
+ let layout = RateLimitProjectionGraphLayout(size: geometry.size, projection: projection)
ZStack(alignment: .topLeading) {
Path { path in
- path.move(to: CGPoint(x: horizontalInset, y: bottomY))
- path.addLine(to: CGPoint(x: width - horizontalInset, y: bottomY))
+ path.move(to: CGPoint(x: layout.horizontalInset, y: layout.bottomY))
+ path.addLine(to: CGPoint(x: geometry.size.width - layout.horizontalInset, y: layout.bottomY))
}
.stroke(palette.divider.opacity(0.55), lineWidth: 0.75)
Path { path in
- path.move(to: CGPoint(x: resetX, y: topY))
- path.addLine(to: CGPoint(x: resetX, y: bottomY))
+ path.move(to: CGPoint(x: layout.resetX, y: layout.topY))
+ path.addLine(to: CGPoint(x: layout.resetX, y: layout.bottomY))
}
.stroke(palette.secondaryText.opacity(0.48), style: StrokeStyle(lineWidth: 0.75, dash: [2, 2]))
Path { path in
- path.move(to: startPoint)
- path.addLine(to: currentPoint)
- path.addLine(to: emptyPoint)
+ path.move(to: layout.startPoint)
+ path.addLine(to: layout.currentPoint)
+ path.addLine(to: layout.emptyPoint)
}
.stroke(statusColor, style: StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round))
Circle()
.fill(statusColor)
.frame(width: 4, height: 4)
- .position(currentPoint)
+ .position(layout.currentPoint)
Circle()
.stroke(statusColor, lineWidth: 1.25)
.frame(width: 5, height: 5)
- .position(emptyPoint)
+ .position(layout.emptyPoint)
+
+ if let hoverTarget {
+ RateLimitProjectionTooltip(
+ text: projection.tooltipText(for: hoverTarget.element),
+ palette: palette
+ )
+ .position(layout.tooltipPosition(for: hoverTarget))
+ .allowsHitTesting(false)
+ .zIndex(1)
+ }
+ }
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ .contentShape(Rectangle())
+ .onContinuousHover { phase in
+ switch phase {
+ case let .active(location):
+ hoverTarget = layout.hoverTarget(at: location)
+ case .ended:
+ hoverTarget = nil
+ }
}
}
+ .onDisappear {
+ hoverTarget = nil
+ }
.accessibilityHidden(true)
}
@@ -427,19 +432,137 @@ private struct RateLimitProjectionSparkline: View {
projectionColor(for: projection.state, palette: palette)
}
- private func point(
- xPosition: Double,
- remainingPercent: Int,
- horizontalInset: CGFloat,
- chartWidth: CGFloat,
- topY: CGFloat,
- chartHeight: CGFloat
- ) -> CGPoint {
- let remainingRatio = min(max(CGFloat(remainingPercent) / 100, 0), 1)
- return CGPoint(
- x: horizontalInset + chartWidth * CGFloat(xPosition),
+}
+
+struct RateLimitProjectionGraphLayout {
+ struct HoverTarget: Equatable {
+ let element: RateLimitProjectionGraphElement
+ let anchor: CGPoint
+ }
+
+ let size: CGSize
+ let horizontalInset: CGFloat = 2
+ let verticalInset: CGFloat = 2
+ let chartWidth: CGFloat
+ let chartHeight: CGFloat
+ let topY: CGFloat
+ let bottomY: CGFloat
+ let startPoint: CGPoint
+ let currentPoint: CGPoint
+ let emptyPoint: CGPoint
+ let resetX: CGFloat
+
+ init(size: CGSize, projection: RateLimitProjectionViewData) {
+ self.size = size
+ chartWidth = max(size.width - horizontalInset * 2, 1)
+ chartHeight = max(size.height - verticalInset * 2, 1)
+ topY = verticalInset
+ bottomY = verticalInset + chartHeight
+ startPoint = CGPoint(x: horizontalInset, y: topY)
+
+ let remainingRatio = min(max(CGFloat(projection.currentRemainingPercent) / 100, 0), 1)
+ currentPoint = CGPoint(
+ x: horizontalInset + chartWidth * CGFloat(projection.currentPosition),
y: topY + chartHeight * (1 - remainingRatio)
)
+ emptyPoint = CGPoint(
+ x: horizontalInset + chartWidth * CGFloat(projection.projectedEmptyPosition),
+ y: bottomY
+ )
+ resetX = horizontalInset + chartWidth * CGFloat(projection.resetPosition)
+ }
+
+ func hoverTarget(at location: CGPoint) -> HoverTarget? {
+ if distance(from: currentPoint, to: emptyPoint) <= 1,
+ distance(from: location, to: currentPoint) <= 9
+ {
+ return HoverTarget(element: .currentAndProjectedEmpty, anchor: currentPoint)
+ }
+
+ let pointTargets = [
+ HoverTarget(element: .current, anchor: currentPoint),
+ HoverTarget(element: .projectedEmpty, anchor: emptyPoint)
+ ]
+ if let closestPointTarget = pointTargets.min(by: {
+ distance(from: location, to: $0.anchor) < distance(from: location, to: $1.anchor)
+ }), distance(from: location, to: closestPointTarget.anchor) <= 9 {
+ return closestPointTarget
+ }
+
+ if abs(location.x - resetX) <= 6 {
+ return HoverTarget(
+ element: .reset,
+ anchor: CGPoint(x: resetX, y: min(max(location.y, topY), bottomY))
+ )
+ }
+
+ let paceAnchors = [
+ closestPoint(onLineFrom: startPoint, to: currentPoint, to: location),
+ closestPoint(onLineFrom: currentPoint, to: emptyPoint, to: location)
+ ]
+ if let closestPaceAnchor = paceAnchors.min(by: {
+ distance(from: location, to: $0) < distance(from: location, to: $1)
+ }), distance(from: location, to: closestPaceAnchor) <= 7 {
+ return HoverTarget(element: .pace, anchor: closestPaceAnchor)
+ }
+
+ return nil
+ }
+
+ func tooltipPosition(for target: HoverTarget) -> CGPoint {
+ let horizontalLimit = min(size.width / 2, 118)
+ let x = min(max(target.anchor.x, horizontalLimit), size.width - horizontalLimit)
+ let y = target.anchor.y > size.height / 2 ? topY - 13 : bottomY + 13
+ return CGPoint(x: x, y: y)
+ }
+
+ private func closestPoint(onLineFrom start: CGPoint, to end: CGPoint, to point: CGPoint) -> CGPoint {
+ let deltaX = end.x - start.x
+ let deltaY = end.y - start.y
+ let lengthSquared = deltaX * deltaX + deltaY * deltaY
+ guard lengthSquared > 0 else {
+ return start
+ }
+
+ let projectedPosition = ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / lengthSquared
+ let clampedPosition = min(max(projectedPosition, 0), 1)
+ return CGPoint(
+ x: start.x + clampedPosition * deltaX,
+ y: start.y + clampedPosition * deltaY
+ )
+ }
+
+ private func distance(from first: CGPoint, to second: CGPoint) -> CGFloat {
+ let deltaX = first.x - second.x
+ let deltaY = first.y - second.y
+ return sqrt(deltaX * deltaX + deltaY * deltaY)
+ }
+}
+
+private struct RateLimitProjectionTooltip: View {
+ let text: String
+ let palette: StatusMenuPalette
+
+ var body: some View {
+ Text(text)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(palette.primaryText)
+ .lineLimit(2)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 220)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, 7)
+ .padding(.vertical, 4)
+ .background {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(palette.surface ?? Color(nsColor: .windowBackgroundColor))
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.75)
+ }
+ }
+ .shadow(color: .black.opacity(0.18), radius: 3, y: 1)
+ .accessibilityHidden(true)
}
}
diff --git a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift
index 4d4c3d9..d63ef85 100644
--- a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift
+++ b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift
@@ -15,12 +15,12 @@ final class CodexAppServerClientTests: XCTestCase {
XCTAssertEqual(
candidates.prefix(6),
[
- "/Applications/ChatGPT.app/Contents/Resources/codex",
- "/Users/example/Applications/ChatGPT.app/Contents/Resources/codex",
- "/Applications/Codex.app/Contents/Resources/codex",
- "/Users/example/Applications/Codex.app/Contents/Resources/codex",
"/usr/bin/codex",
- "/bin/codex"
+ "/bin/codex",
+ "/Users/example/.local/bin/codex",
+ "/opt/homebrew/bin/codex",
+ "/usr/local/bin/codex",
+ "/Applications/ChatGPT.app/Contents/Resources/codex"
]
)
XCTAssertTrue(candidates.contains("/Applications/ChatGPT.app/Contents/Resources/codex"))
@@ -32,6 +32,18 @@ final class CodexAppServerClientTests: XCTestCase {
XCTAssertTrue(candidates.contains("/usr/local/bin/codex"))
}
+ func testCodexPathCandidatesPreferInstalledCLIOverAppBundleExecutables() throws {
+ let candidates = CodexAppServerClient.codexPathCandidates(
+ environmentPath: "/Applications/ChatGPT.app/Contents/Resources:/opt/homebrew/bin",
+ homeDirectory: "/Users/example"
+ )
+
+ XCTAssertLessThan(
+ try XCTUnwrap(candidates.firstIndex(of: "/opt/homebrew/bin/codex")),
+ try XCTUnwrap(candidates.firstIndex(of: "/Applications/ChatGPT.app/Contents/Resources/codex"))
+ )
+ }
+
func testCodexPathCandidatesDeduplicateEntries() {
let candidates = CodexAppServerClient.codexPathCandidates(
environmentPath: "/Applications/ChatGPT.app/Contents/Resources:/opt/homebrew/bin:/Applications/ChatGPT.app/Contents/Resources",
diff --git a/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift b/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift
index beab447..3e8a2e1 100644
--- a/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift
+++ b/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift
@@ -65,8 +65,17 @@ final class RateLimitCardViewDataTests: XCTestCase {
XCTAssertEqual(projection.currentPosition, 0.25, accuracy: 0.001)
XCTAssertEqual(projection.resetPosition, 0.875, accuracy: 0.001)
XCTAssertEqual(projection.projectedEmptyPosition, 1.0, accuracy: 0.001)
+ XCTAssertEqual(projection.paceTooltipText, "Current pace · 12.5% used per day")
+ XCTAssertEqual(projection.currentTooltipText, "Now · 75% remaining")
+ XCTAssertEqual(projection.resetTooltipText, "Reset · May 18")
+ XCTAssertEqual(projection.projectedEmptyTooltipText, "Projected empty · May 19 at current pace")
+ XCTAssertEqual(projection.tooltipText(for: .pace), projection.paceTooltipText)
+ XCTAssertEqual(projection.tooltipText(for: .current), projection.currentTooltipText)
+ XCTAssertEqual(projection.tooltipText(for: .reset), projection.resetTooltipText)
+ XCTAssertEqual(projection.tooltipText(for: .projectedEmpty), projection.projectedEmptyTooltipText)
XCTAssertTrue(card.accessibilityLabel.contains("Reset comes first"))
XCTAssertTrue(card.accessibilityLabel.contains("On pace to last through reset"))
+ XCTAssertTrue(card.accessibilityLabel.contains(projection.paceTooltipText))
XCTAssertTrue(projection.accessibilityLabel.contains("Weekly pace"))
}
@@ -84,9 +93,45 @@ final class RateLimitCardViewDataTests: XCTestCase {
XCTAssertEqual(projection.currentPosition, 0.1, accuracy: 0.001)
XCTAssertEqual(projection.resetPosition, 0.5, accuracy: 0.001)
XCTAssertEqual(projection.projectedEmptyPosition, 1.0, accuracy: 0.001)
+ XCTAssertEqual(projection.paceTooltipText, "Current pace · 10% used per hour")
+ XCTAssertEqual(projection.currentTooltipText, "Now · 90% remaining")
+ XCTAssertEqual(projection.resetTooltipText, "Reset · 4:00 PM")
+ XCTAssertEqual(projection.projectedEmptyTooltipText, "Projected empty · 9:00 PM at current pace")
XCTAssertTrue(projection.accessibilityLabel.contains("5h pace"))
}
+ func testProjectionGraphLayoutTargetsMarkersResetAndPaceWithoutTargetingBaseline() throws {
+ let now = makeDate(year: 2026, month: 5, day: 13, hour: 12, minute: 0)
+ let reset = makeDate(year: 2026, month: 5, day: 18, hour: 12, minute: 0)
+ let projection = try XCTUnwrap(makeWeeklyCard(usedPercent: 25, reset: reset, now: now).projection)
+ let layout = RateLimitProjectionGraphLayout(
+ size: CGSize(width: 320, height: 31),
+ projection: projection
+ )
+
+ XCTAssertEqual(layout.hoverTarget(at: layout.currentPoint)?.element, .current)
+ XCTAssertEqual(
+ layout.hoverTarget(at: CGPoint(x: layout.currentPoint.x + 8, y: layout.currentPoint.y))?.element,
+ .current
+ )
+ let emptyTarget = try XCTUnwrap(layout.hoverTarget(at: layout.emptyPoint))
+ XCTAssertEqual(emptyTarget.element, .projectedEmpty)
+ XCTAssertEqual(layout.tooltipPosition(for: emptyTarget).x, 202, accuracy: 0.001)
+ XCTAssertEqual(
+ layout.hoverTarget(at: CGPoint(x: layout.resetX, y: layout.topY))?.element,
+ .reset
+ )
+
+ let pacePoint = CGPoint(
+ x: (layout.startPoint.x + layout.currentPoint.x) / 2,
+ y: (layout.startPoint.y + layout.currentPoint.y) / 2
+ )
+ XCTAssertEqual(layout.hoverTarget(at: pacePoint)?.element, .pace)
+
+ let baselinePoint = CGPoint(x: layout.horizontalInset + 20, y: layout.bottomY)
+ XCTAssertNil(layout.hoverTarget(at: baselinePoint))
+ }
+
func testFiveHourProjectionIsCriticalWhenProjectedEmptyFallsBeforeReset() throws {
let now = makeDate(year: 2026, month: 5, day: 13, hour: 12, minute: 0)
let reset = makeDate(year: 2026, month: 5, day: 13, hour: 16, minute: 0)
@@ -135,6 +180,14 @@ final class RateLimitCardViewDataTests: XCTestCase {
XCTAssertEqual(projection.state, .critical)
XCTAssertEqual(projection.currentRemainingPercent, 0)
XCTAssertEqual(projection.projectedEmptyDate, now)
+
+ let layout = RateLimitProjectionGraphLayout(
+ size: CGSize(width: 320, height: 31),
+ projection: projection
+ )
+ let target = try XCTUnwrap(layout.hoverTarget(at: layout.currentPoint))
+ XCTAssertEqual(target.element, .currentAndProjectedEmpty)
+ XCTAssertEqual(projection.tooltipText(for: target.element), "Now · 0% remaining · projected empty")
}
func testProjectionIsHiddenWhenInputsAreUnavailable() {
diff --git a/Tests/CodexToolbarTests/RateLimitStoreTests.swift b/Tests/CodexToolbarTests/RateLimitStoreTests.swift
index ea9496e..cecf904 100644
--- a/Tests/CodexToolbarTests/RateLimitStoreTests.swift
+++ b/Tests/CodexToolbarTests/RateLimitStoreTests.swift
@@ -478,12 +478,88 @@ final class RateLimitStoreTests: XCTestCase {
secondary: CodexRateLimitWindow(resetsAt: 1_741_731_200, usedPercent: 41, windowDurationMins: 10_081)
)
+ let cards = RateLimitStore.makeCards(from: snapshot)
+ let weeklyCard = cards.first { $0.usedPercent == 41 }
+
+ XCTAssertEqual(cards.count, 2)
+ XCTAssertEqual(weeklyCard?.compactLabel, "Weekly")
+ XCTAssertEqual(weeklyCard?.title, "Weekly")
+ }
+
+ func testMakeCardsUsesDurationWhenWeeklyWindowIsPrimary() {
+ let snapshot = CodexRateLimitsSnapshot(
+ credits: nil,
+ limitId: "codex",
+ limitName: "Codex",
+ planType: .pro,
+ primary: CodexRateLimitWindow(
+ resetsAt: 1_741_731_200,
+ usedPercent: 2,
+ windowDurationMins: 10_080
+ ),
+ secondary: nil
+ )
+
let cards = RateLimitStore.makeCards(from: snapshot)
XCTAssertEqual(cards[0].compactLabel, "Weekly")
XCTAssertEqual(cards[0].title, "Weekly")
}
+ func testMakeCardsFallsBackToCoreCodexRolesWhenDurationsAreMissing() {
+ let snapshot = CodexRateLimitsSnapshot(
+ credits: nil,
+ limitId: "codex",
+ limitName: "Codex",
+ planType: .pro,
+ primary: CodexRateLimitWindow(
+ resetsAt: 1_741_171_240,
+ usedPercent: 12,
+ windowDurationMins: nil
+ ),
+ secondary: CodexRateLimitWindow(
+ resetsAt: 1_741_731_200,
+ usedPercent: 41,
+ windowDurationMins: nil
+ )
+ )
+
+ let cards = RateLimitStore.makeCards(from: snapshot)
+
+ let primaryCard = cards.first { $0.usedPercent == 12 }
+ let secondaryCard = cards.first { $0.usedPercent == 41 }
+
+ XCTAssertEqual(primaryCard?.compactLabel, "5h")
+ XCTAssertEqual(primaryCard?.title, "5h")
+ XCTAssertEqual(secondaryCard?.compactLabel, "Weekly")
+ XCTAssertEqual(secondaryCard?.title, "Weekly")
+ }
+
+ func testMakeCardsUsesDurationWhenTwoWeekWindowIsSecondary() {
+ let snapshot = CodexRateLimitsSnapshot(
+ credits: nil,
+ limitId: "codex",
+ limitName: "Codex",
+ planType: .pro,
+ primary: CodexRateLimitWindow(
+ resetsAt: 1_741_171_240,
+ usedPercent: 12,
+ windowDurationMins: 300
+ ),
+ secondary: CodexRateLimitWindow(
+ resetsAt: 1_742_336_000,
+ usedPercent: 41,
+ windowDurationMins: 20_160
+ )
+ )
+
+ let cards = RateLimitStore.makeCards(from: snapshot)
+ let secondaryCard = cards.first { $0.usedPercent == 41 }
+
+ XCTAssertEqual(secondaryCard?.compactLabel, "2 Week")
+ XCTAssertEqual(secondaryCard?.title, "2 Week")
+ }
+
func testMakeCardsKeepsNonCodexFormatterLabels() {
let snapshot = CodexRateLimitsSnapshot(
credits: nil,