on master help sign in
Equalize all three providers in the popover

All three providers (Hyper, Claude Code, z.ai) now get the same visual treatment: - 26pt subheroFont for every headline number (was 46pt hero for Hyper only) - Sparkline for every provider (Hyper already had one; added Claude & z.ai) - Trend arrows for every provider's main metric - Click-to-copy for every provider's headline number - Bars under every window including 5-hour (was 7-day/weekly only) - Threshold notifications: Hyper low-balance (<10), Claude & z.ai high-usage (>=90%) - Per-provider history tracking via MetricPoint arrays ViewModel changes: - Renamed BalanceTrend -> MetricTrend, BalancePoint -> MetricPoint - Added claudeHistory, zaiHistory published arrays - Added claudeTrend, zaiTrend computed properties - Added copyClaudePercentToClipboard, copyZaiPercentToClipboard methods - Added copiedClaudeConfirmation, copiedZaiConfirmation flags - Added sendHighUsageNotification for Claude/z.ai threshold crossings - Clear Claude/z.ai history when credentials removed - Clear z.ai history on key change MenuView changes: - Removed 46pt heroFont; all providers use 26pt subheroFont - Removed PulsingBolt loading state; uses simple ellipsis - Removed placeholder for unconfigured Hyper; section simply hidden - Removed hyper.charm.land footer link - Extracted reusable headlineNumber and sparkline view builders - Added bars under 5-hour windows for Claude and z.ai - Hyper section now gated on hyperConfigured (same as Claude/z.ai) CLAUDE.md updated to reflect equal provider treatment.

commita489805a57b0dc4df2690b747b9178e1bd982ce0 authoriko <mail@iko.soy> dateFri 17 Jul 2026, 13:48 (3 months ago) parentee9edd8
3 files changed+381−202
CLAUDE.md+33−8 UsageMenubar/MenuView.swift+174−172 UsageMenubar/ViewModel.swift+174−22
CLAUDE.md+33−8
@@ -1,12 +1,19 @@
11# UsageMenubar
22
33## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5−Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
6−z.ai Coding Plan usage limits when an API key is entered in settings.
4+macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
5+visual treatment:
6+- **Hyper** (Charm) — credit balance
7+- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
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+
714- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
8−- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
9−- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
15+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, per-provider history
16+- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
1017- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
1118- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
1219- `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 —
2128`ViewModel.refresh()` runs them with `async let`, and any can fail or be
2229unconfigured without touching the others.
2330
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+
2449## Claude credentials (strictly read-only)
2550`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
2651Keychain 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
5075- Swift 5.9, macOS 14.0 deployment target
5176
5277## 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
5479- 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)
5681- Monospaced digits for numbers that change
5782- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
5883 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
6085
6186## VCS
6287- 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
44struct MenuView: View {
55 @ObservedObject var viewModel: ViewModel
66
7− /// Dims the balance while the pointer is over it, hinting that it is clickable.
8− @State private var isHoveringBalance = false
9−
107 private static let websiteURL = URL(string: "https://hyper.charm.land")!
118 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
129
1310 // MARK: - Type Scale
1411
15− /// One rounded family, sized so that the balance is the only large element and
16− /// everything else steps down from it — nothing competes with it for attention.
17− /// The Claude percentage is the one exception, and it is deliberately set well
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.
20− private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
21− .monospacedDigit()
12+ /// One rounded family, sized so that every provider's headline number reads at the
13+ /// same weight. With three providers in the popover, the 26pt subhero is the right
14+ /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15+ /// numbers from jittering as their digits change.
2216 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
2317 .monospacedDigit()
2418 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -28,7 +22,9 @@ struct MenuView: View {
2822
2923 var body: some View {
3024 VStack(alignment: .leading, spacing: 18) {
31− hyper
25+ if viewModel.hyperConfigured {
26+ hyper
27+ }
3228 if showsClaude {
3329 serviceBreak
3430 claude
@@ -51,10 +47,14 @@ struct MenuView: View {
5147 .background(Color(nsColor: .windowBackgroundColor))
5248 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
5349 .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)
5452 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
5553 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
5654 .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)
5858 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
5959 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
6060 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,15 +66,14 @@ struct MenuView: View {
6666 Divider().opacity(0.3)
6767 }
6868
69− /// The rule between the two services. Wider than the others on purpose: Hyper and
70− /// Claude are two unrelated accounts, and the gap should say so before the headers do.
69+ /// The rule between services. Wider than the others on purpose: the providers are
70+ /// unrelated accounts, and the gap should say so before the headers do.
7171 private var serviceBreak: some View {
7272 hairline.padding(.vertical, 5)
7373 }
7474
7575 /// 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.
77− /// The icon is what tells the two services apart at a glance.
76+ /// so the popover reads as three equal sections rather than a hero with appendages.
7877 private func sectionHeader(_ title: String, systemImage: String) -> some View {
7978 HStack(spacing: 5) {
8079 Image(systemName: systemImage)
@@ -85,113 +84,62 @@ struct MenuView: View {
8584 }
8685 }
8786
88− // MARK: - Hyper
89−
90− /// The Hyper section: its header, then the balance as the popover's hero.
91− private var hyper: some View {
92− VStack(alignment: .leading, spacing: 12) {
93− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
94−
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("⚡?")
107− .font(Self.heroFont)
108− .foregroundColor(.secondary)
87+ /// A reusable headline number row: the provider's main metric at subhero scale,
88+ /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
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
97+ ) -> some View {
98+ VStack(spacing: 6) {
99+ Button(action: copyAction) {
100+ HStack(alignment: .firstTextBaseline, spacing: 6) {
101+ Text(text)
102+ .font(Self.subheroFont)
103+ .foregroundColor(color)
104+ .contentTransition(.opacity)
109105
110− Text("Enter your API key below")
111− .font(Self.captionFont)
112− .foregroundColor(.secondary)
113− }
114− .transition(.opacity)
115− } else {
116− VStack(spacing: 6) {
117− balanceNumber
118− caption
119− sparkline
106+ if let symbol = trend.symbolName {
107+ Image(systemName: symbol)
108+ .font(.system(size: 12, weight: .semibold))
109+ .foregroundColor(.secondary)
110+ .transition(.opacity)
111+ }
120112 }
121− .transition(.opacity)
113+ .contentShape(Rectangle())
122114 }
123−
124− if let lastUpdatedText = viewModel.lastUpdatedText {
125− Text(lastUpdatedText)
126− .font(Self.footnoteFont)
127− .foregroundColor(.secondary)
128− .transition(.opacity)
129− }
130−
131− if let error = viewModel.errorMessage {
132− errorRow(error)
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))
115+ .buttonStyle(.plain)
116+ .help("Click to copy")
117+
118+ // Caption doubles as copy confirmation so the layout doesn't shift.
119+ Group {
120+ if copiedConfirmation {
121+ Text("✓ Copied")
122+ .foregroundColor(.green)
123+ } else {
124+ Text(captionText)
151125 .foregroundColor(.secondary)
152− .transition(.opacity)
153126 }
154127 }
155− // Without this the hover area is only the glyphs themselves.
156− .contentShape(Rectangle())
128+ .font(Self.captionFont)
157129 }
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)
178130 }
179131
132+ /// A sparkline shown when there are at least 2 history points.
180133 @ViewBuilder
181− private var sparkline: some View {
182− if historyValues.count >= 2 {
183− Sparkline(values: historyValues, color: viewModel.balanceColor)
134+ private func sparkline(values: [Int], color: Color) -> some View {
135+ if values.count >= 2 {
136+ Sparkline(values: values, color: color)
184137 .frame(height: 26)
185138 .padding(.top, 4)
186139 .transition(.opacity)
187140 }
188141 }
189142
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−
195143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
196144 /// message, and a tinted rounded background with no border.
197145 private func errorRow(_ message: String) -> some View {
@@ -216,6 +164,56 @@ struct MenuView: View {
216164 .padding(.top, 2)
217165 }
218166
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+
219217 // MARK: - Claude
220218
221219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -227,8 +225,8 @@ struct MenuView: View {
227225 return false
228226 }
229227
230− /// Claude Code's plan limits. The percentage leads, as the balance does above it,
231− /// with the individual windows underneath as quiet supporting detail.
228+ /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229+ /// headline number, bars under every window, a sparkline, and error row.
232230 private var claude: some View {
233231 VStack(alignment: .leading, spacing: 12) {
234232 HStack(spacing: 8) {
@@ -255,23 +253,31 @@ struct MenuView: View {
255253
256254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
257255 VStack(alignment: .leading, spacing: 12) {
258− // The 5-hour window leads as the hero number, since it's the one
259− // most likely to bind and the one shown in the menu bar title.
256+ // The 5-hour window leads as the headline number.
260257 if let fiveHour = usage.fiveHour {
261258 let percent = Int(fiveHour.utilization.rounded())
262− HStack(alignment: .firstTextBaseline, spacing: 8) {
263− Text("🕐 \(percent)%")
264− .font(Self.subheroFont)
265− .foregroundColor(Self.usageColor(percent))
266− .contentTransition(.opacity)
259+ VStack(spacing: 8) {
260+ HStack(alignment: .firstTextBaseline, spacing: 8) {
261+ headlineNumber(
262+ text: "🕐 \(percent)%",
263+ color: Self.usageColor(percent),
264+ trend: viewModel.claudeTrend,
265+ copiedConfirmation: viewModel.copiedClaudeConfirmation,
266+ captionText: "5-hour usage",
267+ copyAction: { viewModel.copyClaudePercentToClipboard() }
268+ )
267269
268− Spacer(minLength: 0)
270+ Spacer(minLength: 0)
269271
270− if let resets = fiveHour.resetsInFormatted {
271− Text("resets in \(resets)")
272− .font(Self.footnoteFont)
273− .foregroundColor(.secondary)
272+ if let resets = fiveHour.resetsInFormatted {
273+ Text("resets in \(resets)")
274+ .font(Self.footnoteFont)
275+ .foregroundColor(.secondary)
276+ }
274277 }
278+
279+ // Bar under the 5-hour window too, same as 7-day windows below.
280+ UsageBar(percent: percent, color: Self.usageColor(percent))
275281 }
276282 }
277283
@@ -285,6 +291,12 @@ struct MenuView: View {
285291 window("7-day Opus", opus)
286292 }
287293 }
294+
295+ // Sparkline for the Claude 5-hour % history.
296+ sparkline(
297+ values: viewModel.claudeSparklineValues,
298+ color: Self.usageColor(claudeFiveHourPercent ?? 0)
299+ )
288300 }
289301 }
290302
@@ -310,6 +322,11 @@ struct MenuView: View {
310322 }
311323 }
312324
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+
313330 /// The balance colors read the other way round here, because for a limit more is
314331 /// worse: green with room to spare, yellow closing in, red nearly spent.
315332 private static func usageColor(_ percent: Int) -> Color {
@@ -329,8 +346,8 @@ struct MenuView: View {
329346 return false
330347 }
331348
332− /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
333− /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
349+ /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350+ /// a subhero headline number, bars under every window, a sparkline, and error row.
334351 private var zai: some View {
335352 VStack(alignment: .leading, spacing: 12) {
336353 HStack(spacing: 8) {
@@ -357,21 +374,30 @@ struct MenuView: View {
357374
358375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
359376 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.
361378 if let fiveHour = usage.fiveHourPercent {
362− HStack(alignment: .firstTextBaseline, spacing: 8) {
363− Text("🤖 \(fiveHour)%")
364− .font(Self.subheroFont)
365− .foregroundColor(Self.usageColor(fiveHour))
366− .contentTransition(.opacity)
379+ VStack(spacing: 8) {
380+ HStack(alignment: .firstTextBaseline, spacing: 8) {
381+ headlineNumber(
382+ text: "🤖 \(fiveHour)%",
383+ color: Self.usageColor(fiveHour),
384+ trend: viewModel.zaiTrend,
385+ copiedConfirmation: viewModel.copiedZaiConfirmation,
386+ captionText: "5-hour usage",
387+ copyAction: { viewModel.copyZaiPercentToClipboard() }
388+ )
367389
368− Spacer(minLength: 0)
390+ Spacer(minLength: 0)
369391
370− if let resets = usage.fiveHourResetsAt {
371− Text("resets in \(Self.zaiResetsIn(from: resets))")
372− .font(Self.footnoteFont)
373− .foregroundColor(.secondary)
392+ if let resets = usage.fiveHourResetsAt {
393+ Text("resets in \(Self.zaiResetsIn(from: resets))")
394+ .font(Self.footnoteFont)
395+ .foregroundColor(.secondary)
396+ }
374397 }
398+
399+ // Bar under the 5-hour window too.
400+ UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
375401 }
376402 }
377403
@@ -380,9 +406,20 @@ struct MenuView: View {
380406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
381407 }
382408 }
409+
410+ // Sparkline for the z.ai 5-hour % history.
411+ sparkline(
412+ values: viewModel.zaiSparklineValues,
413+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
414+ )
383415 }
384416 }
385417
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+
386423 /// One window row for z.ai: label, percentage, and a hairline bar.
387424 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388425 VStack(alignment: .leading, spacing: 5) {
@@ -523,45 +560,10 @@ struct MenuView: View {
523560 .keyboardShortcut("q")
524561 }
525562 .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)
532563 }
533564 }
534565}
535566
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−
565567// MARK: - Usage Bar
566568
567569/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -592,8 +594,8 @@ private struct UsageBar: View {
592594
593595// MARK: - Sparkline
594596
595−/// A minimal line chart of recent balances: 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
597+/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
598+/// takes the metric's color so the trend reads as part of the number above it, with a
597599/// faint fade underneath for weight and a dot marking the latest point.
598600///
599601/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -685,4 +687,4 @@ struct Sparkline: View {
685687 path.closeSubpath()
686688 }
687689 }
688−}
690+}
UsageMenubar/ViewModel.swift+174−22
@@ -10,8 +10,8 @@ extension Notification.Name {
1010 static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
1111}
1212
13−/// The direction the balance has moved over the most recent history points.
14−enum BalanceTrend {
13+/// The direction a metric has moved over the most recent history points.
14+enum MetricTrend {
1515 case increasing
1616 case decreasing
1717 case stable
@@ -29,10 +29,11 @@ enum BalanceTrend {
2929 }
3030}
3131
32−/// A single balance observation.
33−struct BalancePoint: Equatable {
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
34+struct MetricPoint: Equatable {
3435 let date: Date
35− let balance: Int
36+ let value: Int
3637}
3738
3839/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -111,8 +112,20 @@ final class ViewModel: ObservableObject {
111112 /// Brief "✓ Copied" confirmation shown after copying the balance.
112113 @Published var copiedConfirmation: Bool = false
113114
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+
114121 /// 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] = []
116129
117130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
118131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -163,6 +176,12 @@ final class ViewModel: ObservableObject {
163176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
164177 private var savedResetTask: Task<Void, Never>?
165178
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+
166185 // MARK: - History Configuration
167186
168187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -170,6 +189,9 @@ final class ViewModel: ObservableObject {
170189 private static let maxHistoryEntries = 300
171190 private static let historyWindow: TimeInterval = 24 * 60 * 60
172191
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
173195 init(
174196 checker: CreditsChecking = CreditsChecker(),
175197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -290,33 +312,69 @@ final class ViewModel: ObservableObject {
290312 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
291313 }
292314
293− // MARK: - History
315+ // MARK: - History (Hyper)
294316
295− /// The last 24 hours of successful fetches, oldest first.
317+ /// The last 24 hours of successful balance fetches, oldest first.
296318 var balanceHistory: [(date: Date, balance: Int)] {
297− history.map { (date: $0.date, balance: $0.balance) }
319+ history.map { (date: $0.date, balance: $0.value) }
298320 }
299321
300322 /// The direction of travel across the most recent handful of observations.
301323 /// `.unknown` until there are at least two points to compare.
302− var balanceTrend: BalanceTrend {
303− guard history.count >= 2 else { return .unknown }
304− let recent = history.suffix(5)
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 {
361+ guard points.count >= 2 else { return .unknown }
362+ let recent = points.suffix(5)
305363 guard let first = recent.first, let last = recent.last else { return .unknown }
306− if last.balance > first.balance { return .increasing }
307− if last.balance < first.balance { return .decreasing }
364+ if last.value > first.value { return .increasing }
365+ if last.value < first.value { return .decreasing }
308366 return .stable
309367 }
310368
311369 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
312− private func recordHistory(balance: Int, at date: Date) {
313− history.append(BalancePoint(date: date, balance: balance))
370+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
371+ array.append(MetricPoint(date: date, value: value))
314372
315373 let cutoff = date.addingTimeInterval(-Self.historyWindow)
316− history.removeAll { $0.date < cutoff }
374+ array.removeAll { $0.date < cutoff }
317375
318− if history.count > Self.maxHistoryEntries {
319− history.removeFirst(history.count - Self.maxHistoryEntries)
376+ if array.count > Self.maxHistoryEntries {
377+ array.removeFirst(array.count - Self.maxHistoryEntries)
320378 }
321379 }
322380
@@ -391,7 +449,7 @@ final class ViewModel: ObservableObject {
391449 errorMessage = nil
392450 let now = Date()
393451 lastUpdated = now
394− recordHistory(balance: newBalance, at: now)
452+ recordMetric(newBalance, into: &history, at: now)
395453
396454 // Low-balance notification: on threshold crossing (≥10 → <10)
397455 // OR on first successful fetch if already below threshold.
@@ -436,12 +494,30 @@ final class ViewModel: ObservableObject {
436494 claudeUsage = nil
437495 claudePlan = nil
438496 claudeError = nil
497+ claudeHistory.removeAll()
439498
440499 case .usage(let report):
500+ // Capture the previous 5-hour % for threshold-crossing notification.
501+ let previousPercent = claudeFiveHourPercent
441502 claudeUsage = report.usage
442503 claudePlan = report.subscriptionType
443504 claudeError = nil
444505
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+
445521 case .failure(let message):
446522 // As with the balance: keep the last good numbers on screen and say what
447523 // went wrong, rather than blanking the section on a transient failure.
@@ -509,11 +585,29 @@ final class ViewModel: ObservableObject {
509585 case .notConfigured:
510586 zaiUsage = nil
511587 zaiError = nil
588+ zaiHistory.removeAll()
512589
513590 case .usage(let report):
591+ // Capture the previous 5-hour % for threshold-crossing notification.
592+ let previousPercent = zaiFiveHourPercent
514593 zaiUsage = report
515594 zaiError = nil
516595
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+
517611 case .failure(let message):
518612 // As with the balance and Claude: keep the last good numbers on screen
519613 // and say what went wrong, rather than blanking the section on a
@@ -567,7 +661,41 @@ final class ViewModel: ObservableObject {
567661 }
568662 }
569663
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
571699
572700 private func sendLowBalanceNotification(balance: Int) {
573701 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -596,6 +724,29 @@ final class ViewModel: ObservableObject {
596724 }
597725 }
598726
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+
599750 // MARK: - API Key
600751
601752 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -678,6 +829,7 @@ final class ViewModel: ObservableObject {
678829 if key != activeZaiAPIKey {
679830 zaiUsage = nil
680831 zaiError = nil
832+ zaiHistory.removeAll()
681833 }
682834 // Show the trimmed key that was actually stored.
683835 zaiAPIKeyInput = key
@@ -715,4 +867,4 @@ final class ViewModel: ObservableObject {
715867 isInitializingLaunchAtLogin = false
716868 }
717869 }
718−}
870+}