on master help sign in
Equalize all three providers in the popover
a489805iko
CLAUDE.md+33−8
@@ -1,12 +1,19 @@
1# UsageMenubar
1# UsageMenubar
2
2
3## Architecture
3## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
4+macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
5+visual treatment:
6+- **Hyper** (Charm) — credit balance
5−Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
7+- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
6−z.ai Coding Plan usage limits when an API key is entered in settings.
8+- **z.ai Coding Plan** — usage limits (5-hour, weekly)
9+
10+Each configured provider gets the same popover treatment: a 26pt subhero headline
11+number, bars under every window, a sparkline of recent history, a trend arrow,
12+click-to-copy on the headline, and threshold-crossing notifications.
13+
7- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
14- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
8−- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
15+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, per-provider history
9−- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
16+- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
10- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
17- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
11- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
18- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
12- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
19- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
@@ -21,6 +28,24 @@ produces no placeholder in the menu bar. The three fetches are independent —
21`ViewModel.refresh()` runs them with `async let`, and any can fail or be
28`ViewModel.refresh()` runs them with `async let`, and any can fail or be
22unconfigured without touching the others.
29unconfigured without touching the others.
23
30
31+## Equal provider treatment
32+All three providers share the same visual structure in the popover:
33+1. Section header with icon + plan label
34+2. Headline number (26pt subheroFont) with emoji, color-coded, click-to-copy, trend arrow
35+3. Reset countdown text (Claude/z.ai windows only — Hyper has no windows)
36+4. Bars under EVERY window (5-hour, 7-day, weekly — all get bars)
37+5. Sparkline (each provider tracks its own history via `MetricPoint` arrays)
38+6. Error row if applicable
39+
40+Notifications fire on threshold crossings:
41+- Hyper: balance drops below 10 credits
42+- Claude: 5-hour % crosses above 90%
43+- z.ai: 5-hour % crosses above 90%
44+
45+If a provider isn't configured, its section simply doesn't appear — no placeholder,
46+no error. This is already the case for Claude and z.ai; Hyper now behaves the same
47+way (gated on `hyperConfigured`).
48+
24## Claude credentials (strictly read-only)
49## Claude credentials (strictly read-only)
25`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
50`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
26Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
51Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
@@ -50,9 +75,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
50- Swift 5.9, macOS 14.0 deployment target
75- Swift 5.9, macOS 14.0 deployment target
51
76
52## Conventions
77## Conventions
53−- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
78+- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
54- No card-heavy/multi-material/gradient-rich designs
79- No card-heavy/multi-material/gradient-rich designs
55−- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
80+- Rounded font family at one scale (subhero/section/control/caption/footnote)
56- Monospaced digits for numbers that change
81- Monospaced digits for numbers that change
57- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
82- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
58 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
83 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -60,4 +85,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
60
85
61## VCS
86## VCS
62- jj colocated with git
87- jj colocated with git
63−- Push directly to master — no PRs for this repo
88+- Push directly to master — no PRs for this repo
UsageMenubar/MenuView.swift+174−172
@@ -4,21 +4,15 @@ import SwiftUI
4struct MenuView: View {
4struct MenuView: View {
5 @ObservedObject var viewModel: ViewModel
5 @ObservedObject var viewModel: ViewModel
6
6
7− /// Dims the balance while the pointer is over it, hinting that it is clickable.
8− @State private var isHoveringBalance = false
9−
10 private static let websiteURL = URL(string: "https://hyper.charm.land")!
7 private static let websiteURL = URL(string: "https://hyper.charm.land")!
11 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
8 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
12
9
13 // MARK: - Type Scale
10 // MARK: - Type Scale
14
11
15− /// One rounded family, sized so that the balance is the only large element and
12+ /// One rounded family, sized so that every provider's headline number reads at the
16− /// everything else steps down from it — nothing competes with it for attention.
13+ /// same weight. With three providers in the popover, the 26pt subhero is the right
17− /// The Claude percentage is the one exception, and it is deliberately set well
14+ /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
18− /// below the balance so it reads as the second thing on the page, not the first.
19− /// Monospaced digits keep numbers from jittering as their digits change.
15+ /// numbers from jittering as their digits change.
20− private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
21− .monospacedDigit()
22 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
23 .monospacedDigit()
17 .monospacedDigit()
24 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -28,7 +22,9 @@ struct MenuView: View {
28
22
29 var body: some View {
23 var body: some View {
30 VStack(alignment: .leading, spacing: 18) {
24 VStack(alignment: .leading, spacing: 18) {
25+ if viewModel.hyperConfigured {
31− hyper
26+ hyper
27+ }
32 if showsClaude {
28 if showsClaude {
33 serviceBreak
29 serviceBreak
34 claude
30 claude
@@ -51,10 +47,14 @@ struct MenuView: View {
51 .background(Color(nsColor: .windowBackgroundColor))
47 .background(Color(nsColor: .windowBackgroundColor))
52 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
53 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
49 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
54 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
55 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
56 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
57− .animation(.easeInOut(duration: 0.3), value: historyValues.count)
55+ .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56+ .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,15 +66,14 @@ struct MenuView: View {
66 Divider().opacity(0.3)
66 Divider().opacity(0.3)
67 }
67 }
68
68
69− /// The rule between the two services. Wider than the others on purpose: Hyper and
69+ /// The rule between services. Wider than the others on purpose: the providers are
70− /// Claude are two unrelated accounts, and the gap should say so before the headers do.
70+ /// unrelated accounts, and the gap should say so before the headers do.
71 private var serviceBreak: some View {
71 private var serviceBreak: some View {
72 hairline.padding(.vertical, 5)
72 hairline.padding(.vertical, 5)
73 }
73 }
74
74
75 /// A section header: an icon and a title, sized to match the "API Key" header below
75 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three sections rather than a hero with two appendices.
76+ /// so the popover reads as three equal sections rather than a hero with appendages.
77− /// The icon is what tells the two services apart at a glance.
78 private func sectionHeader(_ title: String, systemImage: String) -> some View {
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
79 HStack(spacing: 5) {
78 HStack(spacing: 5) {
80 Image(systemName: systemImage)
79 Image(systemName: systemImage)
@@ -85,113 +84,62 @@ struct MenuView: View {
85 }
84 }
86 }
85 }
87
86
88− // MARK: - Hyper
87+ /// A reusable headline number row: the provider's main metric at subhero scale,
89−
88+ /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
90− /// The Hyper section: its header, then the balance as the popover's hero.
89+ /// copy-confirmation slot so the layout doesn't shift.
90+ private func headlineNumber(
91+ text: String,
92+ color: Color,
93+ trend: MetricTrend,
94+ copiedConfirmation: Bool,
95+ captionText: String,
96+ copyAction: @escaping () -> Void
91− private var hyper: some View {
97+ ) -> some View {
92− VStack(alignment: .leading, spacing: 12) {
98+ VStack(spacing: 6) {
93− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
99+ Button(action: copyAction) {
94−
100+ HStack(alignment: .firstTextBaseline, spacing: 6) {
95− hero
96− }
97− }
98−
99− private var hero: some View {
100− VStack(spacing: 8) {
101− if viewModel.isLoading && viewModel.balance == nil {
102− PulsingBolt()
103− .transition(.opacity)
104− } else if viewModel.balance == nil {
105− VStack(spacing: 6) {
106− Text("⚡?")
101+ Text(text)
107− .font(Self.heroFont)
102+ .font(Self.subheroFont)
108− .foregroundColor(.secondary)
103+ .foregroundColor(color)
104+ .contentTransition(.opacity)
109
105
110− Text("Enter your API key below")
106+ if let symbol = trend.symbolName {
111− .font(Self.captionFont)
107+ Image(systemName: symbol)
108+ .font(.system(size: 12, weight: .semibold))
112− .foregroundColor(.secondary)
109+ .foregroundColor(.secondary)
113− }
114− .transition(.opacity)
110+ .transition(.opacity)
115− } else {
111+ }
116− VStack(spacing: 6) {
117− balanceNumber
118− caption
119− sparkline
120 }
112 }
121− .transition(.opacity)
113+ .contentShape(Rectangle())
122 }
114 }
123−
115+ .buttonStyle(.plain)
124− if let lastUpdatedText = viewModel.lastUpdatedText {
116+ .help("Click to copy")
125− Text(lastUpdatedText)
117+
126− .font(Self.footnoteFont)
118+ // Caption doubles as copy confirmation so the layout doesn't shift.
127− .foregroundColor(.secondary)
119+ Group {
128− .transition(.opacity)
120+ if copiedConfirmation {
129− }
121+ Text("✓ Copied")
130−
122+ .foregroundColor(.green)
131− if let error = viewModel.errorMessage {
123+ } else {
132− errorRow(error)
124+ Text(captionText)
133− .transition(.opacity)
134− }
135− }
136− .frame(maxWidth: .infinity)
137− }
138−
139− /// The balance itself. Clicking it copies the raw number to the clipboard.
140− private var balanceNumber: some View {
141− Button(action: { viewModel.copyBalanceToClipboard() }) {
142− HStack(alignment: .firstTextBaseline, spacing: 6) {
143− Text(viewModel.formattedBalance)
144− .font(Self.heroFont)
145− .foregroundColor(viewModel.balanceColor)
146− .contentTransition(.opacity)
147−
148− if let symbol = viewModel.balanceTrend.symbolName {
149− Image(systemName: symbol)
150− .font(.system(size: 12, weight: .semibold))
151 .foregroundColor(.secondary)
125 .foregroundColor(.secondary)
152− .transition(.opacity)
153 }
126 }
154 }
127 }
155− // Without this the hover area is only the glyphs themselves.
128+ .font(Self.captionFont)
156− .contentShape(Rectangle())
157 }
129 }
158− .buttonStyle(.plain)
159− .disabled(viewModel.balance == nil)
160− .help("Click to copy")
161− .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
162− .onHover { isHoveringBalance = $0 }
163− .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
164− }
165−
166− /// Doubles as the copy confirmation so the layout doesn't shift.
167− private var caption: some View {
168− Group {
169− if viewModel.copiedConfirmation {
170− Text("✓ Copied")
171− .foregroundColor(.green)
172− } else {
173− Text("credits")
174− .foregroundColor(.secondary)
175− }
176− }
177− .font(Self.captionFont)
178 }
130 }
179
131
132+ /// A sparkline shown when there are at least 2 history points.
180 @ViewBuilder
133 @ViewBuilder
181− private var sparkline: some View {
134+ private func sparkline(values: [Int], color: Color) -> some View {
182− if historyValues.count >= 2 {
135+ if values.count >= 2 {
183− Sparkline(values: historyValues, color: viewModel.balanceColor)
136+ Sparkline(values: values, color: color)
184 .frame(height: 26)
137 .frame(height: 26)
185 .padding(.top, 4)
138 .padding(.top, 4)
186 .transition(.opacity)
139 .transition(.opacity)
187 }
140 }
188 }
141 }
189
142
190− /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
191− private var historyValues: [Int] {
192− viewModel.balanceHistory.map { $0.balance }
193− }
194−
195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
196 /// message, and a tinted rounded background with no border.
144 /// message, and a tinted rounded background with no border.
197 private func errorRow(_ message: String) -> some View {
145 private func errorRow(_ message: String) -> some View {
@@ -216,6 +164,56 @@ struct MenuView: View {
216 .padding(.top, 2)
164 .padding(.top, 2)
217 }
165 }
218
166
167+ // MARK: - Hyper
168+
169+ /// Whether there is anything to say about Hyper. Nothing at all when Hyper isn't
170+ /// configured — the section doesn't appear, same as Claude and z.ai.
171+ private var showsHyper: Bool {
172+ viewModel.hyperConfigured
173+ }
174+
175+ /// The Hyper section: header, balance as a subhero number, caption, sparkline.
176+ private var hyper: some View {
177+ VStack(alignment: .leading, spacing: 12) {
178+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
179+
180+ if viewModel.isLoading && viewModel.balance == nil {
181+ Text("…")
182+ .font(Self.subheroFont)
183+ .foregroundColor(.secondary)
184+ } else if let balance = viewModel.balance {
185+ VStack(spacing: 8) {
186+ headlineNumber(
187+ text: "⚡ \(viewModel.formattedBalance)",
188+ color: viewModel.balanceColor,
189+ trend: viewModel.balanceTrend,
190+ copiedConfirmation: viewModel.copiedConfirmation,
191+ captionText: "credits",
192+ copyAction: { viewModel.copyBalanceToClipboard() }
193+ )
194+
195+ sparkline(
196+ values: viewModel.balanceSparklineValues,
197+ color: viewModel.balanceColor
198+ )
199+ }
200+ .transition(.opacity)
201+ }
202+
203+ if let lastUpdatedText = viewModel.lastUpdatedText {
204+ Text(lastUpdatedText)
205+ .font(Self.footnoteFont)
206+ .foregroundColor(.secondary)
207+ .transition(.opacity)
208+ }
209+
210+ if let error = viewModel.errorMessage {
211+ errorRow(error)
212+ .transition(.opacity)
213+ }
214+ }
215+ }
216+
219 // MARK: - Claude
217 // MARK: - Claude
220
218
221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -227,8 +225,8 @@ struct MenuView: View {
227 return false
225 return false
228 }
226 }
229
227
230− /// Claude Code's plan limits. The percentage leads, as the balance does above it,
228+ /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
231− /// with the individual windows underneath as quiet supporting detail.
229+ /// headline number, bars under every window, a sparkline, and error row.
232 private var claude: some View {
230 private var claude: some View {
233 VStack(alignment: .leading, spacing: 12) {
231 VStack(alignment: .leading, spacing: 12) {
234 HStack(spacing: 8) {
232 HStack(spacing: 8) {
@@ -255,23 +253,31 @@ struct MenuView: View {
255
253
256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
257 VStack(alignment: .leading, spacing: 12) {
255 VStack(alignment: .leading, spacing: 12) {
258− // The 5-hour window leads as the hero number, since it's the one
256+ // The 5-hour window leads as the headline number.
259− // most likely to bind and the one shown in the menu bar title.
260 if let fiveHour = usage.fiveHour {
257 if let fiveHour = usage.fiveHour {
261 let percent = Int(fiveHour.utilization.rounded())
258 let percent = Int(fiveHour.utilization.rounded())
259+ VStack(spacing: 8) {
262− HStack(alignment: .firstTextBaseline, spacing: 8) {
260+ HStack(alignment: .firstTextBaseline, spacing: 8) {
261+ headlineNumber(
263− Text("🕐 \(percent)%")
262+ text: "🕐 \(percent)%",
264− .font(Self.subheroFont)
265− .foregroundColor(Self.usageColor(percent))
263+ color: Self.usageColor(percent),
266− .contentTransition(.opacity)
264+ trend: viewModel.claudeTrend,
265+ copiedConfirmation: viewModel.copiedClaudeConfirmation,
266+ captionText: "5-hour usage",
267+ copyAction: { viewModel.copyClaudePercentToClipboard() }
268+ )
267
269
268− Spacer(minLength: 0)
270+ Spacer(minLength: 0)
269
271
270− if let resets = fiveHour.resetsInFormatted {
272+ if let resets = fiveHour.resetsInFormatted {
271− Text("resets in \(resets)")
273+ Text("resets in \(resets)")
272− .font(Self.footnoteFont)
274+ .font(Self.footnoteFont)
273− .foregroundColor(.secondary)
275+ .foregroundColor(.secondary)
276+ }
274 }
277 }
278+
279+ // Bar under the 5-hour window too, same as 7-day windows below.
280+ UsageBar(percent: percent, color: Self.usageColor(percent))
275 }
281 }
276 }
282 }
277
283
@@ -285,6 +291,12 @@ struct MenuView: View {
285 window("7-day Opus", opus)
291 window("7-day Opus", opus)
286 }
292 }
287 }
293 }
294+
295+ // Sparkline for the Claude 5-hour % history.
296+ sparkline(
297+ values: viewModel.claudeSparklineValues,
298+ color: Self.usageColor(claudeFiveHourPercent ?? 0)
299+ )
288 }
300 }
289 }
301 }
290
302
@@ -310,6 +322,11 @@ struct MenuView: View {
310 }
322 }
311 }
323 }
312
324
325+ /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326+ private var claudeFiveHourPercent: Int? {
327+ viewModel.claudeFiveHourPercent
328+ }
329+
313 /// The balance colors read the other way round here, because for a limit more is
330 /// The balance colors read the other way round here, because for a limit more is
314 /// worse: green with room to spare, yellow closing in, red nearly spent.
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
315 private static func usageColor(_ percent: Int) -> Color {
332 private static func usageColor(_ percent: Int) -> Color {
@@ -329,8 +346,8 @@ struct MenuView: View {
329 return false
346 return false
330 }
347 }
331
348
332− /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
349+ /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
333− /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
350+ /// a subhero headline number, bars under every window, a sparkline, and error row.
334 private var zai: some View {
351 private var zai: some View {
335 VStack(alignment: .leading, spacing: 12) {
352 VStack(alignment: .leading, spacing: 12) {
336 HStack(spacing: 8) {
353 HStack(spacing: 8) {
@@ -357,21 +374,30 @@ struct MenuView: View {
357
374
358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
359 VStack(alignment: .leading, spacing: 12) {
376 VStack(alignment: .leading, spacing: 12) {
360− // The 5-hour window leads as the hero number, same as Claude's.
377+ // The 5-hour window leads as the headline number.
361 if let fiveHour = usage.fiveHourPercent {
378 if let fiveHour = usage.fiveHourPercent {
379+ VStack(spacing: 8) {
362− HStack(alignment: .firstTextBaseline, spacing: 8) {
380+ HStack(alignment: .firstTextBaseline, spacing: 8) {
381+ headlineNumber(
363− Text("🤖 \(fiveHour)%")
382+ text: "🤖 \(fiveHour)%",
364− .font(Self.subheroFont)
365− .foregroundColor(Self.usageColor(fiveHour))
383+ color: Self.usageColor(fiveHour),
366− .contentTransition(.opacity)
384+ trend: viewModel.zaiTrend,
385+ copiedConfirmation: viewModel.copiedZaiConfirmation,
386+ captionText: "5-hour usage",
387+ copyAction: { viewModel.copyZaiPercentToClipboard() }
388+ )
367
389
368− Spacer(minLength: 0)
390+ Spacer(minLength: 0)
369
391
370− if let resets = usage.fiveHourResetsAt {
392+ if let resets = usage.fiveHourResetsAt {
371− Text("resets in \(Self.zaiResetsIn(from: resets))")
393+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372− .font(Self.footnoteFont)
394+ .font(Self.footnoteFont)
373− .foregroundColor(.secondary)
395+ .foregroundColor(.secondary)
396+ }
374 }
397 }
398+
399+ // Bar under the 5-hour window too.
400+ UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
375 }
401 }
376 }
402 }
377
403
@@ -380,9 +406,20 @@ struct MenuView: View {
380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
381 }
407 }
382 }
408 }
409+
410+ // Sparkline for the z.ai 5-hour % history.
411+ sparkline(
412+ values: viewModel.zaiSparklineValues,
413+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
414+ )
383 }
415 }
384 }
416 }
385
417
418+ /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
419+ private var zaiFiveHourPercent: Int? {
420+ viewModel.zaiFiveHourPercent
421+ }
422+
386 /// One window row for z.ai: label, percentage, and a hairline bar.
423 /// One window row for z.ai: label, percentage, and a hairline bar.
387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
424 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388 VStack(alignment: .leading, spacing: 5) {
425 VStack(alignment: .leading, spacing: 5) {
@@ -523,45 +560,10 @@ struct MenuView: View {
523 .keyboardShortcut("q")
560 .keyboardShortcut("q")
524 }
561 }
525 .controlSize(.small)
562 .controlSize(.small)
526−
527− Link("hyper.charm.land", destination: Self.websiteURL)
528− .font(Self.footnoteFont)
529− .foregroundColor(.secondary)
530− .opacity(0.7)
531− .frame(maxWidth: .infinity, alignment: .center)
532 }
563 }
533 }
564 }
534}
565}
535
566
536−// MARK: - Loading
537−
538−/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
539−/// the menu bar title rather than a generic spinner, so the two read as the same app.
540−private struct PulsingBolt: View {
541− @State private var isPulsing = false
542−
543− var body: some View {
544− VStack(spacing: 6) {
545− Text("⚡")
546− .font(.system(size: 34))
547− .opacity(isPulsing ? 0.35 : 1)
548− .scaleEffect(isPulsing ? 0.94 : 1)
549− .animation(
550− .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
551− value: isPulsing
552− )
553− // Roughly the height of the balance it stands in for, so the hero
554− // doesn't jump when the first fetch lands.
555− .frame(height: 56)
556−
557− Text("Loading…")
558− .font(.system(size: 11, weight: .medium, design: .rounded))
559− .foregroundColor(.secondary)
560− }
561− .onAppear { isPulsing = true }
562− }
563−}
564−
565// MARK: - Usage Bar
567// MARK: - Usage Bar
566
568
567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
569/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -592,8 +594,8 @@ private struct UsageBar: View {
592
594
593// MARK: - Sparkline
595// MARK: - Sparkline
594
596
595−/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
597+/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
596−/// takes the balance's color so the trend reads as part of the number above it, with a
598+/// takes the metric's color so the trend reads as part of the number above it, with a
597/// faint fade underneath for weight and a dot marking the latest point.
599/// faint fade underneath for weight and a dot marking the latest point.
598///
600///
599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
601/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -685,4 +687,4 @@ struct Sparkline: View {
685 path.closeSubpath()
687 path.closeSubpath()
686 }
688 }
687 }
689 }
688−}
690+}
UsageMenubar/ViewModel.swift+174−22
@@ -10,8 +10,8 @@ extension Notification.Name {
10 static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10 static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
11}
11}
12
12
13−/// The direction the balance has moved over the most recent history points.
13+/// The direction a metric has moved over the most recent history points.
14−enum BalanceTrend {
14+enum MetricTrend {
15 case increasing
15 case increasing
16 case decreasing
16 case decreasing
17 case stable
17 case stable
@@ -29,10 +29,11 @@ enum BalanceTrend {
29 }
29 }
30}
30}
31
31
32−/// A single balance observation.
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
33−struct BalancePoint: Equatable {
34+struct MetricPoint: Equatable {
34 let date: Date
35 let date: Date
35− let balance: Int
36+ let value: Int
36}
37}
37
38
38/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
39/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -111,8 +112,20 @@ final class ViewModel: ObservableObject {
111 /// Brief "✓ Copied" confirmation shown after copying the balance.
112 /// Brief "✓ Copied" confirmation shown after copying the balance.
112 @Published var copiedConfirmation: Bool = false
113 @Published var copiedConfirmation: Bool = false
113
114
115+ /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116+ @Published var copiedClaudeConfirmation: Bool = false
117+
118+ /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119+ @Published var copiedZaiConfirmation: Bool = false
120+
114 /// Successful balance observations from the last 24 hours, oldest first.
121 /// Successful balance observations from the last 24 hours, oldest first.
115− @Published private(set) var history: [BalancePoint] = []
122+ @Published private(set) var history: [MetricPoint] = []
123+
124+ /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125+ @Published private(set) var claudeHistory: [MetricPoint] = []
126+
127+ /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128+ @Published private(set) var zaiHistory: [MetricPoint] = []
116
129
117 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
118 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -163,6 +176,12 @@ final class ViewModel: ObservableObject {
163 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
164 private var savedResetTask: Task<Void, Never>?
177 private var savedResetTask: Task<Void, Never>?
165
178
179+ /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180+ private var copiedClaudeResetTask: Task<Void, Never>?
181+
182+ /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183+ private var copiedZaiResetTask: Task<Void, Never>?
184+
166 // MARK: - History Configuration
185 // MARK: - History Configuration
167
186
168 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -170,6 +189,9 @@ final class ViewModel: ObservableObject {
170 private static let maxHistoryEntries = 300
189 private static let maxHistoryEntries = 300
171 private static let historyWindow: TimeInterval = 24 * 60 * 60
190 private static let historyWindow: TimeInterval = 24 * 60 * 60
172
191
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
173 init(
195 init(
174 checker: CreditsChecking = CreditsChecker(),
196 checker: CreditsChecking = CreditsChecker(),
175 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -290,33 +312,69 @@ final class ViewModel: ObservableObject {
290 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
312 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
291 }
313 }
292
314
293− // MARK: - History
315+ // MARK: - History (Hyper)
294
316
295− /// The last 24 hours of successful fetches, oldest first.
317+ /// The last 24 hours of successful balance fetches, oldest first.
296 var balanceHistory: [(date: Date, balance: Int)] {
318 var balanceHistory: [(date: Date, balance: Int)] {
297− history.map { (date: $0.date, balance: $0.balance) }
319+ history.map { (date: $0.date, balance: $0.value) }
298 }
320 }
299
321
300 /// The direction of travel across the most recent handful of observations.
322 /// The direction of travel across the most recent handful of observations.
301 /// `.unknown` until there are at least two points to compare.
323 /// `.unknown` until there are at least two points to compare.
302− var balanceTrend: BalanceTrend {
324+ var balanceTrend: MetricTrend {
325+ metricTrend(history)
326+ }
327+
328+ /// The sparkline's data for the Hyper balance.
329+ var balanceSparklineValues: [Int] {
330+ history.map { $0.value }
331+ }
332+
333+ // MARK: - History (Claude)
334+
335+ /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
336+ var claudeSparklineValues: [Int] {
337+ claudeHistory.map { $0.value }
338+ }
339+
340+ /// The direction of travel for the Claude 5-hour %.
341+ var claudeTrend: MetricTrend {
342+ metricTrend(claudeHistory)
343+ }
344+
345+ // MARK: - History (z.ai)
346+
347+ /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
348+ var zaiSparklineValues: [Int] {
349+ zaiHistory.map { $0.value }
350+ }
351+
352+ /// The direction of travel for the z.ai 5-hour %.
353+ var zaiTrend: MetricTrend {
354+ metricTrend(zaiHistory)
355+ }
356+
357+ // MARK: - History Helpers
358+
359+ /// Computes the trend from a generic history array.
360+ private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
303− guard history.count >= 2 else { return .unknown }
361+ guard points.count >= 2 else { return .unknown }
304− let recent = history.suffix(5)
362+ let recent = points.suffix(5)
305 guard let first = recent.first, let last = recent.last else { return .unknown }
363 guard let first = recent.first, let last = recent.last else { return .unknown }
306− if last.balance > first.balance { return .increasing }
364+ if last.value > first.value { return .increasing }
307− if last.balance < first.balance { return .decreasing }
365+ if last.value < first.value { return .decreasing }
308 return .stable
366 return .stable
309 }
367 }
310
368
311 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
369 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
312− private func recordHistory(balance: Int, at date: Date) {
370+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
313− history.append(BalancePoint(date: date, balance: balance))
371+ array.append(MetricPoint(date: date, value: value))
314
372
315 let cutoff = date.addingTimeInterval(-Self.historyWindow)
373 let cutoff = date.addingTimeInterval(-Self.historyWindow)
316− history.removeAll { $0.date < cutoff }
374+ array.removeAll { $0.date < cutoff }
317
375
318− if history.count > Self.maxHistoryEntries {
376+ if array.count > Self.maxHistoryEntries {
319− history.removeFirst(history.count - Self.maxHistoryEntries)
377+ array.removeFirst(array.count - Self.maxHistoryEntries)
320 }
378 }
321 }
379 }
322
380
@@ -391,7 +449,7 @@ final class ViewModel: ObservableObject {
391 errorMessage = nil
449 errorMessage = nil
392 let now = Date()
450 let now = Date()
393 lastUpdated = now
451 lastUpdated = now
394− recordHistory(balance: newBalance, at: now)
452+ recordMetric(newBalance, into: &history, at: now)
395
453
396 // Low-balance notification: on threshold crossing (≥10 → <10)
454 // Low-balance notification: on threshold crossing (≥10 → <10)
397 // OR on first successful fetch if already below threshold.
455 // OR on first successful fetch if already below threshold.
@@ -436,12 +494,30 @@ final class ViewModel: ObservableObject {
436 claudeUsage = nil
494 claudeUsage = nil
437 claudePlan = nil
495 claudePlan = nil
438 claudeError = nil
496 claudeError = nil
497+ claudeHistory.removeAll()
439
498
440 case .usage(let report):
499 case .usage(let report):
500+ // Capture the previous 5-hour % for threshold-crossing notification.
501+ let previousPercent = claudeFiveHourPercent
441 claudeUsage = report.usage
502 claudeUsage = report.usage
442 claudePlan = report.subscriptionType
503 claudePlan = report.subscriptionType
443 claudeError = nil
504 claudeError = nil
444
505
506+ // Record history and fire high-usage notification based on the 5-hour %.
507+ if let percent = claudeFiveHourPercent {
508+ let now = Date()
509+ recordMetric(percent, into: &claudeHistory, at: now)
510+
511+ // High-usage notification: on threshold crossing (<90 → ≥90)
512+ // OR on first successful fetch if already above threshold.
513+ if let previous = previousPercent, previous < Self.highUsageThreshold,
514+ percent >= Self.highUsageThreshold {
515+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
516+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
517+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
518+ }
519+ }
520+
445 case .failure(let message):
521 case .failure(let message):
446 // As with the balance: keep the last good numbers on screen and say what
522 // As with the balance: keep the last good numbers on screen and say what
447 // went wrong, rather than blanking the section on a transient failure.
523 // went wrong, rather than blanking the section on a transient failure.
@@ -509,11 +585,29 @@ final class ViewModel: ObservableObject {
509 case .notConfigured:
585 case .notConfigured:
510 zaiUsage = nil
586 zaiUsage = nil
511 zaiError = nil
587 zaiError = nil
588+ zaiHistory.removeAll()
512
589
513 case .usage(let report):
590 case .usage(let report):
591+ // Capture the previous 5-hour % for threshold-crossing notification.
592+ let previousPercent = zaiFiveHourPercent
514 zaiUsage = report
593 zaiUsage = report
515 zaiError = nil
594 zaiError = nil
516
595
596+ // Record history and fire high-usage notification based on the 5-hour %.
597+ if let percent = zaiFiveHourPercent {
598+ let now = Date()
599+ recordMetric(percent, into: &zaiHistory, at: now)
600+
601+ // High-usage notification: on threshold crossing (<90 → ≥90)
602+ // OR on first successful fetch if already above threshold.
603+ if let previous = previousPercent, previous < Self.highUsageThreshold,
604+ percent >= Self.highUsageThreshold {
605+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
606+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
607+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
608+ }
609+ }
610+
517 case .failure(let message):
611 case .failure(let message):
518 // As with the balance and Claude: keep the last good numbers on screen
612 // As with the balance and Claude: keep the last good numbers on screen
519 // and say what went wrong, rather than blanking the section on a
613 // and say what went wrong, rather than blanking the section on a
@@ -567,7 +661,41 @@ final class ViewModel: ObservableObject {
567 }
661 }
568 }
662 }
569
663
570− // MARK: - Low Balance Notification
664+ /// Copies the current Claude 5-hour percentage to the clipboard.
665+ func copyClaudePercentToClipboard() {
666+ guard let percent = claudeFiveHourPercent else { return }
667+
668+ let pasteboard = NSPasteboard.general
669+ pasteboard.clearContents()
670+ pasteboard.setString("\(percent)%", forType: .string)
671+
672+ copiedClaudeConfirmation = true
673+ copiedClaudeResetTask?.cancel()
674+ copiedClaudeResetTask = Task { @MainActor in
675+ try? await Task.sleep(nanoseconds: 1_500_000_000)
676+ guard !Task.isCancelled else { return }
677+ copiedClaudeConfirmation = false
678+ }
679+ }
680+
681+ /// Copies the current z.ai 5-hour percentage to the clipboard.
682+ func copyZaiPercentToClipboard() {
683+ guard let percent = zaiFiveHourPercent else { return }
684+
685+ let pasteboard = NSPasteboard.general
686+ pasteboard.clearContents()
687+ pasteboard.setString("\(percent)%", forType: .string)
688+
689+ copiedZaiConfirmation = true
690+ copiedZaiResetTask?.cancel()
691+ copiedZaiResetTask = Task { @MainActor in
692+ try? await Task.sleep(nanoseconds: 1_500_000_000)
693+ guard !Task.isCancelled else { return }
694+ copiedZaiConfirmation = false
695+ }
696+ }
697+
698+ // MARK: - Notifications
571
699
572 private func sendLowBalanceNotification(balance: Int) {
700 private func sendLowBalanceNotification(balance: Int) {
573 // Build the body here, on the main actor. `getNotificationSettings` calls back on
701 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -596,6 +724,29 @@ final class ViewModel: ObservableObject {
596 }
724 }
597 }
725 }
598
726
727+ /// Sends a notification when a usage percentage crosses the high-usage threshold.
728+ private func sendHighUsageNotification(provider: String, percent: Int) {
729+ let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
730+
731+ let center = UNUserNotificationCenter.current()
732+ center.getNotificationSettings { settings in
733+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
734+ return
735+ }
736+ let content = UNMutableNotificationContent()
737+ content.title = "High Usage Menubar"
738+ content.body = body
739+ content.sound = .default
740+
741+ let request = UNNotificationRequest(
742+ identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
743+ content: content,
744+ trigger: nil
745+ )
746+ center.add(request)
747+ }
748+ }
749+
599 // MARK: - API Key
750 // MARK: - API Key
600
751
601 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
752 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -678,6 +829,7 @@ final class ViewModel: ObservableObject {
678 if key != activeZaiAPIKey {
829 if key != activeZaiAPIKey {
679 zaiUsage = nil
830 zaiUsage = nil
680 zaiError = nil
831 zaiError = nil
832+ zaiHistory.removeAll()
681 }
833 }
682 // Show the trimmed key that was actually stored.
834 // Show the trimmed key that was actually stored.
683 zaiAPIKeyInput = key
835 zaiAPIKeyInput = key
@@ -715,4 +867,4 @@ final class ViewModel: ObservableObject {
715 isInitializingLaunchAtLogin = false
867 isInitializingLaunchAtLogin = false
716 }
868 }
717 }
869 }
718−}
870+}