on master help sign in
Show 5-hour and 7-day Claude limits in menu bar

The menu bar title now shows both Claude usage windows with emoji prefixes: ๐Ÿ• for the 5-hour window, ๐Ÿ“… for the 7-day window. Windows at 0% or absent are omitted from the title. The popover's hero number is now the 5-hour window (matching the menu bar), with 7-day and 7-day Opus as supporting bars below. Removed the old claudeHeadline system that picked a single binding limit โ€” the menu bar now shows all windows directly.

commit3276b6b61bc50c364fc1bef76128f8000e123e72 authoriko <mail@iko.soy> dateSun 12 Jul 2026, 03:41 (3 months ago) parent86d28a5
4 files changed+152โˆ’74
UsageMenubar/MenuView.swift+7โˆ’7 UsageMenubar/UsageMenubarApp.swift+9โˆ’2 UsageMenubar/ViewModel.swift+32โˆ’27 UsageMenubarTests/StatusBarTextTests.swift+104โˆ’38
UsageMenubar/MenuView.swift+7โˆ’7
@@ -248,16 +248,19 @@ struct MenuView: View {
248248
249249 private func usageDetail(_ usage: ClaudeUsage) -> some View {
250250 VStack(alignment: .leading, spacing: 12) {
251โˆ’ if let headline = ViewModel.claudeHeadline(for: usage) {
251+ // The 5-hour window leads as the hero number, since it's the one
252+ // most likely to bind and the one shown in the menu bar title.
253+ if let fiveHour = usage.fiveHour {
254+ let percent = Int(fiveHour.utilization.rounded())
252255 HStack(alignment: .firstTextBaseline, spacing: 8) {
253โˆ’ Text("\(headline.percent)%")
256+ Text("๐Ÿ• \(percent)%")
254257 .font(Self.subheroFont)
255โˆ’ .foregroundColor(Self.usageColor(headline.percent))
258+ .foregroundColor(Self.usageColor(percent))
256259 .contentTransition(.opacity)
257260
258261 Spacer(minLength: 0)
259262
260โˆ’ if let resets = headline.resetsIn {
263+ if let resets = fiveHour.resetsInFormatted {
261264 Text("resets in \(resets)")
262265 .font(Self.footnoteFont)
263266 .foregroundColor(.secondary)
@@ -266,9 +269,6 @@ struct MenuView: View {
266269 }
267270
268271 VStack(alignment: .leading, spacing: 9) {
269โˆ’ if let fiveHour = usage.fiveHour {
270โˆ’ window("5-hour", fiveHour)
271โˆ’ }
272272 if let sevenDay = usage.sevenDay {
273273 window("7-day", sevenDay)
274274 }
UsageMenubar/UsageMenubarApp.swift+9โˆ’2
@@ -62,10 +62,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
6262 viewModel.$claudeUsage
6363 )
6464 .map { balance, isLoading, claudeUsage in
65โˆ’ ViewModel.statusBarText(
65+ let fiveHour = claudeUsage?.fiveHour
66+ .map { Int($0.utilization.rounded()) }
67+ let sevenDay = claudeUsage?.sevenDay
68+ .map { Int($0.utilization.rounded()) }
69+ ?? claudeUsage?.sevenDayOpus
70+ .map { Int($0.utilization.rounded()) }
71+ return ViewModel.statusBarText(
6672 balance: balance,
6773 isLoading: isLoading,
68โˆ’ claudePercent: ViewModel.claudeHeadline(for: claudeUsage)?.percent
74+ claudeFiveHourPercent: fiveHour,
75+ claudeSevenDayPercent: sevenDay
6976 )
7077 }
7178 .removeDuplicates()
UsageMenubar/ViewModel.swift+32โˆ’27
@@ -186,22 +186,30 @@ final class ViewModel: ObservableObject {
186186 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
187187 }
188188
189โˆ’ /// The text to show in the menu bar: `โšก{balance} ยท {percent}%` with both services,
190โˆ’ /// and just the half that is there when only one of them is โ€” an unconfigured or
191โˆ’ /// failing service drops out of the title rather than taking a slot to say nothing.
192โˆ’ /// With neither, the bolt stands in alone: `โšกโ€ฆ` while the first fetch is in flight,
193โˆ’ /// `โšก?` once it has come back empty-handed.
189+ /// The text to show in the menu bar: `โšก{balance}` followed by Claude usage
190+ /// windows, each prefixed with an emoji so they are distinguishable at a glance:
191+ /// ๐Ÿ• for the 5-hour window, ๐Ÿ“… for the 7-day window. Windows that are absent or
192+ /// at 0% are omitted; with neither, the bolt stands in alone: `โšกโ€ฆ` while the
193+ /// first fetch is in flight, `โšก?` once it has come back empty-handed.
194194 ///
195195 /// Takes its values as parameters rather than reading the properties: `@Published`
196196 /// publishes in `willSet`, so a Combine subscriber that called back into the view
197197 /// model would see the *pre-change* value and render a title one update behind.
198โˆ’ static func statusBarText(balance: Int?, isLoading: Bool, claudePercent: Int?) -> String {
198+ static func statusBarText(
199+ balance: Int?,
200+ isLoading: Bool,
201+ claudeFiveHourPercent: Int?,
202+ claudeSevenDayPercent: Int?
203+ ) -> String {
199204 var parts: [String] = []
200205 if let balance = balance {
201206 parts.append("โšก\(formatBalance(balance))")
202207 }
203โˆ’ if let claudePercent = claudePercent {
204โˆ’ parts.append("\(claudePercent)%")
208+ if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
209+ parts.append("๐Ÿ•\(fiveHour)%")
210+ }
211+ if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
212+ parts.append("๐Ÿ“…\(sevenDay)%")
205213 }
206214 if parts.isEmpty {
207215 return isLoading ? "โšกโ€ฆ" : "โšก?"
@@ -214,7 +222,8 @@ final class ViewModel: ObservableObject {
214222 Self.statusBarText(
215223 balance: balance,
216224 isLoading: isLoading,
217โˆ’ claudePercent: claudeStatusBarPercent
225+ claudeFiveHourPercent: claudeFiveHourPercent,
226+ claudeSevenDayPercent: claudeSevenDayPercent
218227 )
219228 }
220229
@@ -393,29 +402,25 @@ final class ViewModel: ObservableObject {
393402 }
394403 }
395404
396โˆ’ /// The number Claude gets judged by: whichever limit the server itself flags as
397โˆ’ /// binding, falling back to the 5-hour window when it flags none. `nil` when there
398โˆ’ /// is no usage to summarize.
399โˆ’ ///
400โˆ’ /// Shared by the popover and the menu bar title so the two can never disagree about
401โˆ’ /// which of the windows is the one that matters.
402โˆ’ static func claudeHeadline(for usage: ClaudeUsage?) -> (percent: Int, resetsIn: String?)? {
403โˆ’ guard let usage = usage else { return nil }
404โˆ’ if let active = usage.activeLimit {
405โˆ’ return (active.percent, active.resetsInFormatted)
405+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
406+ var claudeFiveHourPercent: Int? {
407+ guard let fiveHour = claudeUsage?.fiveHour else { return nil }
408+ return Int(fiveHour.utilization.rounded())
409+ }
410+
411+ /// The 7-day window percentage for the menu bar title, or `nil` when absent.
412+ /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
413+ /// has been used โ€” on Max plans the Opus cap is the one that binds first.
414+ var claudeSevenDayPercent: Int? {
415+ if let sevenDay = claudeUsage?.sevenDay {
416+ return Int(sevenDay.utilization.rounded())
406417 }
407โˆ’ if let fiveHour = usage.fiveHour {
408โˆ’ return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
418+ if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
419+ return Int(opus.utilization.rounded())
409420 }
410421 return nil
411422 }
412423
413โˆ’ /// The Claude percentage for the menu bar title, or `nil` when Claude Code isn't
414โˆ’ /// signed in on this machine โ€” in which case the title is Hyper's alone.
415โˆ’ var claudeStatusBarPercent: Int? {
416โˆ’ Self.claudeHeadline(for: claudeUsage)?.percent
417โˆ’ }
418โˆ’
419424 /// Display label for the Claude plan, e.g. "Pro" or "Max".
420425 var claudePlanLabel: String? {
421426 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
UsageMenubarTests/StatusBarTextTests.swift+104โˆ’38
@@ -1,7 +1,7 @@
11import XCTest
22@testable import UsageMenubar
33
4โˆ’/// The menu bar title and the Claude number it is built from.
4+/// The menu bar title and the Claude numbers it is built from.
55///
66/// Balances are kept under 1,000 so the assertions don't depend on the grouping
77/// separator of whatever locale the test happens to run in.
@@ -11,27 +11,42 @@ final class StatusBarTextTests: XCTestCase {
1111 // MARK: - Title
1212
1313 func testBothServices() {
14โˆ’ let title = ViewModel.statusBarText(balance: 42, isLoading: false, claudePercent: 62)
15โˆ’ XCTAssertEqual(title, "โšก42 ยท 62%")
14+ let title = ViewModel.statusBarText(
15+ balance: 42, isLoading: false,
16+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
17+ )
18+ XCTAssertEqual(title, "โšก42 ยท ๐Ÿ•62% ยท ๐Ÿ“…8%")
1619 }
1720
1821 func testHyperOnly() {
19โˆ’ let title = ViewModel.statusBarText(balance: 42, isLoading: false, claudePercent: nil)
22+ let title = ViewModel.statusBarText(
23+ balance: 42, isLoading: false,
24+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
25+ )
2026 XCTAssertEqual(title, "โšก42")
2127 }
2228
2329 func testClaudeOnly() {
24โˆ’ let title = ViewModel.statusBarText(balance: nil, isLoading: false, claudePercent: 62)
25โˆ’ XCTAssertEqual(title, "62%")
30+ let title = ViewModel.statusBarText(
31+ balance: nil, isLoading: false,
32+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
33+ )
34+ XCTAssertEqual(title, "๐Ÿ•62% ยท ๐Ÿ“…8%")
2635 }
2736
2837 func testLoadingWithNothingToShow() {
29โˆ’ let title = ViewModel.statusBarText(balance: nil, isLoading: true, claudePercent: nil)
38+ let title = ViewModel.statusBarText(
39+ balance: nil, isLoading: true,
40+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
41+ )
3042 XCTAssertEqual(title, "โšกโ€ฆ")
3143 }
3244
3345 func testNothingToShow() {
34โˆ’ let title = ViewModel.statusBarText(balance: nil, isLoading: false, claudePercent: nil)
46+ let title = ViewModel.statusBarText(
47+ balance: nil, isLoading: false,
48+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
49+ )
3550 XCTAssertEqual(title, "โšก?")
3651 }
3752
@@ -39,54 +54,105 @@ final class StatusBarTextTests: XCTestCase {
3954 /// without a Hyper key refreshes every few minutes, and a title that fell back to `โšกโ€ฆ`
4055 /// each time would blink their percentage away and back.
4156 func testLoadingKeepsAClaudePercentOnScreen() {
42โˆ’ let title = ViewModel.statusBarText(balance: nil, isLoading: true, claudePercent: 62)
43โˆ’ XCTAssertEqual(title, "62%")
57+ let title = ViewModel.statusBarText(
58+ balance: nil, isLoading: true,
59+ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil
60+ )
61+ XCTAssertEqual(title, "๐Ÿ•62%")
4462 }
4563
4664 func testLoadingKeepsAStaleBalanceOnScreen() {
47โˆ’ let title = ViewModel.statusBarText(balance: 42, isLoading: true, claudePercent: 62)
48โˆ’ XCTAssertEqual(title, "โšก42 ยท 62%")
65+ let title = ViewModel.statusBarText(
66+ balance: 42, isLoading: true,
67+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
68+ )
69+ XCTAssertEqual(title, "โšก42 ยท ๐Ÿ•62% ยท ๐Ÿ“…8%")
4970 }
5071
5172 func testZeroPercentIsShownRatherThanOmitted() {
52โˆ’ let title = ViewModel.statusBarText(balance: 42, isLoading: false, claudePercent: 0)
53โˆ’ XCTAssertEqual(title, "โšก42 ยท 0%")
73+ let title = ViewModel.statusBarText(
74+ balance: 42, isLoading: false,
75+ claudeFiveHourPercent: 0, claudeSevenDayPercent: 8
76+ )
77+ XCTAssertEqual(title, "โšก42 ยท ๐Ÿ“…8%")
5478 }
5579
56โˆ’ // MARK: - Claude Headline
80+ func testBothClaudeWindowsZero() {
81+ let title = ViewModel.statusBarText(
82+ balance: 42, isLoading: false,
83+ claudeFiveHourPercent: 0, claudeSevenDayPercent: 0
84+ )
85+ XCTAssertEqual(title, "โšก42")
86+ }
5787
58โˆ’ func testHeadlinePrefersTheActiveLimit() {
59โˆ’ let usage = ClaudeUsage(
60โˆ’ fiveHour: UsageWindow(utilization: 12),
61โˆ’ sevenDay: nil,
62โˆ’ sevenDayOpus: nil,
63โˆ’ limits: [
64โˆ’ Limit(percent: 12, isActive: false),
65โˆ’ Limit(percent: 68, isActive: true)
66โˆ’ ]
88+ func testFiveHourOnly() {
89+ let title = ViewModel.statusBarText(
90+ balance: nil, isLoading: false,
91+ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil
6792 )
68โˆ’ XCTAssertEqual(ViewModel.claudeHeadline(for: usage)?.percent, 68)
93+ XCTAssertEqual(title, "๐Ÿ•62%")
6994 }
7095
71โˆ’ /// Below the thresholds the server flags no limit at all, and the 5-hour window
72โˆ’ /// stands in โ€” rounded, because the title has no room for a decimal.
73โˆ’ func testHeadlineFallsBackToTheRoundedFiveHourWindow() {
96+ func testSevenDayOnly() {
97+ let title = ViewModel.statusBarText(
98+ balance: nil, isLoading: false,
99+ claudeFiveHourPercent: nil, claudeSevenDayPercent: 8
100+ )
101+ XCTAssertEqual(title, "๐Ÿ“…8%")
102+ }
103+
104+ // MARK: - Claude Window Percentages
105+
106+ func testFiveHourPercentRounds() {
74107 let usage = ClaudeUsage(
75108 fiveHour: UsageWindow(utilization: 61.6),
76โˆ’ sevenDay: UsageWindow(utilization: 4),
77โˆ’ sevenDayOpus: nil,
78โˆ’ limits: []
109+ sevenDay: nil, sevenDayOpus: nil, limits: nil
110+ )
111+ let vm = ViewModel()
112+ vm.claudeUsage = usage
113+ XCTAssertEqual(vm.claudeFiveHourPercent, 62)
114+ XCTAssertNil(vm.claudeSevenDayPercent)
115+ }
116+
117+ func testSevenDayPercentRounds() {
118+ let usage = ClaudeUsage(
119+ fiveHour: nil,
120+ sevenDay: UsageWindow(utilization: 4.4),
121+ sevenDayOpus: nil, limits: nil
122+ )
123+ let vm = ViewModel()
124+ vm.claudeUsage = usage
125+ XCTAssertNil(vm.claudeFiveHourPercent)
126+ XCTAssertEqual(vm.claudeSevenDayPercent, 4)
127+ }
128+
129+ func testSevenDayFallsBackToOpus() {
130+ let usage = ClaudeUsage(
131+ fiveHour: nil,
132+ sevenDay: nil,
133+ sevenDayOpus: UsageWindow(utilization: 33.5),
134+ limits: nil
79135 )
80โˆ’ XCTAssertEqual(ViewModel.claudeHeadline(for: usage)?.percent, 62)
136+ let vm = ViewModel()
137+ vm.claudeUsage = usage
138+ XCTAssertEqual(vm.claudeSevenDayPercent, 34)
81139 }
82140
83โˆ’ func testHeadlineIsNilWithoutUsage() {
84โˆ’ XCTAssertNil(ViewModel.claudeHeadline(for: nil))
141+ func testSevenDayDoesNotFallBackToZeroOpus() {
142+ let usage = ClaudeUsage(
143+ fiveHour: nil,
144+ sevenDay: nil,
145+ sevenDayOpus: UsageWindow(utilization: 0),
146+ limits: nil
147+ )
148+ let vm = ViewModel()
149+ vm.claudeUsage = usage
150+ XCTAssertNil(vm.claudeSevenDayPercent)
85151 }
86152
87โˆ’ /// The endpoint answers `{}` for an account that has never used the plan.
88โˆ’ func testHeadlineIsNilForAnEmptyPayload() {
89โˆ’ let usage = ClaudeUsage(fiveHour: nil, sevenDay: nil, sevenDayOpus: nil, limits: nil)
90โˆ’ XCTAssertNil(ViewModel.claudeHeadline(for: usage))
153+ func testNilUsageYieldsNilPercents() {
154+ let vm = ViewModel()
155+ XCTAssertNil(vm.claudeFiveHourPercent)
156+ XCTAssertNil(vm.claudeSevenDayPercent)
91157 }
92158}