on master help sign in
feat: show Claude usage in the menu bar title, and split the popover in two

The title now reads `⚡1,234 · 62%`, dropping whichever half isn't there: Hyper alone, Claude alone, or `⚡…`/`⚡?` when there is nothing to show at all. The placeholders only stand in for an empty title, so a Claude-only user's percentage doesn't blink away and back on every refresh. The percentage is the same active-limit-or-5-hour-window number the popover leads with: `ViewModel.claudeHeadline(for:)` is now the one source both read, rather than the view and the title each deriving it. In the popover, the two services get matching icon + title headers and a wider rule between them, so they read as two accounts on two services rather than as a balance with a footnote underneath it.

commit949ff8a6cdf9851dcf3fa8215d4e310d43c08b61 authoriko <mail@iko.soy> dateSat 11 Jul 2026, 19:04 (3 months ago) parent9db15f5
4 files changed+190−48
UsageMenubar/MenuView.swift+33−25 UsageMenubar/UsageMenubarApp.swift+19−11 UsageMenubar/ViewModel.swift+46−12 UsageMenubarTests/StatusBarTextTests.swift+92
UsageMenubar/MenuView.swift+33−25
@@ -27,9 +27,9 @@ struct MenuView: View {
2727
2828 var body: some View {
2929 VStack(alignment: .leading, spacing: 18) {
30− hero
30+ hyper
3131 if showsClaude {
32− hairline
32+ serviceBreak
3333 claude
3434 }
3535 hairline
@@ -59,7 +59,35 @@ struct MenuView: View {
5959 Divider().opacity(0.3)
6060 }
6161
62− // MARK: - Hero
62+ /// The rule between the two services. Wider than the others on purpose: Hyper and
63+ /// Claude are two unrelated accounts, and the gap should say so before the headers do.
64+ private var serviceBreak: some View {
65+ hairline.padding(.vertical, 5)
66+ }
67+
68+ /// A section header: an icon and a title, sized to match the "API Key" header below
69+ /// so the popover reads as three sections rather than a hero with two appendices.
70+ /// The icon is what tells the two services apart at a glance.
71+ private func sectionHeader(_ title: String, systemImage: String) -> some View {
72+ HStack(spacing: 5) {
73+ Image(systemName: systemImage)
74+ .font(.system(size: 11, weight: .semibold))
75+
76+ Text(title)
77+ .font(Self.sectionFont)
78+ }
79+ }
80+
81+ // MARK: - Hyper
82+
83+ /// The Hyper section: its header, then the balance as the popover's hero.
84+ private var hyper: some View {
85+ VStack(alignment: .leading, spacing: 12) {
86+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
87+
88+ hero
89+ }
90+ }
6391
6492 private var hero: some View {
6593 VStack(spacing: 8) {
@@ -68,10 +96,6 @@ struct MenuView: View {
6896 .transition(.opacity)
6997 } else if viewModel.balance == nil {
7098 VStack(spacing: 6) {
71− Text("Hyper Credits")
72− .font(Self.sectionFont)
73− .foregroundColor(.secondary)
74−
7599 Text("⚡?")
76100 .font(Self.heroFont)
77101 .foregroundColor(.secondary)
@@ -83,9 +107,6 @@ struct MenuView: View {
83107 .transition(.opacity)
84108 } else {
85109 VStack(spacing: 6) {
86− Text("Hyper Credits")
87− .font(Self.sectionFont)
88− .foregroundColor(.secondary)
89110 balanceNumber
90111 caption
91112 sparkline
@@ -204,8 +225,7 @@ struct MenuView: View {
204225 private var claude: some View {
205226 VStack(alignment: .leading, spacing: 12) {
206227 HStack(spacing: 8) {
207− Text("Claude Code")
208− .font(Self.sectionFont)
228+ sectionHeader("Claude Code", systemImage: "sparkles")
209229
210230 Spacer(minLength: 0)
211231
@@ -228,7 +248,7 @@ struct MenuView: View {
228248
229249 private func usageDetail(_ usage: ClaudeUsage) -> some View {
230250 VStack(alignment: .leading, spacing: 12) {
231− if let headline = Self.headline(for: usage) {
251+ if let headline = ViewModel.claudeHeadline(for: usage) {
232252 HStack(alignment: .firstTextBaseline, spacing: 8) {
233253 Text("\(headline.percent)%")
234254 .font(Self.subheroFont)
@@ -283,18 +303,6 @@ struct MenuView: View {
283303 }
284304 }
285305
286− /// The number the section leads with: whichever limit the server flags as binding,
287− /// falling back to the 5-hour window when it flags none.
288− private static func headline(for usage: ClaudeUsage) -> (percent: Int, resetsIn: String?)? {
289− if let active = usage.activeLimit {
290− return (active.percent, active.resetsInFormatted)
291− }
292− if let fiveHour = usage.fiveHour {
293− return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
294− }
295− return nil
296− }
297−
298306 /// The balance colors read the other way round here, because for a limit more is
299307 /// worse: green with room to spare, yellow closing in, red nearly spent.
300308 private static func usageColor(_ percent: Int) -> Color {
UsageMenubar/UsageMenubarApp.swift+19−11
@@ -46,8 +46,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4646 popover.behavior = .transient
4747 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4848
49− // Observe published changes (balance + isLoading) to update the menu bar title.
50− // CombineLatest fires when either published property changes.
49+ // Observe published changes (balance + isLoading + Claude usage) to update the
50+ // menu bar title. CombineLatest fires when any of them changes.
5151 //
5252 // The title is built from the values the publisher hands us, not by reading the
5353 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -56,15 +56,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5656 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
5757 // whose Combine scheduler only runs in the default run loop mode — the title
5858 // would freeze while a menu or a scroll was tracking.
59− statusTextCancellable = Publishers.CombineLatest(viewModel.$balance, viewModel.$isLoading)
60− .map { balance, isLoading in
61− ViewModel.statusBarText(balance: balance, isLoading: isLoading)
62− }
63− .removeDuplicates()
64− .receive(on: DispatchQueue.main)
65− .sink { [weak self] text in
66− self?.statusItem.button?.title = text
67− }
59+ statusTextCancellable = Publishers.CombineLatest3(
60+ viewModel.$balance,
61+ viewModel.$isLoading,
62+ viewModel.$claudeUsage
63+ )
64+ .map { balance, isLoading, claudeUsage in
65+ ViewModel.statusBarText(
66+ balance: balance,
67+ isLoading: isLoading,
68+ claudePercent: ViewModel.claudeHeadline(for: claudeUsage)?.percent
69+ )
70+ }
71+ .removeDuplicates()
72+ .receive(on: DispatchQueue.main)
73+ .sink { [weak self] text in
74+ self?.statusItem.button?.title = text
75+ }
6876
6977 // Register for sleep/wake notifications
7078 registerSleepWakeNotifications()
UsageMenubar/ViewModel.swift+46−12
@@ -186,25 +186,36 @@ final class ViewModel: ObservableObject {
186186 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
187187 }
188188
189− /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
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.
190194 ///
191− /// Takes the balance and loading flag as parameters rather than reading the
192− /// properties: `@Published` publishes in `willSet`, so a Combine subscriber that
193− /// called back into the view model would see the *pre-change* value and render a
194− /// title one update behind.
195− static func statusBarText(balance: Int?, isLoading: Bool) -> String {
196− if isLoading && balance == nil {
197− return "⚡…"
198− }
195+ /// Takes its values as parameters rather than reading the properties: `@Published`
196+ /// publishes in `willSet`, so a Combine subscriber that called back into the view
197+ /// 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 {
199+ var parts: [String] = []
199200 if let balance = balance {
200− return "⚡\(formatBalance(balance))"
201+ parts.append("⚡\(formatBalance(balance))")
202+ }
203+ if let claudePercent = claudePercent {
204+ parts.append("\(claudePercent)%")
205+ }
206+ if parts.isEmpty {
207+ return isLoading ? "⚡…" : "⚡?"
201208 }
202− return "⚡?"
209+ return parts.joined(separator: " · ")
203210 }
204211
205212 /// The text to show in the menu bar for the current state.
206213 var statusBarItemText: String {
207− Self.statusBarText(balance: balance, isLoading: isLoading)
214+ Self.statusBarText(
215+ balance: balance,
216+ isLoading: isLoading,
217+ claudePercent: claudeStatusBarPercent
218+ )
208219 }
209220
210221 /// The formatted balance string for display in the popover.
@@ -382,6 +393,29 @@ final class ViewModel: ObservableObject {
382393 }
383394 }
384395
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)
406+ }
407+ if let fiveHour = usage.fiveHour {
408+ return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
409+ }
410+ return nil
411+ }
412+
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+
385419 /// Display label for the Claude plan, e.g. "Pro" or "Max".
386420 var claudePlanLabel: String? {
387421 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
UsageMenubarTests/StatusBarTextTests.swiftnew file+92
@@ -0,0 +1,92 @@
1+import XCTest
2+@testable import UsageMenubar
3+
4+/// The menu bar title and the Claude number it is built from.
5+///
6+/// Balances are kept under 1,000 so the assertions don't depend on the grouping
7+/// separator of whatever locale the test happens to run in.
8+@MainActor
9+final class StatusBarTextTests: XCTestCase {
10+
11+ // MARK: - Title
12+
13+ func testBothServices() {
14+ let title = ViewModel.statusBarText(balance: 42, isLoading: false, claudePercent: 62)
15+ XCTAssertEqual(title, "⚡42 · 62%")
16+ }
17+
18+ func testHyperOnly() {
19+ let title = ViewModel.statusBarText(balance: 42, isLoading: false, claudePercent: nil)
20+ XCTAssertEqual(title, "⚡42")
21+ }
22+
23+ func testClaudeOnly() {
24+ let title = ViewModel.statusBarText(balance: nil, isLoading: false, claudePercent: 62)
25+ XCTAssertEqual(title, "62%")
26+ }
27+
28+ func testLoadingWithNothingToShow() {
29+ let title = ViewModel.statusBarText(balance: nil, isLoading: true, claudePercent: nil)
30+ XCTAssertEqual(title, "⚡…")
31+ }
32+
33+ func testNothingToShow() {
34+ let title = ViewModel.statusBarText(balance: nil, isLoading: false, claudePercent: nil)
35+ XCTAssertEqual(title, "⚡?")
36+ }
37+
38+ /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
39+ /// without a Hyper key refreshes every few minutes, and a title that fell back to `⚡…`
40+ /// each time would blink their percentage away and back.
41+ func testLoadingKeepsAClaudePercentOnScreen() {
42+ let title = ViewModel.statusBarText(balance: nil, isLoading: true, claudePercent: 62)
43+ XCTAssertEqual(title, "62%")
44+ }
45+
46+ func testLoadingKeepsAStaleBalanceOnScreen() {
47+ let title = ViewModel.statusBarText(balance: 42, isLoading: true, claudePercent: 62)
48+ XCTAssertEqual(title, "⚡42 · 62%")
49+ }
50+
51+ func testZeroPercentIsShownRatherThanOmitted() {
52+ let title = ViewModel.statusBarText(balance: 42, isLoading: false, claudePercent: 0)
53+ XCTAssertEqual(title, "⚡42 · 0%")
54+ }
55+
56+ // MARK: - Claude Headline
57+
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+ ]
67+ )
68+ XCTAssertEqual(ViewModel.claudeHeadline(for: usage)?.percent, 68)
69+ }
70+
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() {
74+ let usage = ClaudeUsage(
75+ fiveHour: UsageWindow(utilization: 61.6),
76+ sevenDay: UsageWindow(utilization: 4),
77+ sevenDayOpus: nil,
78+ limits: []
79+ )
80+ XCTAssertEqual(ViewModel.claudeHeadline(for: usage)?.percent, 62)
81+ }
82+
83+ func testHeadlineIsNilWithoutUsage() {
84+ XCTAssertNil(ViewModel.claudeHeadline(for: nil))
85+ }
86+
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))
91+ }
92+}