on e71c8bf help sign in
CLAUDE.md+12−40
since splitsnapshots
unifiedsplit
@@ -1,50 +1,22 @@
1# UsageMenubar
1# UsageMenubar
2
2
3## Architecture
3## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
4+macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5−visual treatment:
6−- **Hyper** (Charm) — credit balance
7−- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
5+Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
8−- **z.ai Coding Plan** — usage limits (5-hour, weekly)
6+z.ai Coding Plan usage limits when an API key is entered in settings.
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−
14- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
7- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
15−- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, per-provider history
8+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
16−- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
9+- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
17- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
10- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
18- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
11- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
19- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
12- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
20- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
13- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
21- `VersionFormatter.swift` — Version string formatting
14- `VersionFormatter.swift` — Version string formatting
22
15
23−The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
16+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}%`
17+`📅{percent}%` (Claude 7-day), and `🤖{percent}%` (z.ai 5-hour) — each omitted when
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 —
18+absent or at 0%. The three fetches are independent — `ViewModel.refresh()` runs them
28−`ViewModel.refresh()` runs them with `async let`, and any can fail or be
29−unconfigured without touching the others.
19+with `async let`, and any can fail or be 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`).
48
20
49## Claude credentials (strictly read-only)
21## Claude credentials (strictly read-only)
50`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
22`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
@@ -75,9 +47,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
75- Swift 5.9, macOS 14.0 deployment target
47- Swift 5.9, macOS 14.0 deployment target
76
48
77## Conventions
49## Conventions
78−- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
50+- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
79- No card-heavy/multi-material/gradient-rich designs
51- No card-heavy/multi-material/gradient-rich designs
80−- Rounded font family at one scale (subhero/section/control/caption/footnote)
52+- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
81- Monospaced digits for numbers that change
53- Monospaced digits for numbers that change
82- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
54- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
83 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
55 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -85,4 +57,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
85
57
86## VCS
58## VCS
87- jj colocated with git
59- jj colocated with git
88−- Push directly to master — no PRs for this repo
60+- Push directly to master — no PRs for this repo
README.md+48−64
@@ -4,33 +4,24 @@
4
4
5# UsageMenubar
5# UsageMenubar
6
6
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.
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.
8
8
9## Features
9## Features
10
10
11−- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
11+- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
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
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−- 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
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
15−- 🖱️ **Right-click menu** — quick access to Refresh and Quit
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
16−- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
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
17- 🚀 **Launch at login** — optional, using `SMAppService`
21- 🚀 **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
18- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
23- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
19
24
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−
34## Installation
25## Installation
35
26
36### Homebrew (recommended)
27### Homebrew (recommended)
@@ -51,47 +42,37 @@ brew install --cask ilyakooo0/tap/usage-menubar
511. Go to the [Releases page](https://github.com/ilyakooo0/usage-menubar/releases)
421. Go to the [Releases page](https://github.com/ilyakooo0/usage-menubar/releases)
522. Download the latest `UsageMenubar-*.zip`
432. Download the latest `UsageMenubar-*.zip`
533. Unzip and move `UsageMenubar.app` to `/Applications`
443. Unzip and move `UsageMenubar.app` to `/Applications`
54−4. Launch the app — it will appear in your menu bar
45+4. Launch the app — it will appear in your menu bar as ⚡
55
46
56> **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.
47> **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.
57
48
58−## Setup
49+### Set your API key
59−
60−Click the menu bar icon to open the popover. Each service is configured independently:
61
50
62−### Hyper credits
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
63
55
64−1. Enter your Hyper API key (starts with `sk-...`) in the **Hyper API Key** field
56+You can get your API key from [hyper.charm.land](https://hyper.charm.land).
65−2. Click **Save**
66−3. Your balance appears immediately
67
57
68−Get your key from [hyper.charm.land](https://hyper.charm.land).
58+### Claude Code limits (optional, no setup)
69
59
70−### Claude Code limits (no setup)
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.
71
61
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.
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.
73
63
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.
64+Two things worth knowing:
75
65
76- **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.
66- **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.
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`.
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.
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).
86
68
87## Settings
69## Settings
88
70
89−Everything is configured from the popover — click the menu bar icon.
71+Everything is configured from the popover — click the ⚡ icon in the menu bar.
90
72
91| Setting | Where | Notes |
73| Setting | Where | Notes |
92| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
74| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
93−| **Hyper API key** | **Hyper API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
75+| **API key** | **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. |
95| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
76| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
96| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
77| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
97| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
78| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
@@ -157,21 +138,18 @@ usage-menubar/
157├── project.yml # xcodegen project definition
138├── project.yml # xcodegen project definition
158├── UsageMenubar/
139├── UsageMenubar/
159│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
140│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
160−│ ├── ViewModel.swift # Observable state: balance, Claude usage, z.ai usage, refresh
141+│ ├── ViewModel.swift # Observable state: balance, Claude usage, refresh, notifications
161│ ├── MenuView.swift # SwiftUI popover view
142│ ├── MenuView.swift # SwiftUI popover view
162−│ ├── CreditsChecker.swift # Hyper API client (GET /v1/credits)
143+│ ├── CreditsChecker.swift # API client (GET /v1/credits)
163│ ├── ClaudeUsageClient.swift # Claude OAuth client (GET /api/oauth/usage) + models
144│ ├── ClaudeUsageClient.swift # Claude OAuth client (GET /api/oauth/usage) + models
164−│ ├── ZaiUsageClient.swift # z.ai API client (GET /monitor/usage/quota/limit)
165−│ ├── KeychainHelper.swift # Keychain wrapper (Hyper + z.ai keys)
145+│ ├── KeychainHelper.swift # Keychain wrapper for API key
166│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
146│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
167│ ├── Info.plist # LSUIElement=true (agent app)
147│ ├── Info.plist # LSUIElement=true (agent app)
168−│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
148+│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
169│ └── Assets.xcassets/ # App icon, accent color
149│ └── Assets.xcassets/ # App icon, accent color
170├── UsageMenubarTests/
150├── UsageMenubarTests/
171−│ ├── CreditsCheckerTests.swift # Hyper API, retry, keychain, StubURLProtocol
151+│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
172│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
152│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
173−│ ├── ZaiUsageClientTests.swift # z.ai API, retry, decoding
174−│ ├── StatusBarTextTests.swift # Menu bar title formatting
175│ └── VersionFormatterTests.swift # Version formatting tests
153│ └── VersionFormatterTests.swift # Version formatting tests
176├── .github/workflows/
154├── .github/workflows/
177│ └── release.yml # Build + release on push to master
155│ └── release.yml # Build + release on push to master
@@ -182,18 +160,24 @@ usage-menubar/
182
160
183## How it works
161## How it works
184
162
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).
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).
186−2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with each configured service's data and the settings.
164+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with the balance, sparkline, API key field, and 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>}`.
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).
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.
166+4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework. `save()` returns a `Bool` indicating success.
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.
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.
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.
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.
194−10. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
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.
19511. **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.
17311. **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.
196
174
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+
197## License
181## License
198
182
199−MIT — see [LICENSE](LICENSE).
183+MIT — see [LICENSE](LICENSE).
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
7 <key>CFBundleDisplayName</key>
7 <key>CFBundleDisplayName</key>
8 <string>Usage Menubar</string>
8 <string>Usage Menubar</string>
9 <key>CFBundleIdentifier</key>
9 <key>CFBundleIdentifier</key>
10− <string>soy.iko.usage-menubar</string>
10+ <string>com.ilyakooo0.usage-menubar</string>
11 <key>CFBundleVersion</key>
11 <key>CFBundleVersion</key>
12 <string>1</string>
12 <string>1</string>
13 <key>CFBundleShortVersionString</key>
13 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
82
82
83/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
83/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
84enum KeychainHelper {
84enum KeychainHelper {
85− private static let service = "soy.iko.usage-menubar"
85+ private static let service = "com.ilyakooo0.usage-menubar"
86 private static let account = "hyper-api-key"
86 private static let account = "hyper-api-key"
87
87
88 private static let store = KeychainStore(service: service, account: account)
88 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112/// Uses the same keychain service as the Hyper key but a different account, so the
112/// Uses the same keychain service as the Hyper key but a different account, so the
113/// two keys coexist without clobbering each other.
113/// two keys coexist without clobbering each other.
114enum ZaiKeychainHelper {
114enum ZaiKeychainHelper {
115− private static let service = "soy.iko.usage-menubar"
115+ private static let service = "com.ilyakooo0.usage-menubar"
116 private static let account = "zai-api-key"
116 private static let account = "zai-api-key"
117
117
118 private static let store = KeychainStore(service: service, account: account)
118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+172−225
@@ -4,15 +4,21 @@ import SwiftUI
4struct MenuView: View {
4struct MenuView: View {
5 @ObservedObject var viewModel: ViewModel
5 @ObservedObject var viewModel: ViewModel
6
6
7+ /// Dims the balance while the pointer is over it, hinting that it is clickable.
8+ @State private var isHoveringBalance = false
9+
7 private static let websiteURL = URL(string: "https://hyper.charm.land")!
10 private static let websiteURL = URL(string: "https://hyper.charm.land")!
8 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
11 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
9
12
10 // MARK: - Type Scale
13 // MARK: - Type Scale
11
14
12− /// One rounded family, sized so that every provider's headline number reads at the
15+ /// One rounded family, sized so that the balance is the only large element and
13− /// same weight. With three providers in the popover, the 26pt subhero is the right
16+ /// everything else steps down from it — nothing competes with it for attention.
14− /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
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.
15− /// numbers from jittering as their digits change.
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()
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
22 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
17 .monospacedDigit()
23 .monospacedDigit()
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
24 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -22,9 +28,7 @@ struct MenuView: View {
22
28
23 var body: some View {
29 var body: some View {
24 VStack(alignment: .leading, spacing: 18) {
30 VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
26− hyper
31+ hyper
27− }
28 if showsClaude {
32 if showsClaude {
29 serviceBreak
33 serviceBreak
30 claude
34 claude
@@ -47,14 +51,10 @@ struct MenuView: View {
47 .background(Color(nsColor: .windowBackgroundColor))
51 .background(Color(nsColor: .windowBackgroundColor))
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
49 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
53 .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)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
54 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
55 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
56 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55− .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
56− .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57− .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,14 +66,15 @@ struct MenuView: View {
66 Divider().opacity(0.3)
66 Divider().opacity(0.3)
67 }
67 }
68
68
69− /// The rule between services. Wider than the others on purpose: the providers are
69+ /// The rule between the two services. Wider than the others on purpose: Hyper and
70− /// unrelated accounts, and the gap should say so before the headers do.
70+ /// Claude are two unrelated accounts, and the gap should say so before the headers do.
71 private var serviceBreak: some View {
71 private var serviceBreak: some View {
72 hairline.padding(.vertical, 5)
72 hairline.padding(.vertical, 5)
73 }
73 }
74
74
75 /// A section header: an icon and a title, sized to match the "API Key" header below
75 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three equal sections rather than a hero with appendages.
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.
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
78 private func sectionHeader(_ title: String, systemImage: String) -> some View {
78 HStack(spacing: 5) {
79 HStack(spacing: 5) {
79 Image(systemName: systemImage)
80 Image(systemName: systemImage)
@@ -84,62 +85,113 @@ struct MenuView: View {
84 }
85 }
85 }
86 }
86
87
87− /// A reusable headline number row: the provider's main metric at subhero scale,
88+ // MARK: - Hyper
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)
105
89
106− if let symbol = trend.symbolName {
90+ /// The Hyper section: its header, then the balance as the popover's hero.
107− Image(systemName: symbol)
91+ private var hyper: some View {
108− .font(.system(size: 12, weight: .semibold))
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)
109− .foregroundColor(.secondary)
108+ .foregroundColor(.secondary)
109+
110+ Text("Enter your API key below")
111+ .font(Self.captionFont)
112+ .foregroundColor(.secondary)
113+ }
110− .transition(.opacity)
114+ .transition(.opacity)
111− }
115+ } else {
116+ VStack(spacing: 6) {
117+ balanceNumber
118+ caption
119+ sparkline
112 }
120 }
113− .contentShape(Rectangle())
121+ .transition(.opacity)
114 }
122 }
115− .buttonStyle(.plain)
123+
116− .help("Click to copy")
124+ if let lastUpdatedText = viewModel.lastUpdatedText {
117−
125+ Text(lastUpdatedText)
118− // Caption doubles as copy confirmation so the layout doesn't shift.
126+ .font(Self.footnoteFont)
119− Group {
127+ .foregroundColor(.secondary)
120− if copiedConfirmation {
128+ .transition(.opacity)
121− Text("✓ Copied")
129+ }
122− .foregroundColor(.green)
130+
123− } else {
131+ if let error = viewModel.errorMessage {
124− Text(captionText)
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))
125 .foregroundColor(.secondary)
151 .foregroundColor(.secondary)
152+ .transition(.opacity)
126 }
153 }
127 }
154 }
128− .font(Self.captionFont)
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+ }
129 }
176 }
177+ .font(Self.captionFont)
130 }
178 }
131
179
132− /// A sparkline shown when there are at least 2 history points.
133 @ViewBuilder
180 @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
181+ private var sparkline: some View {
135− if values.count >= 2 {
182+ if historyValues.count >= 2 {
136− Sparkline(values: values, color: color)
183+ Sparkline(values: historyValues, color: viewModel.balanceColor)
137 .frame(height: 26)
184 .frame(height: 26)
138 .padding(.top, 4)
185 .padding(.top, 4)
139 .transition(.opacity)
186 .transition(.opacity)
140 }
187 }
141 }
188 }
142
189
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+
143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144 /// message, and a tinted rounded background with no border.
196 /// message, and a tinted rounded background with no border.
145 private func errorRow(_ message: String) -> some View {
197 private func errorRow(_ message: String) -> some View {
@@ -164,56 +216,6 @@ struct MenuView: View {
164 .padding(.top, 2)
216 .padding(.top, 2)
165 }
217 }
166
218
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−
217 // MARK: - Claude
219 // MARK: - Claude
218
220
219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -225,8 +227,8 @@ struct MenuView: View {
225 return false
227 return false
226 }
228 }
227
229
228− /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
230+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
229− /// headline number, bars under every window, a sparkline, and error row.
231+ /// with the individual windows underneath as quiet supporting detail.
230 private var claude: some View {
232 private var claude: some View {
231 VStack(alignment: .leading, spacing: 12) {
233 VStack(alignment: .leading, spacing: 12) {
232 HStack(spacing: 8) {
234 HStack(spacing: 8) {
@@ -253,31 +255,23 @@ struct MenuView: View {
253
255
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255 VStack(alignment: .leading, spacing: 12) {
257 VStack(alignment: .leading, spacing: 12) {
256− // The 5-hour window leads as the headline number.
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.
257 if let fiveHour = usage.fiveHour {
260 if let fiveHour = usage.fiveHour {
258 let percent = Int(fiveHour.utilization.rounded())
261 let percent = Int(fiveHour.utilization.rounded())
259− VStack(spacing: 8) {
260− HStack(alignment: .firstTextBaseline, spacing: 8) {
262+ HStack(alignment: .firstTextBaseline, spacing: 8) {
261− headlineNumber(
262− text: "⁕ \\(percent)%",
263+ Text("🕐 \(percent)%")
264+ .font(Self.subheroFont)
263− color: Self.usageColor(percent),
265+ .foregroundColor(Self.usageColor(percent))
264− trend: viewModel.claudeTrend,
266+ .contentTransition(.opacity)
265− copiedConfirmation: viewModel.copiedClaudeConfirmation,
266− captionText: "5-hour usage",
267− copyAction: { viewModel.copyClaudePercentToClipboard() }
268− )
269
267
270− Spacer(minLength: 0)
268+ Spacer(minLength: 0)
271
269
272− if let resets = fiveHour.resetsInFormatted {
270+ if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
271+ Text("resets in \(resets)")
274− .font(Self.footnoteFont)
272+ .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
273+ .foregroundColor(.secondary)
276− }
277 }
274 }
278−
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
281 }
275 }
282 }
276 }
283
277
@@ -291,12 +285,6 @@ struct MenuView: View {
291 window("7-day Opus", opus)
285 window("7-day Opus", opus)
292 }
286 }
293 }
287 }
294−
295− // Sparkline for the Claude 5-hour % history.
296− sparkline(
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
299− )
300 }
288 }
301 }
289 }
302
290
@@ -322,11 +310,6 @@ struct MenuView: View {
322 }
310 }
323 }
311 }
324
312
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−
330 /// The balance colors read the other way round here, because for a limit more is
313 /// The balance colors read the other way round here, because for a limit more is
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
314 /// worse: green with room to spare, yellow closing in, red nearly spent.
332 private static func usageColor(_ percent: Int) -> Color {
315 private static func usageColor(_ percent: Int) -> Color {
@@ -346,8 +329,8 @@ struct MenuView: View {
346 return false
329 return false
347 }
330 }
348
331
349− /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
332+ /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
350− /// a subhero headline number, bars under every window, a sparkline, and error row.
333+ /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
351 private var zai: some View {
334 private var zai: some View {
352 VStack(alignment: .leading, spacing: 12) {
335 VStack(alignment: .leading, spacing: 12) {
353 HStack(spacing: 8) {
336 HStack(spacing: 8) {
@@ -374,30 +357,21 @@ struct MenuView: View {
374
357
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376 VStack(alignment: .leading, spacing: 12) {
359 VStack(alignment: .leading, spacing: 12) {
377− // The 5-hour window leads as the headline number.
360+ // The 5-hour window leads as the hero number, same as Claude's.
378 if let fiveHour = usage.fiveHourPercent {
361 if let fiveHour = usage.fiveHourPercent {
379− VStack(spacing: 8) {
380− HStack(alignment: .firstTextBaseline, spacing: 8) {
362+ HStack(alignment: .firstTextBaseline, spacing: 8) {
381− headlineNumber(
382− text: "Z \\(fiveHour)%",
363+ Text("🤖 \(fiveHour)%")
364+ .font(Self.subheroFont)
383− color: Self.usageColor(fiveHour),
365+ .foregroundColor(Self.usageColor(fiveHour))
384− trend: viewModel.zaiTrend,
366+ .contentTransition(.opacity)
385− copiedConfirmation: viewModel.copiedZaiConfirmation,
386− captionText: "5-hour usage",
387− copyAction: { viewModel.copyZaiPercentToClipboard() }
388− )
389
367
390− Spacer(minLength: 0)
368+ Spacer(minLength: 0)
391
369
392− if let resets = usage.fiveHourResetsAt {
370+ if let resets = usage.fiveHourResetsAt {
393− Text("resets in \(Self.zaiResetsIn(from: resets))")
371+ Text("resets in \(Self.zaiResetsIn(from: resets))")
394− .font(Self.footnoteFont)
372+ .font(Self.footnoteFont)
395− .foregroundColor(.secondary)
373+ .foregroundColor(.secondary)
396− }
397 }
374 }
398−
399− // Bar under the 5-hour window too.
400− UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401 }
375 }
402 }
376 }
403
377
@@ -406,35 +380,9 @@ struct MenuView: View {
406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407 }
381 }
408 }
382 }
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− }
430 }
383 }
431 }
384 }
432
385
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−
438 /// One window row for z.ai: label, percentage, and a hairline bar.
386 /// One window row for z.ai: label, percentage, and a hairline bar.
439 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440 VStack(alignment: .leading, spacing: 5) {
388 VStack(alignment: .leading, spacing: 5) {
@@ -454,42 +402,6 @@ struct MenuView: View {
454 }
402 }
455 }
403 }
456
404
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−
493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
405 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494 private static func zaiResetsIn(from date: Date) -> String {
406 private static func zaiResetsIn(from date: Date) -> String {
495 let interval = date.timeIntervalSince(Date())
407 let interval = date.timeIntervalSince(Date())
@@ -611,10 +523,45 @@ struct MenuView: View {
611 .keyboardShortcut("q")
523 .keyboardShortcut("q")
612 }
524 }
613 .controlSize(.small)
525 .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)
614 }
532 }
615 }
533 }
616}
534}
617
535
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+
618// MARK: - Usage Bar
565// MARK: - Usage Bar
619
566
620/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -645,8 +592,8 @@ private struct UsageBar: View {
645
592
646// MARK: - Sparkline
593// MARK: - Sparkline
647
594
648−/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
595+/// A minimal line chart of recent balances: 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
596+/// takes the balance's color so the trend reads as part of the number above it, with a
650/// faint fade underneath for weight and a dot marking the latest point.
597/// faint fade underneath for weight and a dot marking the latest point.
651///
598///
652/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -738,4 +685,4 @@ struct Sparkline: View {
738 path.closeSubpath()
685 path.closeSubpath()
739 }
686 }
740 }
687 }
741−}
688+}
UsageMenubar/UsageMenubarApp.swift+7−22
@@ -35,7 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
35
35
36 if let button = statusItem.button {
36 if let button = statusItem.button {
37 button.image = nil
37 button.image = nil
38− button.title = "…"
38+ button.title = "⚡?"
39 button.target = self
39 button.target = self
40 button.action = #selector(statusItemClicked(_:))
40 button.action = #selector(statusItemClicked(_:))
41 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
41 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -46,9 +46,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
46 popover.behavior = .transient
46 popover.behavior = .transient
47 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
47 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
48
48
49− // Observe published changes (balance + isLoading + hyperConfigured +
49+ // Observe published changes (balance + isLoading + Claude usage + z.ai usage) to
50− // Claude usage + z.ai usage) to update the menu bar title. CombineLatest
50+ // update the menu bar title. CombineLatest fires when any of them changes.
51− // fires when any of them changes.
52 //
51 //
53 // The title is built from the values the publisher hands us, not by reading the
52 // The title is built from the values the publisher hands us, not by reading the
54 // view model back: `@Published` emits in `willSet`, so the property still holds
53 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -57,23 +56,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
57 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
56 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
58 // whose Combine scheduler only runs in the default run loop mode — the title
57 // whose Combine scheduler only runs in the default run loop mode — the title
59 // would freeze while a menu or a scroll was tracking.
58 // 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.
65 statusTextCancellable = Publishers.CombineLatest4(
59 statusTextCancellable = Publishers.CombineLatest4(
66 viewModel.$balance,
60 viewModel.$balance,
67 viewModel.$isLoading,
61 viewModel.$isLoading,
68− viewModel.$hyperConfigured,
69− Publishers.CombineLatest3(
70− viewModel.$claudeUsage,
62+ viewModel.$claudeUsage,
71− viewModel.$zaiUsage,
63+ viewModel.$zaiUsage
72− Just<Void>(())
73− )
74 )
64 )
75− .map { balance, isLoading, hyperConfigured, claudeAndZai in
76− let (claudeUsage, zaiUsage, _) = claudeAndZai
65+ .map { balance, isLoading, claudeUsage, zaiUsage in
77 let fiveHour = claudeUsage?.fiveHour
66 let fiveHour = claudeUsage?.fiveHour
78 .map { Int($0.utilization.rounded()) }
67 .map { Int($0.utilization.rounded()) }
79 let sevenDay = claudeUsage?.sevenDay
68 let sevenDay = claudeUsage?.sevenDay
@@ -81,16 +70,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
81 ?? claudeUsage?.sevenDayOpus
70 ?? claudeUsage?.sevenDayOpus
82 .map { Int($0.utilization.rounded()) }
71 .map { Int($0.utilization.rounded()) }
83 let zaiFiveHour = zaiUsage?.fiveHourPercent
72 let zaiFiveHour = zaiUsage?.fiveHourPercent
84− let zaiWeekly = zaiUsage?.weeklyPercent
85 return ViewModel.statusBarText(
73 return ViewModel.statusBarText(
86 balance: balance,
74 balance: balance,
87 isLoading: isLoading,
75 isLoading: isLoading,
88− hyperConfigured: hyperConfigured,
89 claudeFiveHourPercent: fiveHour,
76 claudeFiveHourPercent: fiveHour,
90 claudeSevenDayPercent: sevenDay,
77 claudeSevenDayPercent: sevenDay,
91− zaiFiveHourPercent: zaiFiveHour,
78+ zaiFiveHourPercent: zaiFiveHour
92− zaiWeeklyPercent: zaiWeekly,
93− zaiInPeakHours: ViewModel.zaiInPeakHours
94 )
79 )
95 }
80 }
96 .removeDuplicates()
81 .removeDuplicates()
UsageMenubar/ViewModel.swift+43−240
@@ -7,11 +7,11 @@ import UserNotifications
7/// Posted when the user changes the refresh interval, so the app delegate can
7/// Posted when the user changes the refresh interval, so the app delegate can
8/// restart its timer with the new period.
8/// restart its timer with the new period.
9extension Notification.Name {
9extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
11}
11}
12
12
13−/// The direction a metric has moved over the most recent history points.
13+/// The direction the balance has moved over the most recent history points.
14−enum MetricTrend {
14+enum BalanceTrend {
15 case increasing
15 case increasing
16 case decreasing
16 case decreasing
17 case stable
17 case stable
@@ -29,11 +29,10 @@ enum MetricTrend {
29 }
29 }
30}
30}
31
31
32−/// A single metric observation — used for Hyper balance, Claude 5-hour %,
32+/// A single balance observation.
33−/// and z.ai 5-hour % alike.
34−struct MetricPoint: Equatable {
33+struct BalancePoint: Equatable {
35 let date: Date
34 let date: Date
36− let value: Int
35+ let balance: Int
37}
36}
38
37
39/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
38/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -94,11 +93,6 @@ final class ViewModel: ObservableObject {
94 /// `activeZaiAPIKey`.
93 /// `activeZaiAPIKey`.
95 @Published var zaiAPIKeyInput: String = ""
94 @Published var zaiAPIKeyInput: String = ""
96
95
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−
102 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
96 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
103 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
97 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
104 @Published var apiKeyInput: String = ""
98 @Published var apiKeyInput: String = ""
@@ -112,20 +106,8 @@ final class ViewModel: ObservableObject {
112 /// Brief "✓ Copied" confirmation shown after copying the balance.
106 /// Brief "✓ Copied" confirmation shown after copying the balance.
113 @Published var copiedConfirmation: Bool = false
107 @Published var copiedConfirmation: Bool = false
114
108
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−
121 /// Successful balance observations from the last 24 hours, oldest first.
109 /// Successful balance observations from the last 24 hours, oldest first.
122− @Published private(set) var history: [MetricPoint] = []
110+ @Published private(set) var history: [BalancePoint] = []
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] = []
129
111
130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
112 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
113 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -176,12 +158,6 @@ final class ViewModel: ObservableObject {
176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
158 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177 private var savedResetTask: Task<Void, Never>?
159 private var savedResetTask: Task<Void, Never>?
178
160
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−
185 // MARK: - History Configuration
161 // MARK: - History Configuration
186
162
187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
163 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -189,9 +165,6 @@ final class ViewModel: ObservableObject {
189 private static let maxHistoryEntries = 300
165 private static let maxHistoryEntries = 300
190 private static let historyWindow: TimeInterval = 24 * 60 * 60
166 private static let historyWindow: TimeInterval = 24 * 60 * 60
191
167
192− /// The threshold above which a usage percentage triggers a notification.
193− private static let highUsageThreshold = 90
194−
195 init(
168 init(
196 checker: CreditsChecking = CreditsChecker(),
169 checker: CreditsChecking = CreditsChecker(),
197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
170 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -205,7 +178,6 @@ final class ViewModel: ObservableObject {
205 let storedKey = KeychainHelper.load() ?? ""
178 let storedKey = KeychainHelper.load() ?? ""
206 apiKeyInput = storedKey
179 apiKeyInput = storedKey
207 activeAPIKey = storedKey
180 activeAPIKey = storedKey
208− hyperConfigured = !storedKey.isEmpty
209 let storedZaiKey = ZaiKeychainHelper.load() ?? ""
181 let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210 zaiAPIKeyInput = storedZaiKey
182 zaiAPIKeyInput = storedZaiKey
211 activeZaiAPIKey = storedZaiKey
183 activeZaiAPIKey = storedZaiKey
@@ -237,13 +209,12 @@ final class ViewModel: ObservableObject {
237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
209 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238 }
210 }
239
211
240− /// The text to show in the menu bar. Each configured provider contributes a
212+ /// The text to show in the menu bar: `⚡{balance}` followed by Claude usage
241− /// segment prefixed with a provider emoji; segments are separated by ` · `
213+ /// windows, each prefixed with an emoji so they are distinguishable at a glance:
242− /// (middle dot). Within a segment, windows are listed as bare percentages
214+ /// 🕐 for the 5-hour window, 📅 for the 7-day window, 🤖 for the z.ai 5-hour
243− /// separated by spaces. An unconfigured service produces no segment.
215+ /// window. Windows that are absent or at 0% are omitted; with neither, the bolt
244− /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
216+ /// stands in alone: `⚡…` while the first fetch is in flight, `⚡?` once it has
245− ///
217+ /// come back empty-handed.
246− /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
247 ///
218 ///
248 /// Takes its values as parameters rather than reading the properties: `@Published`
219 /// Takes its values as parameters rather than reading the properties: `@Published`
249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
220 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -251,54 +222,27 @@ final class ViewModel: ObservableObject {
251 static func statusBarText(
222 static func statusBarText(
252 balance: Int?,
223 balance: Int?,
253 isLoading: Bool,
224 isLoading: Bool,
254− hyperConfigured: Bool,
255 claudeFiveHourPercent: Int?,
225 claudeFiveHourPercent: Int?,
256 claudeSevenDayPercent: Int?,
226 claudeSevenDayPercent: Int?,
257− zaiFiveHourPercent: Int?,
227+ zaiFiveHourPercent: Int?
258− zaiWeeklyPercent: Int?,
259− zaiInPeakHours: Bool
260 ) -> String {
228 ) -> String {
261− var segments: [String] = []
229+ var parts: [String] = []
262−
263− // Hyper
264− if hyperConfigured, let balance = balance {
230+ if let balance = balance {
265− segments.append("⚡\(formatBalance(balance))")
231+ parts.append("⚡\(formatBalance(balance))")
266 }
232 }
267−
268− // Claude — provider emoji + bare percentages
269− var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent {
233+ if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
271− claudeWindows.append("\(fiveHour)%")
234+ parts.append("🕐\(fiveHour)%")
272− }
273− if let sevenDay = claudeSevenDayPercent {
274− claudeWindows.append("\(sevenDay)%")
275 }
235 }
276− if !claudeWindows.isEmpty {
236+ if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
277− segments.append("⁕" + claudeWindows.joined(separator: " "))
237+ parts.append("📅\(sevenDay)%")
278 }
238 }
279−
280− // z.ai — provider emoji + bare percentages
281− var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent {
239+ if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
283− zaiWindows.append("\(zaiFiveHour)%")
240+ parts.append("🤖\(zaiFiveHour)%")
284 }
241 }
285− if let zaiWeekly = zaiWeeklyPercent {
286− zaiWindows.append("\(zaiWeekly)%")
287− }
288− if !zaiWindows.isEmpty {
242+ if parts.isEmpty {
289− var segment = "Z" + zaiWindows.joined(separator: " ")
243+ return isLoading ? "⚡…" : "⚡?"
290− if zaiInPeakHours {
291− segment += "🔶"
292− }
293− segments.append(segment)
294 }
244 }
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: " · ")
245+ return parts.joined(separator: " · ")
302 }
246 }
303
247
304 /// The text to show in the menu bar for the current state.
248 /// The text to show in the menu bar for the current state.
@@ -306,21 +250,12 @@ final class ViewModel: ObservableObject {
306 Self.statusBarText(
250 Self.statusBarText(
307 balance: balance,
251 balance: balance,
308 isLoading: isLoading,
252 isLoading: isLoading,
309− hyperConfigured: hyperConfigured,
310 claudeFiveHourPercent: claudeFiveHourPercent,
253 claudeFiveHourPercent: claudeFiveHourPercent,
311 claudeSevenDayPercent: claudeSevenDayPercent,
254 claudeSevenDayPercent: claudeSevenDayPercent,
312− zaiFiveHourPercent: zaiFiveHourPercent,
255+ zaiFiveHourPercent: zaiFiveHourPercent
313− zaiWeeklyPercent: zaiWeeklyPercent,
314− zaiInPeakHours: Self.zaiInPeakHours
315 )
256 )
316 }
257 }
317
258
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−
324 /// The formatted balance string for display in the popover.
259 /// The formatted balance string for display in the popover.
325 var formattedBalance: String {
260 var formattedBalance: String {
326 guard let balance = balance else { return "?" }
261 guard let balance = balance else { return "?" }
@@ -341,69 +276,33 @@ final class ViewModel: ObservableObject {
341 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
276 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342 }
277 }
343
278
344− // MARK: - History (Hyper)
279+ // MARK: - History
345
280
346− /// The last 24 hours of successful balance fetches, oldest first.
281+ /// The last 24 hours of successful fetches, oldest first.
347 var balanceHistory: [(date: Date, balance: Int)] {
282 var balanceHistory: [(date: Date, balance: Int)] {
348− history.map { (date: $0.date, balance: $0.value) }
283+ history.map { (date: $0.date, balance: $0.balance) }
349 }
284 }
350
285
351 /// The direction of travel across the most recent handful of observations.
286 /// The direction of travel across the most recent handful of observations.
352 /// `.unknown` until there are at least two points to compare.
287 /// `.unknown` until there are at least two points to compare.
353− var balanceTrend: MetricTrend {
288+ var balanceTrend: BalanceTrend {
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 }
289+ guard history.count >= 2 else { return .unknown }
391− let recent = points.suffix(5)
290+ let recent = history.suffix(5)
392 guard let first = recent.first, let last = recent.last else { return .unknown }
291 guard let first = recent.first, let last = recent.last else { return .unknown }
393− if last.value > first.value { return .increasing }
292+ if last.balance > first.balance { return .increasing }
394− if last.value < first.value { return .decreasing }
293+ if last.balance < first.balance { return .decreasing }
395 return .stable
294 return .stable
396 }
295 }
397
296
398 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
297 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
399− private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
298+ private func recordHistory(balance: Int, at date: Date) {
400− array.append(MetricPoint(date: date, value: value))
299+ history.append(BalancePoint(date: date, balance: balance))
401
300
402 let cutoff = date.addingTimeInterval(-Self.historyWindow)
301 let cutoff = date.addingTimeInterval(-Self.historyWindow)
403− array.removeAll { $0.date < cutoff }
302+ history.removeAll { $0.date < cutoff }
404
303
405− if array.count > Self.maxHistoryEntries {
304+ if history.count > Self.maxHistoryEntries {
406− array.removeFirst(array.count - Self.maxHistoryEntries)
305+ history.removeFirst(history.count - Self.maxHistoryEntries)
407 }
306 }
408 }
307 }
409
308
@@ -478,7 +377,7 @@ final class ViewModel: ObservableObject {
478 errorMessage = nil
377 errorMessage = nil
479 let now = Date()
378 let now = Date()
480 lastUpdated = now
379 lastUpdated = now
481− recordMetric(newBalance, into: &history, at: now)
380+ recordHistory(balance: newBalance, at: now)
482
381
483 // Low-balance notification: on threshold crossing (≥10 → <10)
382 // Low-balance notification: on threshold crossing (≥10 → <10)
484 // OR on first successful fetch if already below threshold.
383 // OR on first successful fetch if already below threshold.
@@ -523,30 +422,12 @@ final class ViewModel: ObservableObject {
523 claudeUsage = nil
422 claudeUsage = nil
524 claudePlan = nil
423 claudePlan = nil
525 claudeError = nil
424 claudeError = nil
526− claudeHistory.removeAll()
527
425
528 case .usage(let report):
426 case .usage(let report):
529− // Capture the previous 5-hour % for threshold-crossing notification.
530− let previousPercent = claudeFiveHourPercent
531 claudeUsage = report.usage
427 claudeUsage = report.usage
532 claudePlan = report.subscriptionType
428 claudePlan = report.subscriptionType
533 claudeError = nil
429 claudeError = nil
534
430
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−
550 case .failure(let message):
431 case .failure(let message):
551 // As with the balance: keep the last good numbers on screen and say what
432 // As with the balance: keep the last good numbers on screen and say what
552 // went wrong, rather than blanking the section on a transient failure.
433 // went wrong, rather than blanking the section on a transient failure.
@@ -614,29 +495,11 @@ final class ViewModel: ObservableObject {
614 case .notConfigured:
495 case .notConfigured:
615 zaiUsage = nil
496 zaiUsage = nil
616 zaiError = nil
497 zaiError = nil
617− zaiHistory.removeAll()
618
498
619 case .usage(let report):
499 case .usage(let report):
620− // Capture the previous 5-hour % for threshold-crossing notification.
621− let previousPercent = zaiFiveHourPercent
622 zaiUsage = report
500 zaiUsage = report
623 zaiError = nil
501 zaiError = nil
624
502
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−
640 case .failure(let message):
503 case .failure(let message):
641 // As with the balance and Claude: keep the last good numbers on screen
504 // As with the balance and Claude: keep the last good numbers on screen
642 // and say what went wrong, rather than blanking the section on a
505 // and say what went wrong, rather than blanking the section on a
@@ -690,41 +553,7 @@ final class ViewModel: ObservableObject {
690 }
553 }
691 }
554 }
692
555
693− /// Copies the current Claude 5-hour percentage to the clipboard.
556+ // MARK: - Low Balance Notification
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
728
557
729 private func sendLowBalanceNotification(balance: Int) {
558 private func sendLowBalanceNotification(balance: Int) {
730 // Build the body here, on the main actor. `getNotificationSettings` calls back on
559 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -753,29 +582,6 @@ final class ViewModel: ObservableObject {
753 }
582 }
754 }
583 }
755
584
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−
779 // MARK: - API Key
585 // MARK: - API Key
780
586
781 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
587 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -790,7 +596,6 @@ final class ViewModel: ObservableObject {
790 }
596 }
791 apiKeyInput = ""
597 apiKeyInput = ""
792 activeAPIKey = ""
598 activeAPIKey = ""
793− hyperConfigured = false
794 // refresh() with an empty key clears balance, error, history, and
599 // refresh() with an empty key clears balance, error, history, and
795 // cancels any in-flight task from the previous key.
600 // cancels any in-flight task from the previous key.
796 refresh()
601 refresh()
@@ -814,7 +619,6 @@ final class ViewModel: ObservableObject {
814 // Show the trimmed key that was actually stored.
619 // Show the trimmed key that was actually stored.
815 apiKeyInput = key
620 apiKeyInput = key
816 activeAPIKey = key
621 activeAPIKey = key
817− hyperConfigured = true
818 refresh()
622 refresh()
819 }
623 }
820
624
@@ -858,7 +662,6 @@ final class ViewModel: ObservableObject {
858 if key != activeZaiAPIKey {
662 if key != activeZaiAPIKey {
859 zaiUsage = nil
663 zaiUsage = nil
860 zaiError = nil
664 zaiError = nil
861− zaiHistory.removeAll()
862 }
665 }
863 // Show the trimmed key that was actually stored.
666 // Show the trimmed key that was actually stored.
864 zaiAPIKeyInput = key
667 zaiAPIKeyInput = key
@@ -896,4 +699,4 @@ final class ViewModel: ObservableObject {
896 isInitializingLaunchAtLogin = false
699 isInitializingLaunchAtLogin = false
897 }
700 }
898 }
701 }
899−}
702+}
UsageMenubarTests/CreditsCheckerTests.swift−19
@@ -519,22 +519,10 @@ final class StubURLProtocol: URLProtocol {
519 Self.outcomes.append(contentsOf: outcomes)
519 Self.outcomes.append(contentsOf: outcomes)
520 }
520 }
521
521
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−
533 static func reset() {
522 static func reset() {
534 lock.lock()
523 lock.lock()
535 defer { lock.unlock() }
524 defer { lock.unlock() }
536 outcomes = []
525 outcomes = []
537− urlMatchedOutcomes = [:]
538 lastOutcome = nil
526 lastOutcome = nil
539 requests = []
527 requests = []
540 bodies = []
528 bodies = []
@@ -560,19 +548,12 @@ final class StubURLProtocol: URLProtocol {
560 }
548 }
561
549
562 /// Records the request and pops the outcome that should answer it.
550 /// 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.
565 private static func nextOutcome(for request: URLRequest) -> Outcome {
551 private static func nextOutcome(for request: URLRequest) -> Outcome {
566 lock.lock()
552 lock.lock()
567 defer { lock.unlock() }
553 defer { lock.unlock() }
568 requests.append(request)
554 requests.append(request)
569 bodies.append(drainBody(from: request))
555 bodies.append(drainBody(from: request))
570
556
571− if let path = request.url?.path,
572− let matched = urlMatchedOutcomes[path] {
573− return matched
574− }
575−
576 if !outcomes.isEmpty {
557 if !outcomes.isEmpty {
577 lastOutcome = outcomes.removeFirst()
558 lastOutcome = outcomes.removeFirst()
578 }
559 }
UsageMenubarTests/StatusBarTextTests.swift+37−190
@@ -13,21 +13,17 @@ final class StatusBarTextTests: XCTestCase {
13 func testBothServices() {
13 func testBothServices() {
14 let title = ViewModel.statusBarText(
14 let title = ViewModel.statusBarText(
15 balance: 42, isLoading: false,
15 balance: 42, isLoading: false,
16− hyperConfigured: true,
17 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
16 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
17+ zaiFiveHourPercent: nil
19− zaiInPeakHours: false
20 )
18 )
21− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
19+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
22 }
20 }
23
21
24 func testHyperOnly() {
22 func testHyperOnly() {
25 let title = ViewModel.statusBarText(
23 let title = ViewModel.statusBarText(
26 balance: 42, isLoading: false,
24 balance: 42, isLoading: false,
27− hyperConfigured: true,
28 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
25 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
29− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
26+ zaiFiveHourPercent: nil
30− zaiInPeakHours: false
31 )
27 )
32 XCTAssertEqual(title, "⚡42")
28 XCTAssertEqual(title, "⚡42")
33 }
29 }
@@ -35,140 +31,85 @@ final class StatusBarTextTests: XCTestCase {
35 func testClaudeOnly() {
31 func testClaudeOnly() {
36 let title = ViewModel.statusBarText(
32 let title = ViewModel.statusBarText(
37 balance: nil, isLoading: false,
33 balance: nil, isLoading: false,
38− hyperConfigured: false,
39 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
34 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
40− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
35+ zaiFiveHourPercent: nil
41− zaiInPeakHours: false
42 )
36 )
43− XCTAssertEqual(title, "\u{2055}62% 8%")
37+ XCTAssertEqual(title, "🕐62% · 📅8%")
44 }
38 }
45
39
46 func testLoadingWithNothingToShow() {
40 func testLoadingWithNothingToShow() {
47 let title = ViewModel.statusBarText(
41 let title = ViewModel.statusBarText(
48 balance: nil, isLoading: true,
42 balance: nil, isLoading: true,
49− hyperConfigured: false,
50 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
43 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
51− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
44+ zaiFiveHourPercent: nil
52− zaiInPeakHours: false
53 )
45 )
54− XCTAssertEqual(title, "…")
46+ XCTAssertEqual(title, "⚡…")
55 }
47 }
56
48
57 func testNothingToShow() {
49 func testNothingToShow() {
58 let title = ViewModel.statusBarText(
50 let title = ViewModel.statusBarText(
59 balance: nil, isLoading: false,
51 balance: nil, isLoading: false,
60− hyperConfigured: false,
61 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
52 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
62− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
53+ zaiFiveHourPercent: nil
63− zaiInPeakHours: false
64 )
54 )
65− XCTAssertEqual(title, "")
55+ XCTAssertEqual(title, "⚡?")
66 }
56 }
67
57
68 /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
58 /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
69− /// without a Hyper key refreshes every few minutes, and a title that fell back to `…`
59+ /// without a Hyper key refreshes every few minutes, and a title that fell back to `⚡…`
70 /// each time would blink their percentage away and back.
60 /// each time would blink their percentage away and back.
71 func testLoadingKeepsAClaudePercentOnScreen() {
61 func testLoadingKeepsAClaudePercentOnScreen() {
72 let title = ViewModel.statusBarText(
62 let title = ViewModel.statusBarText(
73 balance: nil, isLoading: true,
63 balance: nil, isLoading: true,
74− hyperConfigured: false,
75 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
64 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
76− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
65+ zaiFiveHourPercent: nil
77− zaiInPeakHours: false
78 )
66 )
79− XCTAssertEqual(title, "\u{2055}62%")
67+ XCTAssertEqual(title, "🕐62%")
80 }
68 }
81
69
82 func testLoadingKeepsAStaleBalanceOnScreen() {
70 func testLoadingKeepsAStaleBalanceOnScreen() {
83 let title = ViewModel.statusBarText(
71 let title = ViewModel.statusBarText(
84 balance: 42, isLoading: true,
72 balance: 42, isLoading: true,
85− hyperConfigured: true,
86 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
73 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
87− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
74+ zaiFiveHourPercent: nil
88− zaiInPeakHours: false
89 )
75 )
90− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
76+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
91 }
77 }
92
78
93− func testZeroPercentIsShown() {
79+ func testZeroPercentIsShownRatherThanOmitted() {
94 let title = ViewModel.statusBarText(
80 let title = ViewModel.statusBarText(
95 balance: 42, isLoading: false,
81 balance: 42, isLoading: false,
96− hyperConfigured: true,
97 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
82 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
98− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
83+ zaiFiveHourPercent: nil
99− zaiInPeakHours: false
100 )
84 )
101− XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
85+ XCTAssertEqual(title, "⚡42 · 📅8%")
102 }
86 }
103
87
104 func testBothClaudeWindowsZero() {
88 func testBothClaudeWindowsZero() {
105 let title = ViewModel.statusBarText(
89 let title = ViewModel.statusBarText(
106 balance: 42, isLoading: false,
90 balance: 42, isLoading: false,
107− hyperConfigured: true,
108 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
91 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
109− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
92+ zaiFiveHourPercent: nil
110− zaiInPeakHours: false
111 )
93 )
112− XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
94+ XCTAssertEqual(title, "⚡42")
113 }
95 }
114
96
115 func testFiveHourOnly() {
97 func testFiveHourOnly() {
116 let title = ViewModel.statusBarText(
98 let title = ViewModel.statusBarText(
117 balance: nil, isLoading: false,
99 balance: nil, isLoading: false,
118− hyperConfigured: false,
119 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
100 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
120− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
101+ zaiFiveHourPercent: nil
121− zaiInPeakHours: false
122 )
102 )
123− XCTAssertEqual(title, "\u{2055}62%")
103+ XCTAssertEqual(title, "🕐62%")
124 }
104 }
125
105
126 func testSevenDayOnly() {
106 func testSevenDayOnly() {
127 let title = ViewModel.statusBarText(
107 let title = ViewModel.statusBarText(
128 balance: nil, isLoading: false,
108 balance: nil, isLoading: false,
129− hyperConfigured: false,
130 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
109 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
131− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110+ zaiFiveHourPercent: nil
132− zaiInPeakHours: false
133 )
111 )
134− XCTAssertEqual(title, "\u{2055}8%")
112+ XCTAssertEqual(title, "📅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%")
172 }
113 }
173
114
174 // MARK: - z.ai in Title
115 // MARK: - z.ai in Title
@@ -176,149 +117,55 @@ final class StatusBarTextTests: XCTestCase {
176 func testZaiOnly() {
117 func testZaiOnly() {
177 let title = ViewModel.statusBarText(
118 let title = ViewModel.statusBarText(
178 balance: nil, isLoading: false,
119 balance: nil, isLoading: false,
179− hyperConfigured: false,
180 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
120 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
181− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
121+ zaiFiveHourPercent: 12
182− zaiInPeakHours: false
183 )
122 )
184− XCTAssertEqual(title, "\u{005A}12%")
123+ XCTAssertEqual(title, "🤖12%")
185 }
124 }
186
125
187 func testZaiWithHyper() {
126 func testZaiWithHyper() {
188 let title = ViewModel.statusBarText(
127 let title = ViewModel.statusBarText(
189 balance: 42, isLoading: false,
128 balance: 42, isLoading: false,
190− hyperConfigured: true,
191 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
129 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
192− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
130+ zaiFiveHourPercent: 12
193− zaiInPeakHours: false
194 )
131 )
195− XCTAssertEqual(title, "⚡42 · \u{005A}12%")
132+ XCTAssertEqual(title, "⚡42 · 🤖12%")
196 }
133 }
197
134
198 func testZaiWithClaude() {
135 func testZaiWithClaude() {
199 let title = ViewModel.statusBarText(
136 let title = ViewModel.statusBarText(
200 balance: nil, isLoading: false,
137 balance: nil, isLoading: false,
201− hyperConfigured: false,
202 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
138 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
203− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
139+ zaiFiveHourPercent: 12
204− zaiInPeakHours: false
205 )
140 )
206− XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
141+ XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
207 }
142 }
208
143
209 func testAllThreeServices() {
144 func testAllThreeServices() {
210 let title = ViewModel.statusBarText(
145 let title = ViewModel.statusBarText(
211 balance: 42, isLoading: false,
146 balance: 42, isLoading: false,
212− hyperConfigured: true,
213 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
147 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
214− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
148+ zaiFiveHourPercent: 12
215− zaiInPeakHours: false
216 )
149 )
217− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
150+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12%")
218 }
151 }
219
152
220− func testZaiZeroIsShown() {
153+ func testZaiZeroIsOmitted() {
221 let title = ViewModel.statusBarText(
154 let title = ViewModel.statusBarText(
222 balance: 42, isLoading: false,
155 balance: 42, isLoading: false,
223− hyperConfigured: true,
224 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
156 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
225− zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
157+ zaiFiveHourPercent: 0
226− zaiInPeakHours: false
227 )
158 )
228− XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
159+ XCTAssertEqual(title, "⚡42")
229 }
160 }
230
161
231 func testLoadingKeepsZaiPercentOnScreen() {
162 func testLoadingKeepsZaiPercentOnScreen() {
232 let title = ViewModel.statusBarText(
163 let title = ViewModel.statusBarText(
233 balance: nil, isLoading: true,
164 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,
248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
165 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
166+ zaiFiveHourPercent: 12
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
319 )
167 )
320− // No z.ai segment, so peak marker has nothing to attach to.
321− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
168+ XCTAssertEqual(title, "🤖12%")
322 }
169 }
323
170
324 // MARK: - Claude Window Percentages
171 // MARK: - Claude Window Percentages
@@ -375,4 +222,4 @@ final class StatusBarTextTests: XCTestCase {
375 XCTAssertNil(vm.claudeFiveHourPercent)
222 XCTAssertNil(vm.claudeFiveHourPercent)
376 XCTAssertNil(vm.claudeSevenDayPercent)
223 XCTAssertNil(vm.claudeSevenDayPercent)
377 }
224 }
378−}
225+}
UsageMenubarTests/ZaiUsageClientTests.swift+28−23
@@ -22,18 +22,6 @@ final class ZaiUsageClientTests: XCTestCase {
22 return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
22 return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
23 }
23 }
24
24
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−
37 // MARK: - Decoding
25 // MARK: - Decoding
38
26
39 func testDecodeQuotaResponse() throws {
27 func testDecodeQuotaResponse() throws {
@@ -90,6 +78,21 @@ final class ZaiUsageClientTests: XCTestCase {
90
78
91 // MARK: - Fetch
79 // MARK: - Fetch
92
80
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+
93 func testFetchUsageSuccess() async throws {
96 func testFetchUsageSuccess() async throws {
94 let quotaJSON = """
97 let quotaJSON = """
95 {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
98 {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
@@ -127,11 +130,12 @@ final class ZaiUsageClientTests: XCTestCase {
127 }
130 }
128
131
129 func testFetchUsageInvalidKey() async {
132 func testFetchUsageInvalidKey() async {
133+ // Both endpoints will 401 — the quota endpoint's 401 is what surfaces.
130 let errorJSON = """
134 let errorJSON = """
131 {"code":401,"msg":"token expired or incorrect","success":false}
135 {"code":401,"msg":"token expired or incorrect","success":false}
132 """.data(using: .utf8)!
136 """.data(using: .utf8)!
133− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
137+ StubURLProtocol.enqueue(.status(401, errorJSON))
134− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
138+ StubURLProtocol.enqueue(.status(401, errorJSON))
135
139
136 let client = makeStubbedClient()
140 let client = makeStubbedClient()
137 do {
141 do {
@@ -156,10 +160,11 @@ final class ZaiUsageClientTests: XCTestCase {
156 {"code":200,"data":[],"success":true}
160 {"code":200,"data":[],"success":true}
157 """.data(using: .utf8)!
161 """.data(using: .utf8)!
158
162
159− // Subscription succeeds on first try. Quota fails with 500, then succeeds.
163+ // Quota: 500 then 200 (retry). Subscription: 200 (first try).
160− StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
164+ // The order is: quota(500), subscription(200), quota(200 on retry).
161− StubURLProtocol.enqueue(.status(500, Data()), forURLPath: "/api/monitor/usage/quota/limit")
165+ StubURLProtocol.enqueue(.status(500, Data()))
166+ StubURLProtocol.enqueue(.status(200, subscriptionJSON))
162− StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
167+ StubURLProtocol.enqueue(.status(200, quotaJSON))
163
168
164 let client = makeStubbedClient()
169 let client = makeStubbedClient()
165 let report = try await client.fetchUsage(apiKey: "test-key")
170 let report = try await client.fetchUsage(apiKey: "test-key")
@@ -176,9 +181,9 @@ final class ZaiUsageClientTests: XCTestCase {
176 {"code":200,"data":[],"success":true}
181 {"code":200,"data":[],"success":true}
177 """.data(using: .utf8)!
182 """.data(using: .utf8)!
178
183
179− StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
184+ StubURLProtocol.enqueue(.status(429, Data()))
180− StubURLProtocol.enqueue(.status(429, Data()), forURLPath: "/api/monitor/usage/quota/limit")
185+ StubURLProtocol.enqueue(.status(200, subscriptionJSON))
181− StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
186+ StubURLProtocol.enqueue(.status(200, quotaJSON))
182
187
183 let client = makeStubbedClient()
188 let client = makeStubbedClient()
184 let report = try await client.fetchUsage(apiKey: "test-key")
189 let report = try await client.fetchUsage(apiKey: "test-key")
@@ -189,8 +194,8 @@ final class ZaiUsageClientTests: XCTestCase {
189 let errorJSON = """
194 let errorJSON = """
190 {"code":401,"msg":"token expired or incorrect","success":false}
195 {"code":401,"msg":"token expired or incorrect","success":false}
191 """.data(using: .utf8)!
196 """.data(using: .utf8)!
192− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
197+ StubURLProtocol.enqueue(.status(401, errorJSON))
193− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
198+ StubURLProtocol.enqueue(.status(401, errorJSON))
194
199
195 let client = makeStubbedClient()
200 let client = makeStubbedClient()
196 do {
201 do {
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
6options:
6options:
7 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
7 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
8 # The app target pins its own id explicitly below, so this is just a default.
8 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: soy.iko
9+ bundleIdPrefix: com.ilyakooo0
10 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
10 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
11 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
11 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
12 deploymentTarget:
12 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
47 properties:
47 properties:
48 CFBundleName: UsageMenubar
48 CFBundleName: UsageMenubar
49 CFBundleDisplayName: Usage Menubar
49 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: soy.iko.usage-menubar
50+ CFBundleIdentifier: com.ilyakooo0.usage-menubar
51 # Overwritten by CI — see the note on MARKETING_VERSION above.
51 # Overwritten by CI — see the note on MARKETING_VERSION above.
52 CFBundleVersion: "1"
52 CFBundleVersion: "1"
53 CFBundleShortVersionString: "1.0.0"
53 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
57 NSHighResolutionCapable: true
57 NSHighResolutionCapable: true
58 settings:
58 settings:
59 base:
59 base:
60− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
61 PRODUCT_NAME: UsageMenubar
61 PRODUCT_NAME: UsageMenubar
62 INFOPLIST_FILE: UsageMenubar/Info.plist
62 INFOPLIST_FILE: UsageMenubar/Info.plist
63 # Grants the outgoing-network-connection entitlement needed to reach
63 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
84 - target: UsageMenubar
84 - target: UsageMenubar
85 settings:
85 settings:
86 base:
86 base:
87− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
88 PRODUCT_NAME: UsageMenubarTests
88 PRODUCT_NAME: UsageMenubarTests
89 BUNDLE_LOADER: "$(TEST_HOST)"
89 BUNDLE_LOADER: "$(TEST_HOST)"
90 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"
90 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"