on master help sign in
11 files changed+830−354
since splitsnapshots
unifiedsplit
CLAUDE.md+40−12 README.md+64−48 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+2−2 UsageMenubar/MenuView.swift+225−172 UsageMenubar/UsageMenubarApp.swift+22−7 UsageMenubar/ViewModel.swift+240−43 UsageMenubarTests/CreditsCheckerTests.swift+19 UsageMenubarTests/StatusBarTextTests.swift+190−37 UsageMenubarTests/ZaiUsageClientTests.swift+23−28 project.yml+4−4
CLAUDE.md+40−12
@@ -1,22 +1,50 @@
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`)
1320- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
1421- `VersionFormatter.swift` — Version string formatting
1522
16−The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
17−`📅{percent}%` (Claude 7-day), and `🤖{percent}%` (z.ai 5-hour) — each omitted when
18−absent or at 0%. The three fetches are independent — `ViewModel.refresh()` runs them
19−with `async let`, and any can fail or be unconfigured without touching the others.
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24+`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
25+(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
26+service is not configured (no API key or no credentials). An unconfigured service
27+produces no placeholder in the menu bar. The three fetches are independent —
28+`ViewModel.refresh()` runs them with `async let`, and any can fail or be
29+unconfigured without touching the others.
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`).
2048
2149## Claude credentials (strictly read-only)
2250`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
@@ -47,9 +75,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
4775- Swift 5.9, macOS 14.0 deployment target
4876
4977## Conventions
50−- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
78+- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
5179- No card-heavy/multi-material/gradient-rich designs
52−- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
80+- Rounded font family at one scale (subhero/section/control/caption/footnote)
5381- Monospaced digits for numbers that change
5482- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
5583 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -57,4 +85,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
5785
5886## VCS
5987- jj colocated with git
60−- Push directly to master — no PRs for this repo
88+- Push directly to master — no PRs for this repo
README.md+64−48
@@ -4,24 +4,33 @@
44
55# UsageMenubar
66
7−A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance — and, if you use the Claude Code CLI, your Claude subscription limits alongside it.
7+A macOS menu bar application that displays usage limits and credit balances for multiple LLM services: [Hyper (Charm)](https://hyper.charm.land), [Claude Code](https://claude.com/claude-code), and [z.ai](https://z.ai) Coding Plans. Each service is independent — configure the ones you use, ignore the ones you don't.
88
99## Features
1010
11−- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
12−- 🤖 **Claude Code limits** — your Pro/Max 5-hour and 7-day usage windows in the popover, with the percentage used and a countdown to the next reset. No setup: if you have signed in with the Claude Code CLI, it just appears. If you haven't, the section doesn't
13−- 🔄 **Auto-refresh** — fetches your balance on a configurable interval (1m / 5m / 15m / 30m), and again on wake from sleep
14−- 📊 **Balance sparkline** — a subtle mini-chart in the popover shows your balance trend over recent fetches
15−- 📋 **Click-to-copy** — click the balance number in the popover to copy it to the clipboard
16−- 🖱️ **Right-click menu** — right-click the menu bar icon for quick access to Refresh, Open hyper.charm.land, and Quit
17−- 🔔 **Low-balance notifications** — alerts you when your balance drops below 10 credits, including on the very first fetch if you are already below the threshold
18−- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
19−- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
20−- 🔐 **Secure input** — API key field is masked
11+- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12+- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
14+- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
15+- 🖱️ **Right-click menu** — quick access to Refresh and Quit
16+- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
2117- 🚀 **Launch at login** — optional, using `SMAppService`
22−- 🔑 **Get key link** — quick link to [hyper.charm.land](https://hyper.charm.land) right next to the API key field
2318- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
2419
20+## Menu bar
21+
22+Each service shows an emoji-prefixed segment when configured and above 0%:
23+
24+| Service | Emoji | Example |
25+|---------|-------|---------|
26+| Hyper balance | ⚡ | `⚡42` |
27+| Claude 5-hour | ⁕ | `⁕62%` |
28+| Claude 7-day | ⁕ | `⁕8%` |
29+| z.ai 5-hour | Z | `Z12%` |
30+| z.ai weekly | Z | `Z3%` |
31+
32+Unconfigured services don't appear at all — no placeholder, no icon.
33+
2534## Installation
2635
2736### Homebrew (recommended)
@@ -42,37 +51,47 @@ brew install --cask ilyakooo0/tap/usage-menubar
42511. Go to the [Releases page](https://github.com/ilyakooo0/usage-menubar/releases)
43522. Download the latest `UsageMenubar-*.zip`
44533. Unzip and move `UsageMenubar.app` to `/Applications`
45−4. Launch the app — it will appear in your menu bar as ⚡
54+4. Launch the app — it will appear in your menu bar
4655
4756> **Note:** Releases are **ad-hoc signed** — the CI build applies `codesign -s -` to the app, which gives it a stable code identity but is *not* an Apple Developer ID signature. macOS will still warn you on first launch: right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**. Removing that prompt entirely requires a paid Developer ID certificate and notarization.
4857
49−### Set your API key
58+## Setup
59+
60+Click the menu bar icon to open the popover. Each service is configured independently:
5061
51−1. Click the ⚡ icon in your menu bar
52−2. Enter your Hyper API key (starts with `sk-...`) in the **API Key** field
53−3. Click **Save**
54−4. Your balance will appear immediately
62+### Hyper credits
5563
56−You can get your API key from [hyper.charm.land](https://hyper.charm.land).
64+1. Enter your Hyper API key (starts with `sk-...`) in the **Hyper API Key** field
65+2. Click **Save**
66+3. Your balance appears immediately
5767
58−### Claude Code limits (optional, no setup)
68+Get your key from [hyper.charm.land](https://hyper.charm.land).
5969
60−If you use the [Claude Code CLI](https://claude.com/claude-code), the popover also shows how much of your Pro/Max plan you have used: the limit currently in effect as a headline percentage, the 5-hour and 7-day windows underneath, and how long until the next reset.
70+### Claude Code limits (no setup)
6171
62−There is nothing to configure. The app reads the OAuth credentials Claude Code already stores when you run `claude` and sign in — first from its login-Keychain item, then from `~/.claude/.credentials.json` if the Keychain has nothing. If neither exists, the Claude section simply doesn't appear; it is not treated as an error, and the Hyper balance is unaffected.
72+If you use the [Claude Code CLI](https://claude.com/claude-code), the popover shows how much of your Pro/Max plan you have used: the 5-hour and 7-day windows, and how long until the next reset.
6373
64−Two things worth knowing:
74+There is nothing to configure. The app reads the OAuth credentials Claude Code already stores when you run `claude` and sign in — first from its login-Keychain item, then from `~/.claude/.credentials.json` if the Keychain has nothing. If neither exists, the Claude section doesn't appear.
6575
6676- **macOS will ask once.** Those credentials belong to Claude Code, not to this app, so the first read prompts you to allow access to the `Claude Code-credentials` Keychain item. Choose **Always Allow** and you won't be asked again.
67−- **The credentials are strictly read-only.** The app reads the access token Claude Code stored and spends it as-is. It never refreshes it, and never writes those credentials back — that is the CLI's job alone. (Refreshing rotates the refresh token, so an app doing it behind the CLI's back would race it and sign you out of your own terminal.) If the token has expired, the Claude section says so and asks you to run `claude`; once the CLI has refreshed on its own, the app picks the new token up on its next fetch.
77+- **The credentials are strictly read-only.** The app reads the access token Claude Code stored and spends it as-is. It never refreshes it, and never writes those credentials back — that is the CLI's job alone. If the token has expired, the Claude section says so and asks you to run `claude`.
78+
79+### z.ai Coding Plan
80+
81+1. Enter your z.ai API key in the **z.ai API Key** field
82+2. Click **Save**
83+3. Your 5-hour and weekly quota percentages appear immediately
84+
85+Get your key from [z.ai/manage-apikey](https://z.ai/manage-apikey/coding-plan/personal/my-plan).
6886
6987## Settings
7088
71−Everything is configured from the popover — click the ⚡ icon in the menu bar.
89+Everything is configured from the popover — click the menu bar icon.
7290
7391| Setting | Where | Notes |
7492| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
75−| **API key** | **API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
93+| **Hyper API key** | **Hyper API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
94+| **z.ai API key** | **z.ai API Key** field → **Save** | Stored separately from the Hyper key. |
7695| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
7796| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
7897| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
@@ -138,18 +157,21 @@ usage-menubar/
138157├── project.yml # xcodegen project definition
139158├── UsageMenubar/
140159│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
141−│ ├── ViewModel.swift # Observable state: balance, Claude usage, refresh, notifications
160+│ ├── ViewModel.swift # Observable state: balance, Claude usage, z.ai usage, refresh
142161│ ├── MenuView.swift # SwiftUI popover view
143−│ ├── CreditsChecker.swift # API client (GET /v1/credits)
162+│ ├── CreditsChecker.swift # Hyper API client (GET /v1/credits)
144163│ ├── ClaudeUsageClient.swift # Claude OAuth client (GET /api/oauth/usage) + models
145−│ ├── KeychainHelper.swift # Keychain wrapper for API key
164+│ ├── ZaiUsageClient.swift # z.ai API client (GET /monitor/usage/quota/limit)
165+│ ├── KeychainHelper.swift # Keychain wrapper (Hyper + z.ai keys)
146166│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
147167│ ├── Info.plist # LSUIElement=true (agent app)
148−│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
168+│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
149169│ └── Assets.xcassets/ # App icon, accent color
150170├── UsageMenubarTests/
151−│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
171+│ ├── CreditsCheckerTests.swift # Hyper API, retry, keychain, StubURLProtocol
152172│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
173+│ ├── ZaiUsageClientTests.swift # z.ai API, retry, decoding
174+│ ├── StatusBarTextTests.swift # Menu bar title formatting
153175│ └── VersionFormatterTests.swift # Version formatting tests
154176├── .github/workflows/
155177│ └── release.yml # Build + release on push to master
@@ -160,24 +182,18 @@ usage-menubar/
160182
161183## How it works
162184
163−1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`. Left-click toggles the popover; right-click opens a context menu (Refresh, Open hyper.charm.land, Quit).
164−2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with the balance, sparkline, API key field, and settings.
165−3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures (5xx, timeouts) with exponential backoff, and decodes `{"balance": <int>}` (tolerating fractional values by rounding).
166−4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework. `save()` returns a `Bool` indicating success.
167−5. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m) to refresh the balance; the app also refreshes on wake from sleep. Changing the interval in the popover restarts the timer immediately.
168−6. **State**: `ViewModel` owns the balance, loading/error state, balance history (for the sparkline), refresh task (cancellable to prevent races), and the refresh interval, and is shared between the menu bar item and the popover.
169−7. **Notifications**: When the balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
170−8. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
171−9. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. It is strictly read-only — it never refreshes the token and never writes credentials back. The token endpoint rotates the refresh token, so an app that also refreshed would race the CLI and sign the user out of their own terminal. When the token is spent (401/403), the app says so and points at `claude`; the next fetch (every 1–5 min, credentials re-read each time) picks up whatever the CLI refreshed on its own. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
172−10. **Independent fetches**: `ViewModel.refresh()` runs the Hyper and Claude requests concurrently with `async let`. Neither can fail, stall, or be unconfigured in a way that affects the other.
185+1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button. Left-click toggles the popover; right-click opens a context menu (Refresh, Quit).
186+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with each configured service's data and the settings.
187+3. **Independent fetches**: `ViewModel.refresh()` runs the Hyper, Claude, and z.ai requests concurrently with `async let`. Any can fail, stall, or be unconfigured without affecting the others.
188+4. **Hyper API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures with exponential backoff, and decodes `{"balance": <int>}`.
189+5. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage`. It is strictly read-only — it never refreshes the token and never writes credentials back. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
190+6. **z.ai usage**: `ZaiUsageClient` fetches `GET https://api.z.ai/api/monitor/usage/quota/limit` and `GET https://api.z.ai/api/biz/subscription/list` concurrently with an API key the user enters in settings. Same retry pattern as the other clients.
191+7. **Keychain**: API keys are stored/retrieved via `KeychainHelper` using the macOS Security framework. Hyper and z.ai keys are stored separately.
192+8. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m); the app also refreshes on wake from sleep.
193+9. **Notifications**: When the Hyper balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
194+10. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
17319511. **No sandbox**: Reading credentials that belong to another app means spawning `/usr/bin/security` and reading a file in the user's home directory, neither of which the App Sandbox permits. The app is distributed ad-hoc signed via Homebrew rather than through the App Store, where the sandbox would be mandatory.
174196
175−## Credits
176−
177−- **Author**: Ilya Koo ([@ilyakooo0](https://github.com/ilyakooo0))
178−- **API**: [Hyper by Charm](https://hyper.charm.land)
179−- **Built with**: Swift 5.9, SwiftUI, macOS 14+
180−
181197## License
182198
183−MIT — see [LICENSE](LICENSE).
199+MIT — see [LICENSE](LICENSE).
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.usage-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "com.ilyakooo0.usage-menubar"
85+ private static let service = "soy.iko.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112112/// Uses the same keychain service as the Hyper key but a different account, so the
113113/// two keys coexist without clobbering each other.
114114enum ZaiKeychainHelper {
115− private static let service = "com.ilyakooo0.usage-menubar"
115+ private static let service = "soy.iko.usage-menubar"
116116 private static let account = "zai-api-key"
117117
118118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+225−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())
157− }
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− }
128+ .font(Self.captionFont)
176129 }
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: "Z \\(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,35 @@ struct MenuView: View {
380406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
381407 }
382408 }
409+
410+ // Peak hours notice.
411+ zaiPeakHours
412+
413+ // Sparkline for the z.ai 5-hour % history, with an orange tint
414+ // when currently in peak hours.
415+ if viewModel.zaiSparklineValues.count >= 2 {
416+ Sparkline(
417+ values: viewModel.zaiSparklineValues,
418+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
419+ )
420+ .frame(height: 26)
421+ .padding(.top, 4)
422+ .transition(.opacity)
423+ .background(
424+ ViewModel.zaiInPeakHours
425+ ? RoundedRectangle(cornerRadius: 4, style: .continuous)
426+ .fill(Color.orange.opacity(0.08))
427+ : nil
428+ )
429+ }
383430 }
384431 }
385432
433+ /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
434+ private var zaiFiveHourPercent: Int? {
435+ viewModel.zaiFiveHourPercent
436+ }
437+
386438 /// One window row for z.ai: label, percentage, and a hairline bar.
387439 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388440 VStack(alignment: .leading, spacing: 5) {
@@ -402,6 +454,42 @@ struct MenuView: View {
402454 }
403455 }
404456
457+ /// z.ai peak hours: 14:00–18:00 CST (UTC+8), i.e. 06:00–10:00 UTC.
458+ /// Limits are most likely to bind during this window. Displayed in the
459+ /// user's local timezone so they know when to expect slowdowns.
460+ private var zaiPeakHours: some View {
461+ let inPeak = ViewModel.zaiInPeakHours
462+
463+ return HStack(spacing: 6) {
464+ Circle()
465+ .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
466+ .frame(width: 6, height: 6)
467+
468+ Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
469+ .font(Self.footnoteFont)
470+ .foregroundColor(inPeak ? .orange : .secondary)
471+ }
472+ }
473+
474+ /// z.ai peak hours converted to the user's local timezone, e.g. "09:00–13:00".
475+ private static var zaiPeakHoursLocal: String {
476+ let formatter = DateFormatter()
477+ formatter.timeZone = TimeZone.current
478+ formatter.dateFormat = "HH:mm"
479+
480+ var utc = DateComponents()
481+ utc.timeZone = TimeZone(identifier: "UTC")
482+ utc.hour = 6
483+ var calendar = Calendar(identifier: .gregorian)
484+ calendar.timeZone = TimeZone(identifier: "UTC")!
485+ let start = calendar.date(from: utc)!
486+
487+ utc.hour = 10
488+ let end = calendar.date(from: utc)!
489+
490+ return "\(formatter.string(from: start))–\(formatter.string(from: end))"
491+ }
492+
405493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
406494 private static func zaiResetsIn(from date: Date) -> String {
407495 let interval = date.timeIntervalSince(Date())
@@ -523,45 +611,10 @@ struct MenuView: View {
523611 .keyboardShortcut("q")
524612 }
525613 .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)
532614 }
533615 }
534616}
535617
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−
565618// MARK: - Usage Bar
566619
567620/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -592,8 +645,8 @@ private struct UsageBar: View {
592645
593646// MARK: - Sparkline
594647
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
648+/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
649+/// takes the metric's color so the trend reads as part of the number above it, with a
597650/// faint fade underneath for weight and a dot marking the latest point.
598651///
599652/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -685,4 +738,4 @@ struct Sparkline: View {
685738 path.closeSubpath()
686739 }
687740 }
688−}
741+}
UsageMenubar/UsageMenubarApp.swift+22−7
@@ -35,7 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
3535
3636 if let button = statusItem.button {
3737 button.image = nil
38− button.title = "⚡?"
38+ button.title = "…"
3939 button.target = self
4040 button.action = #selector(statusItemClicked(_:))
4141 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -46,8 +46,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4646 popover.behavior = .transient
4747 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4848
49− // Observe published changes (balance + isLoading + Claude usage + z.ai usage) to
50− // update the menu bar title. CombineLatest fires when any of them changes.
49+ // Observe published changes (balance + isLoading + hyperConfigured +
50+ // Claude usage + z.ai usage) to update the menu bar title. CombineLatest
51+ // fires when any of them changes.
5152 //
5253 // The title is built from the values the publisher hands us, not by reading the
5354 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -56,13 +57,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5657 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
5758 // whose Combine scheduler only runs in the default run loop mode — the title
5859 // would freeze while a menu or a scroll was tracking.
60+ // Combine has CombineLatest4 but not 5, so nest: outer combines
61+ // balance + isLoading + hyperConfigured with an inner that merges
62+ // claudeUsage + zaiUsage. The inner Just(()) is a dummy third stream
63+ // to satisfy CombineLatest3 — it fires once and never again, so the
64+ // inner effectively republishes claudeUsage and zaiUsage changes.
5965 statusTextCancellable = Publishers.CombineLatest4(
6066 viewModel.$balance,
6167 viewModel.$isLoading,
62− viewModel.$claudeUsage,
63− viewModel.$zaiUsage
68+ viewModel.$hyperConfigured,
69+ Publishers.CombineLatest3(
70+ viewModel.$claudeUsage,
71+ viewModel.$zaiUsage,
72+ Just<Void>(())
73+ )
6474 )
65− .map { balance, isLoading, claudeUsage, zaiUsage in
75+ .map { balance, isLoading, hyperConfigured, claudeAndZai in
76+ let (claudeUsage, zaiUsage, _) = claudeAndZai
6677 let fiveHour = claudeUsage?.fiveHour
6778 .map { Int($0.utilization.rounded()) }
6879 let sevenDay = claudeUsage?.sevenDay
@@ -70,12 +81,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
7081 ?? claudeUsage?.sevenDayOpus
7182 .map { Int($0.utilization.rounded()) }
7283 let zaiFiveHour = zaiUsage?.fiveHourPercent
84+ let zaiWeekly = zaiUsage?.weeklyPercent
7385 return ViewModel.statusBarText(
7486 balance: balance,
7587 isLoading: isLoading,
88+ hyperConfigured: hyperConfigured,
7689 claudeFiveHourPercent: fiveHour,
7790 claudeSevenDayPercent: sevenDay,
78− zaiFiveHourPercent: zaiFiveHour
91+ zaiFiveHourPercent: zaiFiveHour,
92+ zaiWeeklyPercent: zaiWeekly,
93+ zaiInPeakHours: ViewModel.zaiInPeakHours
7994 )
8095 }
8196 .removeDuplicates()
UsageMenubar/ViewModel.swift+240−43
@@ -7,11 +7,11 @@ import UserNotifications
77/// Posted when the user changes the refresh interval, so the app delegate can
88/// restart its timer with the new period.
99extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.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
@@ -93,6 +94,11 @@ final class ViewModel: ObservableObject {
9394 /// `activeZaiAPIKey`.
9495 @Published var zaiAPIKeyInput: String = ""
9596
97+ /// Whether Hyper is configured — i.e. there is an API key in the Keychain.
98+ /// Drives whether the `⚡` segment appears in the menu bar at all: an
99+ /// unconfigured service should not take up space in the title.
100+ @Published private(set) var hyperConfigured = false
101+
96102 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
97103 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
98104 @Published var apiKeyInput: String = ""
@@ -106,8 +112,20 @@ final class ViewModel: ObservableObject {
106112 /// Brief "✓ Copied" confirmation shown after copying the balance.
107113 @Published var copiedConfirmation: Bool = false
108114
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+
109121 /// Successful balance observations from the last 24 hours, oldest first.
110− @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] = []
111129
112130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
113131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -158,6 +176,12 @@ final class ViewModel: ObservableObject {
158176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
159177 private var savedResetTask: Task<Void, Never>?
160178
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+
161185 // MARK: - History Configuration
162186
163187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -165,6 +189,9 @@ final class ViewModel: ObservableObject {
165189 private static let maxHistoryEntries = 300
166190 private static let historyWindow: TimeInterval = 24 * 60 * 60
167191
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
168195 init(
169196 checker: CreditsChecking = CreditsChecker(),
170197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -178,6 +205,7 @@ final class ViewModel: ObservableObject {
178205 let storedKey = KeychainHelper.load() ?? ""
179206 apiKeyInput = storedKey
180207 activeAPIKey = storedKey
208+ hyperConfigured = !storedKey.isEmpty
181209 let storedZaiKey = ZaiKeychainHelper.load() ?? ""
182210 zaiAPIKeyInput = storedZaiKey
183211 activeZaiAPIKey = storedZaiKey
@@ -209,12 +237,13 @@ final class ViewModel: ObservableObject {
209237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
210238 }
211239
212− /// The text to show in the menu bar: `⚡{balance}` followed by Claude usage
213− /// windows, each prefixed with an emoji so they are distinguishable at a glance:
214− /// 🕐 for the 5-hour window, 📅 for the 7-day window, 🤖 for the z.ai 5-hour
215− /// window. Windows that are absent or at 0% are omitted; with neither, the bolt
216− /// stands in alone: `⚡…` while the first fetch is in flight, `⚡?` once it has
217− /// come back empty-handed.
240+ /// The text to show in the menu bar. Each configured provider contributes a
241+ /// segment prefixed with a provider emoji; segments are separated by ` · `
242+ /// (middle dot). Within a segment, windows are listed as bare percentages
243+ /// separated by spaces. An unconfigured service produces no segment.
244+ /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245+ ///
246+ /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
218247 ///
219248 /// Takes its values as parameters rather than reading the properties: `@Published`
220249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -222,27 +251,54 @@ final class ViewModel: ObservableObject {
222251 static func statusBarText(
223252 balance: Int?,
224253 isLoading: Bool,
254+ hyperConfigured: Bool,
225255 claudeFiveHourPercent: Int?,
226256 claudeSevenDayPercent: Int?,
227− zaiFiveHourPercent: Int?
257+ zaiFiveHourPercent: Int?,
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
228260 ) -> String {
229− var parts: [String] = []
230− if let balance = balance {
231− parts.append("⚡\(formatBalance(balance))")
261+ var segments: [String] = []
262+
263+ // Hyper
264+ if hyperConfigured, let balance = balance {
265+ segments.append("⚡\(formatBalance(balance))")
232266 }
233− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
234− parts.append("🕐\(fiveHour)%")
267+
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
270+ if let fiveHour = claudeFiveHourPercent {
271+ claudeWindows.append("\(fiveHour)%")
272+ }
273+ if let sevenDay = claudeSevenDayPercent {
274+ claudeWindows.append("\(sevenDay)%")
235275 }
236− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
237− parts.append("📅\(sevenDay)%")
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
238278 }
239− if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
240− parts.append("🤖\(zaiFiveHour)%")
279+
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
241284 }
242− if parts.isEmpty {
243− return isLoading ? "⚡…" : "⚡?"
285+ if let zaiWeekly = zaiWeeklyPercent {
286+ zaiWindows.append("\(zaiWeekly)%")
287+ }
288+ if !zaiWindows.isEmpty {
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "🔶"
292+ }
293+ segments.append(segment)
244294 }
245− return parts.joined(separator: " · ")
295+
296+ if segments.isEmpty {
297+ // No service is configured or has anything to show. While loading,
298+ // show an ellipsis; otherwise the title is empty.
299+ return isLoading ? "…" : ""
300+ }
301+ return segments.joined(separator: " · ")
246302 }
247303
248304 /// The text to show in the menu bar for the current state.
@@ -250,12 +306,21 @@ final class ViewModel: ObservableObject {
250306 Self.statusBarText(
251307 balance: balance,
252308 isLoading: isLoading,
309+ hyperConfigured: hyperConfigured,
253310 claudeFiveHourPercent: claudeFiveHourPercent,
254311 claudeSevenDayPercent: claudeSevenDayPercent,
255− zaiFiveHourPercent: zaiFiveHourPercent
312+ zaiFiveHourPercent: zaiFiveHourPercent,
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
256315 )
257316 }
258317
318+ /// Whether z.ai is currently in peak hours (06:00–10:00 UTC).
319+ static var zaiInPeakHours: Bool {
320+ let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
321+ return hour >= 6 && hour < 10
322+ }
323+
259324 /// The formatted balance string for display in the popover.
260325 var formattedBalance: String {
261326 guard let balance = balance else { return "?" }
@@ -276,33 +341,69 @@ final class ViewModel: ObservableObject {
276341 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
277342 }
278343
279− // MARK: - History
344+ // MARK: - History (Hyper)
280345
281− /// The last 24 hours of successful fetches, oldest first.
346+ /// The last 24 hours of successful balance fetches, oldest first.
282347 var balanceHistory: [(date: Date, balance: Int)] {
283− history.map { (date: $0.date, balance: $0.balance) }
348+ history.map { (date: $0.date, balance: $0.value) }
284349 }
285350
286351 /// The direction of travel across the most recent handful of observations.
287352 /// `.unknown` until there are at least two points to compare.
288− var balanceTrend: BalanceTrend {
289− guard history.count >= 2 else { return .unknown }
290− let recent = history.suffix(5)
353+ var balanceTrend: MetricTrend {
354+ metricTrend(history)
355+ }
356+
357+ /// The sparkline's data for the Hyper balance.
358+ var balanceSparklineValues: [Int] {
359+ history.map { $0.value }
360+ }
361+
362+ // MARK: - History (Claude)
363+
364+ /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365+ var claudeSparklineValues: [Int] {
366+ claudeHistory.map { $0.value }
367+ }
368+
369+ /// The direction of travel for the Claude 5-hour %.
370+ var claudeTrend: MetricTrend {
371+ metricTrend(claudeHistory)
372+ }
373+
374+ // MARK: - History (z.ai)
375+
376+ /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377+ var zaiSparklineValues: [Int] {
378+ zaiHistory.map { $0.value }
379+ }
380+
381+ /// The direction of travel for the z.ai 5-hour %.
382+ var zaiTrend: MetricTrend {
383+ metricTrend(zaiHistory)
384+ }
385+
386+ // MARK: - History Helpers
387+
388+ /// Computes the trend from a generic history array.
389+ private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
390+ guard points.count >= 2 else { return .unknown }
391+ let recent = points.suffix(5)
291392 guard let first = recent.first, let last = recent.last else { return .unknown }
292− if last.balance > first.balance { return .increasing }
293− if last.balance < first.balance { return .decreasing }
393+ if last.value > first.value { return .increasing }
394+ if last.value < first.value { return .decreasing }
294395 return .stable
295396 }
296397
297398 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
298− private func recordHistory(balance: Int, at date: Date) {
299− history.append(BalancePoint(date: date, balance: balance))
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400+ array.append(MetricPoint(date: date, value: value))
300401
301402 let cutoff = date.addingTimeInterval(-Self.historyWindow)
302− history.removeAll { $0.date < cutoff }
403+ array.removeAll { $0.date < cutoff }
303404
304− if history.count > Self.maxHistoryEntries {
305− history.removeFirst(history.count - Self.maxHistoryEntries)
405+ if array.count > Self.maxHistoryEntries {
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
306407 }
307408 }
308409
@@ -377,7 +478,7 @@ final class ViewModel: ObservableObject {
377478 errorMessage = nil
378479 let now = Date()
379480 lastUpdated = now
380− recordHistory(balance: newBalance, at: now)
481+ recordMetric(newBalance, into: &history, at: now)
381482
382483 // Low-balance notification: on threshold crossing (≥10 → <10)
383484 // OR on first successful fetch if already below threshold.
@@ -422,12 +523,30 @@ final class ViewModel: ObservableObject {
422523 claudeUsage = nil
423524 claudePlan = nil
424525 claudeError = nil
526+ claudeHistory.removeAll()
425527
426528 case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
427531 claudeUsage = report.usage
428532 claudePlan = report.subscriptionType
429533 claudeError = nil
430534
535+ // Record history and fire high-usage notification based on the 5-hour %.
536+ if let percent = claudeFiveHourPercent {
537+ let now = Date()
538+ recordMetric(percent, into: &claudeHistory, at: now)
539+
540+ // High-usage notification: on threshold crossing (<90 → ≥90)
541+ // OR on first successful fetch if already above threshold.
542+ if let previous = previousPercent, previous < Self.highUsageThreshold,
543+ percent >= Self.highUsageThreshold {
544+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
545+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
546+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
547+ }
548+ }
549+
431550 case .failure(let message):
432551 // As with the balance: keep the last good numbers on screen and say what
433552 // went wrong, rather than blanking the section on a transient failure.
@@ -495,11 +614,29 @@ final class ViewModel: ObservableObject {
495614 case .notConfigured:
496615 zaiUsage = nil
497616 zaiError = nil
617+ zaiHistory.removeAll()
498618
499619 case .usage(let report):
620+ // Capture the previous 5-hour % for threshold-crossing notification.
621+ let previousPercent = zaiFiveHourPercent
500622 zaiUsage = report
501623 zaiError = nil
502624
625+ // Record history and fire high-usage notification based on the 5-hour %.
626+ if let percent = zaiFiveHourPercent {
627+ let now = Date()
628+ recordMetric(percent, into: &zaiHistory, at: now)
629+
630+ // High-usage notification: on threshold crossing (<90 → ≥90)
631+ // OR on first successful fetch if already above threshold.
632+ if let previous = previousPercent, previous < Self.highUsageThreshold,
633+ percent >= Self.highUsageThreshold {
634+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
635+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
636+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
637+ }
638+ }
639+
503640 case .failure(let message):
504641 // As with the balance and Claude: keep the last good numbers on screen
505642 // and say what went wrong, rather than blanking the section on a
@@ -553,7 +690,41 @@ final class ViewModel: ObservableObject {
553690 }
554691 }
555692
556− // MARK: - Low Balance Notification
693+ /// Copies the current Claude 5-hour percentage to the clipboard.
694+ func copyClaudePercentToClipboard() {
695+ guard let percent = claudeFiveHourPercent else { return }
696+
697+ let pasteboard = NSPasteboard.general
698+ pasteboard.clearContents()
699+ pasteboard.setString("\(percent)%", forType: .string)
700+
701+ copiedClaudeConfirmation = true
702+ copiedClaudeResetTask?.cancel()
703+ copiedClaudeResetTask = Task { @MainActor in
704+ try? await Task.sleep(nanoseconds: 1_500_000_000)
705+ guard !Task.isCancelled else { return }
706+ copiedClaudeConfirmation = false
707+ }
708+ }
709+
710+ /// Copies the current z.ai 5-hour percentage to the clipboard.
711+ func copyZaiPercentToClipboard() {
712+ guard let percent = zaiFiveHourPercent else { return }
713+
714+ let pasteboard = NSPasteboard.general
715+ pasteboard.clearContents()
716+ pasteboard.setString("\(percent)%", forType: .string)
717+
718+ copiedZaiConfirmation = true
719+ copiedZaiResetTask?.cancel()
720+ copiedZaiResetTask = Task { @MainActor in
721+ try? await Task.sleep(nanoseconds: 1_500_000_000)
722+ guard !Task.isCancelled else { return }
723+ copiedZaiConfirmation = false
724+ }
725+ }
726+
727+ // MARK: - Notifications
557728
558729 private func sendLowBalanceNotification(balance: Int) {
559730 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -582,6 +753,29 @@ final class ViewModel: ObservableObject {
582753 }
583754 }
584755
756+ /// Sends a notification when a usage percentage crosses the high-usage threshold.
757+ private func sendHighUsageNotification(provider: String, percent: Int) {
758+ let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
759+
760+ let center = UNUserNotificationCenter.current()
761+ center.getNotificationSettings { settings in
762+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
763+ return
764+ }
765+ let content = UNMutableNotificationContent()
766+ content.title = "High Usage Menubar"
767+ content.body = body
768+ content.sound = .default
769+
770+ let request = UNNotificationRequest(
771+ identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
772+ content: content,
773+ trigger: nil
774+ )
775+ center.add(request)
776+ }
777+ }
778+
585779 // MARK: - API Key
586780
587781 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -596,6 +790,7 @@ final class ViewModel: ObservableObject {
596790 }
597791 apiKeyInput = ""
598792 activeAPIKey = ""
793+ hyperConfigured = false
599794 // refresh() with an empty key clears balance, error, history, and
600795 // cancels any in-flight task from the previous key.
601796 refresh()
@@ -619,6 +814,7 @@ final class ViewModel: ObservableObject {
619814 // Show the trimmed key that was actually stored.
620815 apiKeyInput = key
621816 activeAPIKey = key
817+ hyperConfigured = true
622818 refresh()
623819 }
624820
@@ -662,6 +858,7 @@ final class ViewModel: ObservableObject {
662858 if key != activeZaiAPIKey {
663859 zaiUsage = nil
664860 zaiError = nil
861+ zaiHistory.removeAll()
665862 }
666863 // Show the trimmed key that was actually stored.
667864 zaiAPIKeyInput = key
@@ -699,4 +896,4 @@ final class ViewModel: ObservableObject {
699896 isInitializingLaunchAtLogin = false
700897 }
701898 }
702−}
899+}
UsageMenubarTests/CreditsCheckerTests.swift+19
@@ -519,10 +519,22 @@ final class StubURLProtocol: URLProtocol {
519519 Self.outcomes.append(contentsOf: outcomes)
520520 }
521521
522+ /// Queues an outcome for a specific URL path. URL-matched outcomes are consumed
523+ /// before the FIFO queue, so concurrent requests to different endpoints each get
524+ /// the right response regardless of arrival order.
525+ static func enqueue(_ outcome: Outcome, forURLPath path: String) {
526+ lock.lock()
527+ defer { lock.unlock() }
528+ urlMatchedOutcomes[path] = outcome
529+ }
530+
531+ private static var urlMatchedOutcomes: [String: Outcome] = [:]
532+
522533 static func reset() {
523534 lock.lock()
524535 defer { lock.unlock() }
525536 outcomes = []
537+ urlMatchedOutcomes = [:]
526538 lastOutcome = nil
527539 requests = []
528540 bodies = []
@@ -548,12 +560,19 @@ final class StubURLProtocol: URLProtocol {
548560 }
549561
550562 /// Records the request and pops the outcome that should answer it.
563+ /// URL-matched outcomes take priority over the FIFO queue, so concurrent
564+ /// requests to different endpoints each get the right response.
551565 private static func nextOutcome(for request: URLRequest) -> Outcome {
552566 lock.lock()
553567 defer { lock.unlock() }
554568 requests.append(request)
555569 bodies.append(drainBody(from: request))
556570
571+ if let path = request.url?.path,
572+ let matched = urlMatchedOutcomes[path] {
573+ return matched
574+ }
575+
557576 if !outcomes.isEmpty {
558577 lastOutcome = outcomes.removeFirst()
559578 }
UsageMenubarTests/StatusBarTextTests.swift+190−37
@@ -13,17 +13,21 @@ final class StatusBarTextTests: XCTestCase {
1313 func testBothServices() {
1414 let title = ViewModel.statusBarText(
1515 balance: 42, isLoading: false,
16+ hyperConfigured: true,
1617 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
17− zaiFiveHourPercent: nil
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19+ zaiInPeakHours: false
1820 )
19− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
21+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
2022 }
2123
2224 func testHyperOnly() {
2325 let title = ViewModel.statusBarText(
2426 balance: 42, isLoading: false,
27+ hyperConfigured: true,
2528 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
26− zaiFiveHourPercent: nil
29+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30+ zaiInPeakHours: false
2731 )
2832 XCTAssertEqual(title, "⚡42")
2933 }
@@ -31,85 +35,140 @@ final class StatusBarTextTests: XCTestCase {
3135 func testClaudeOnly() {
3236 let title = ViewModel.statusBarText(
3337 balance: nil, isLoading: false,
38+ hyperConfigured: false,
3439 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
35− zaiFiveHourPercent: nil
40+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41+ zaiInPeakHours: false
3642 )
37− XCTAssertEqual(title, "🕐62% · 📅8%")
43+ XCTAssertEqual(title, "\u{2055}62% 8%")
3844 }
3945
4046 func testLoadingWithNothingToShow() {
4147 let title = ViewModel.statusBarText(
4248 balance: nil, isLoading: true,
49+ hyperConfigured: false,
4350 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
44− zaiFiveHourPercent: nil
51+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52+ zaiInPeakHours: false
4553 )
46− XCTAssertEqual(title, "⚡…")
54+ XCTAssertEqual(title, "…")
4755 }
4856
4957 func testNothingToShow() {
5058 let title = ViewModel.statusBarText(
5159 balance: nil, isLoading: false,
60+ hyperConfigured: false,
5261 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
53− zaiFiveHourPercent: nil
62+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63+ zaiInPeakHours: false
5464 )
55− XCTAssertEqual(title, "⚡?")
65+ XCTAssertEqual(title, "")
5666 }
5767
5868 /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
59− /// without a Hyper key refreshes every few minutes, and a title that fell back to `⚡…`
69+ /// without a Hyper key refreshes every few minutes, and a title that fell back to `…`
6070 /// each time would blink their percentage away and back.
6171 func testLoadingKeepsAClaudePercentOnScreen() {
6272 let title = ViewModel.statusBarText(
6373 balance: nil, isLoading: true,
74+ hyperConfigured: false,
6475 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
65− zaiFiveHourPercent: nil
76+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77+ zaiInPeakHours: false
6678 )
67− XCTAssertEqual(title, "🕐62%")
79+ XCTAssertEqual(title, "\u{2055}62%")
6880 }
6981
7082 func testLoadingKeepsAStaleBalanceOnScreen() {
7183 let title = ViewModel.statusBarText(
7284 balance: 42, isLoading: true,
85+ hyperConfigured: true,
7386 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
74− zaiFiveHourPercent: nil
87+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88+ zaiInPeakHours: false
7589 )
76− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
90+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
7791 }
7892
79− func testZeroPercentIsShownRatherThanOmitted() {
93+ func testZeroPercentIsShown() {
8094 let title = ViewModel.statusBarText(
8195 balance: 42, isLoading: false,
96+ hyperConfigured: true,
8297 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
83− zaiFiveHourPercent: nil
98+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99+ zaiInPeakHours: false
84100 )
85− XCTAssertEqual(title, "⚡42 · 📅8%")
101+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
86102 }
87103
88104 func testBothClaudeWindowsZero() {
89105 let title = ViewModel.statusBarText(
90106 balance: 42, isLoading: false,
107+ hyperConfigured: true,
91108 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
92− zaiFiveHourPercent: nil
109+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110+ zaiInPeakHours: false
93111 )
94− XCTAssertEqual(title, "⚡42")
112+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
95113 }
96114
97115 func testFiveHourOnly() {
98116 let title = ViewModel.statusBarText(
99117 balance: nil, isLoading: false,
118+ hyperConfigured: false,
100119 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
101− zaiFiveHourPercent: nil
120+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121+ zaiInPeakHours: false
102122 )
103− XCTAssertEqual(title, "🕐62%")
123+ XCTAssertEqual(title, "\u{2055}62%")
104124 }
105125
106126 func testSevenDayOnly() {
107127 let title = ViewModel.statusBarText(
108128 balance: nil, isLoading: false,
129+ hyperConfigured: false,
109130 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
110− zaiFiveHourPercent: nil
131+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132+ zaiInPeakHours: false
111133 )
112− XCTAssertEqual(title, "📅8%")
134+ XCTAssertEqual(title, "\u{2055}8%")
135+ }
136+
137+ // MARK: - Hyper not configured
138+
139+ func testHyperNotConfiguredHidesBoltEvenWithBalance() {
140+ // balance is non-nil but Hyper is not configured — should not appear.
141+ // This can happen briefly during the transition when a key is deleted.
142+ let title = ViewModel.statusBarText(
143+ balance: 42, isLoading: false,
144+ hyperConfigured: false,
145+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
146+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147+ zaiInPeakHours: false
148+ )
149+ XCTAssertEqual(title, "")
150+ }
151+
152+ func testHyperNotConfiguredLoadingShowsJustDots() {
153+ let title = ViewModel.statusBarText(
154+ balance: nil, isLoading: true,
155+ hyperConfigured: false,
156+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158+ zaiInPeakHours: false
159+ )
160+ XCTAssertEqual(title, "…")
161+ }
162+
163+ func testHyperNotConfiguredButClaudeStillShows() {
164+ let title = ViewModel.statusBarText(
165+ balance: nil, isLoading: false,
166+ hyperConfigured: false,
167+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
168+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169+ zaiInPeakHours: false
170+ )
171+ XCTAssertEqual(title, "\u{2055}62% 8%")
113172 }
114173
115174 // MARK: - z.ai in Title
@@ -117,55 +176,149 @@ final class StatusBarTextTests: XCTestCase {
117176 func testZaiOnly() {
118177 let title = ViewModel.statusBarText(
119178 balance: nil, isLoading: false,
179+ hyperConfigured: false,
120180 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
121− zaiFiveHourPercent: 12
181+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182+ zaiInPeakHours: false
122183 )
123− XCTAssertEqual(title, "🤖12%")
184+ XCTAssertEqual(title, "\u{005A}12%")
124185 }
125186
126187 func testZaiWithHyper() {
127188 let title = ViewModel.statusBarText(
128189 balance: 42, isLoading: false,
190+ hyperConfigured: true,
129191 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
130− zaiFiveHourPercent: 12
192+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193+ zaiInPeakHours: false
131194 )
132− XCTAssertEqual(title, "⚡42 · 🤖12%")
195+ XCTAssertEqual(title, "⚡42 · \u{005A}12%")
133196 }
134197
135198 func testZaiWithClaude() {
136199 let title = ViewModel.statusBarText(
137200 balance: nil, isLoading: false,
201+ hyperConfigured: false,
138202 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
139− zaiFiveHourPercent: 12
203+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204+ zaiInPeakHours: false
140205 )
141− XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
206+ XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
142207 }
143208
144209 func testAllThreeServices() {
145210 let title = ViewModel.statusBarText(
146211 balance: 42, isLoading: false,
212+ hyperConfigured: true,
147213 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
148− zaiFiveHourPercent: 12
214+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215+ zaiInPeakHours: false
149216 )
150− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12%")
217+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
151218 }
152219
153− func testZaiZeroIsOmitted() {
220+ func testZaiZeroIsShown() {
154221 let title = ViewModel.statusBarText(
155222 balance: 42, isLoading: false,
223+ hyperConfigured: true,
156224 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157− zaiFiveHourPercent: 0
225+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226+ zaiInPeakHours: false
158227 )
159− XCTAssertEqual(title, "⚡42")
228+ XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
160229 }
161230
162231 func testLoadingKeepsZaiPercentOnScreen() {
163232 let title = ViewModel.statusBarText(
164233 balance: nil, isLoading: true,
234+ hyperConfigured: false,
235+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
236+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237+ zaiInPeakHours: false
238+ )
239+ XCTAssertEqual(title, "\u{005A}12%")
240+ }
241+
242+ // MARK: - z.ai Weekly
243+
244+ func testZaiWeeklyOnly() {
245+ let title = ViewModel.statusBarText(
246+ balance: nil, isLoading: false,
247+ hyperConfigured: false,
165248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
166− zaiFiveHourPercent: 12
249+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250+ zaiInPeakHours: false
251+ )
252+ XCTAssertEqual(title, "\u{005A}3%")
253+ }
254+
255+ func testZaiWeeklyWithFiveHour() {
256+ let title = ViewModel.statusBarText(
257+ balance: nil, isLoading: false,
258+ hyperConfigured: false,
259+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
260+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261+ zaiInPeakHours: false
262+ )
263+ XCTAssertEqual(title, "\u{005A}12% 3%")
264+ }
265+
266+ func testZaiWeeklyZeroIsShown() {
267+ let title = ViewModel.statusBarText(
268+ balance: nil, isLoading: false,
269+ hyperConfigured: false,
270+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
271+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272+ zaiInPeakHours: false
273+ )
274+ XCTAssertEqual(title, "\u{005A}12% 0%")
275+ }
276+
277+ func testLoadingKeepsZaiWeeklyOnScreen() {
278+ let title = ViewModel.statusBarText(
279+ balance: nil, isLoading: true,
280+ hyperConfigured: false,
281+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
282+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
283+ zaiInPeakHours: false
284+ )
285+ XCTAssertEqual(title, "\u{005A}3%")
286+ }
287+
288+ // MARK: - z.ai Peak Hours
289+
290+ func testPeakHoursAddsDiamondToZaiSegment() {
291+ let title = ViewModel.statusBarText(
292+ balance: 42, isLoading: false,
293+ hyperConfigured: true,
294+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
295+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
296+ zaiInPeakHours: true
297+ )
298+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%\u{1F536}")
299+ }
300+
301+ func testPeakHoursWithZaiOnly() {
302+ let title = ViewModel.statusBarText(
303+ balance: nil, isLoading: false,
304+ hyperConfigured: false,
305+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
306+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
307+ zaiInPeakHours: true
308+ )
309+ XCTAssertEqual(title, "\u{005A}12%\u{1F536}")
310+ }
311+
312+ func testPeakHoursIgnoredWhenZaiNotConfigured() {
313+ let title = ViewModel.statusBarText(
314+ balance: 42, isLoading: false,
315+ hyperConfigured: true,
316+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
317+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
318+ zaiInPeakHours: true
167319 )
168− XCTAssertEqual(title, "🤖12%")
320+ // No z.ai segment, so peak marker has nothing to attach to.
321+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
169322 }
170323
171324 // MARK: - Claude Window Percentages
@@ -222,4 +375,4 @@ final class StatusBarTextTests: XCTestCase {
222375 XCTAssertNil(vm.claudeFiveHourPercent)
223376 XCTAssertNil(vm.claudeSevenDayPercent)
224377 }
225−}
378+}
UsageMenubarTests/ZaiUsageClientTests.swift+23−28
@@ -22,6 +22,18 @@ final class ZaiUsageClientTests: XCTestCase {
2222 return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
2323 }
2424
25+ /// Enqueues quota and subscription responses matched to their URL paths, so the
26+ /// order of arrival doesn't matter — each endpoint gets its own response.
27+ private func enqueueQuotaAndSubscription(
28+ quotaStatus: Int = 200,
29+ quotaJSON: Data,
30+ subscriptionStatus: Int = 200,
31+ subscriptionJSON: Data
32+ ) {
33+ StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
34+ StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
35+ }
36+
2537 // MARK: - Decoding
2638
2739 func testDecodeQuotaResponse() throws {
@@ -78,21 +90,6 @@ final class ZaiUsageClientTests: XCTestCase {
7890
7991 // MARK: - Fetch
8092
81− /// Helper: enqueues a quota response and a subscription response. The two endpoints
82− /// are fetched concurrently, so the order they're enqueued in is the order they'll
83− /// be dequeued — but which request gets which response depends on which arrives first
84− /// at the stub. Since the stub dequeues in FIFO order, we enqueue quota first (it's
85− /// the one whose data matters), then subscription.
86− private func enqueueQuotaAndSubscription(
87− quotaStatus: Int = 200,
88− quotaJSON: Data,
89− subscriptionStatus: Int = 200,
90− subscriptionJSON: Data
91− ) {
92− StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON))
93− StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON))
94− }
95−
9693 func testFetchUsageSuccess() async throws {
9794 let quotaJSON = """
9895 {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
@@ -130,12 +127,11 @@ final class ZaiUsageClientTests: XCTestCase {
130127 }
131128
132129 func testFetchUsageInvalidKey() async {
133− // Both endpoints will 401 — the quota endpoint's 401 is what surfaces.
134130 let errorJSON = """
135131 {"code":401,"msg":"token expired or incorrect","success":false}
136132 """.data(using: .utf8)!
137− StubURLProtocol.enqueue(.status(401, errorJSON))
138− StubURLProtocol.enqueue(.status(401, errorJSON))
133+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
134+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
139135
140136 let client = makeStubbedClient()
141137 do {
@@ -160,11 +156,10 @@ final class ZaiUsageClientTests: XCTestCase {
160156 {"code":200,"data":[],"success":true}
161157 """.data(using: .utf8)!
162158
163− // Quota: 500 then 200 (retry). Subscription: 200 (first try).
164− // The order is: quota(500), subscription(200), quota(200 on retry).
165− StubURLProtocol.enqueue(.status(500, Data()))
166− StubURLProtocol.enqueue(.status(200, subscriptionJSON))
167− StubURLProtocol.enqueue(.status(200, quotaJSON))
159+ // Subscription succeeds on first try. Quota fails with 500, then succeeds.
160+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
161+ StubURLProtocol.enqueue(.status(500, Data()), forURLPath: "/api/monitor/usage/quota/limit")
162+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
168163
169164 let client = makeStubbedClient()
170165 let report = try await client.fetchUsage(apiKey: "test-key")
@@ -181,9 +176,9 @@ final class ZaiUsageClientTests: XCTestCase {
181176 {"code":200,"data":[],"success":true}
182177 """.data(using: .utf8)!
183178
184− StubURLProtocol.enqueue(.status(429, Data()))
185− StubURLProtocol.enqueue(.status(200, subscriptionJSON))
186− StubURLProtocol.enqueue(.status(200, quotaJSON))
179+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
180+ StubURLProtocol.enqueue(.status(429, Data()), forURLPath: "/api/monitor/usage/quota/limit")
181+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
187182
188183 let client = makeStubbedClient()
189184 let report = try await client.fetchUsage(apiKey: "test-key")
@@ -194,8 +189,8 @@ final class ZaiUsageClientTests: XCTestCase {
194189 let errorJSON = """
195190 {"code":401,"msg":"token expired or incorrect","success":false}
196191 """.data(using: .utf8)!
197− StubURLProtocol.enqueue(.status(401, errorJSON))
198− StubURLProtocol.enqueue(.status(401, errorJSON))
192+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
193+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
199194
200195 let client = makeStubbedClient()
201196 do {
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: com.ilyakooo0
9+ bundleIdPrefix: soy.iko
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: com.ilyakooo0.usage-menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"