on 0f40344 help sign in
9 files changed+320−662
since splitsnapshots
unifiedsplit
CLAUDE.md+10−35 README.md+42−64 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+2−2 UsageMenubar/MenuView.swift+172−225 UsageMenubar/UsageMenubarApp.swift+1−2 UsageMenubar/ViewModel.swift+42−223 UsageMenubarTests/StatusBarTextTests.swift+46−106 project.yml+4−4
CLAUDE.md+10−35
@@ -1,51 +1,26 @@
11# UsageMenubar
22
33## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
5−visual treatment:
6−- **Hyper** (Charm) — credit balance
7−- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
8−- **z.ai Coding Plan** — usage limits (5-hour, weekly)
9−
10−Each configured provider gets the same popover treatment: a 26pt subhero headline
11−number, bars under every window, a sparkline of recent history, a trend arrow,
12−click-to-copy on the headline, and threshold-crossing notifications.
13−
4+macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5+Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
6+z.ai Coding Plan usage limits when an API key is entered in settings.
147- `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
16−- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
8+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
9+- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
1710- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
1811- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
1912- `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`)
2013- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
2114- `VersionFormatter.swift` — Version string formatting
2215
23−The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24−`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
16+The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
17+`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
2518(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
2619service is not configured (no API key or no credentials). An unconfigured service
2720produces no placeholder in the menu bar. The three fetches are independent —
2821`ViewModel.refresh()` runs them with `async let`, and any can fail or be
2922unconfigured without touching the others.
3023
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−
4924## Claude credentials (strictly read-only)
5025`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
5126Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
@@ -75,9 +50,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
7550- Swift 5.9, macOS 14.0 deployment target
7651
7752## Conventions
78−- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
53+- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
7954- No card-heavy/multi-material/gradient-rich designs
80−- Rounded font family at one scale (subhero/section/control/caption/footnote)
55+- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
8156- Monospaced digits for numbers that change
8257- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
8358 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -85,4 +60,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
8560
8661## VCS
8762- jj colocated with git
88−- Push directly to master — no PRs for this repo
63+- Push directly to master — no PRs for this repo
README.md+42−64
@@ -4,33 +4,24 @@
44
55# UsageMenubar
66
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.
88
99## Features
1010
11−- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12−- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13−- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
14−- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
15−- 🖱️ **Right-click menu** — quick access to Refresh and Quit
16−- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
11+- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
12+- 🤖 **Claude Code limits** — your Pro/Max 5-hour and 7-day usage windows in the popover, with the percentage used and a countdown to the next reset. No setup: if you have signed in with the Claude Code CLI, it just appears. If you haven't, the section doesn't
13+- 🔄 **Auto-refresh** — fetches your balance on a configurable interval (1m / 5m / 15m / 30m), and again on wake from sleep
14+- 📊 **Balance sparkline** — a subtle mini-chart in the popover shows your balance trend over recent fetches
15+- 📋 **Click-to-copy** — click the balance number in the popover to copy it to the clipboard
16+- 🖱️ **Right-click menu** — right-click the menu bar icon for quick access to Refresh, Open hyper.charm.land, and Quit
17+- 🔔 **Low-balance notifications** — alerts you when your balance drops below 10 credits, including on the very first fetch if you are already below the threshold
18+- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
19+- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
20+- 🔐 **Secure input** — API key field is masked
1721- 🚀 **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
1823- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
1924
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−
3425## Installation
3526
3627### Homebrew (recommended)
@@ -51,47 +42,37 @@ brew install --cask ilyakooo0/tap/usage-menubar
51421. Go to the [Releases page](https://github.com/ilyakooo0/usage-menubar/releases)
52432. Download the latest `UsageMenubar-*.zip`
53443. 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 ⚡
5546
5647> **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.
5748
58−## Setup
49+### Set your API key
5950
60−Click the menu bar icon to open the popover. Each service is configured independently:
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
6155
62−### Hyper credits
56+You can get your API key from [hyper.charm.land](https://hyper.charm.land).
6357
64−1. Enter your Hyper API key (starts with `sk-...`) in the **Hyper API Key** field
65−2. Click **Save**
66−3. Your balance appears immediately
58+### Claude Code limits (optional, no setup)
6759
68−Get your key from [hyper.charm.land](https://hyper.charm.land).
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.
6961
70−### Claude Code limits (no setup)
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.
7163
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.
73−
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:
7565
7666- **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`.
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).
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.
8668
8769## Settings
8870
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.
9072
9173| Setting | Where | Notes |
9274| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
93−| **Hyper API key** | **Hyper API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
94−| **z.ai API key** | **z.ai API Key** field → **Save** | Stored separately from the Hyper key. |
75+| **API key** | **API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
9576| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
9677| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
9778| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
@@ -157,21 +138,18 @@ usage-menubar/
157138├── project.yml # xcodegen project definition
158139├── UsageMenubar/
159140│ ├── 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
161142│ ├── MenuView.swift # SwiftUI popover view
162−│ ├── CreditsChecker.swift # Hyper API client (GET /v1/credits)
143+│ ├── CreditsChecker.swift # API client (GET /v1/credits)
163144│ ├── 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
166146│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
167147│ ├── Info.plist # LSUIElement=true (agent app)
168−│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
148+│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
169149│ └── Assets.xcassets/ # App icon, accent color
170150├── UsageMenubarTests/
171−│ ├── CreditsCheckerTests.swift # Hyper API, retry, keychain, StubURLProtocol
151+│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
172152│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
173−│ ├── ZaiUsageClientTests.swift # z.ai API, retry, decoding
174−│ ├── StatusBarTextTests.swift # Menu bar title formatting
175153│ └── VersionFormatterTests.swift # Version formatting tests
176154├── .github/workflows/
177155│ └── release.yml # Build + release on push to master
@@ -182,18 +160,18 @@ usage-menubar/
182160
183161## How it works
184162
185−1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button. Left-click toggles the popover; right-click opens a context menu (Refresh, Quit).
186−2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with each configured service's data and the settings.
187−3. **Independent fetches**: `ViewModel.refresh()` runs the Hyper, Claude, and z.ai requests concurrently with `async let`. Any can fail, stall, or be unconfigured without affecting the others.
188−4. **Hyper API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures with exponential backoff, and decodes `{"balance": <int>}`.
189−5. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage`. It is strictly read-only — it never refreshes the token and never writes credentials back. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
190−6. **z.ai usage**: `ZaiUsageClient` fetches `GET https://api.z.ai/api/monitor/usage/quota/limit` and `GET https://api.z.ai/api/biz/subscription/list` concurrently with an API key the user enters in settings. Same retry pattern as the other clients.
191−7. **Keychain**: API keys are stored/retrieved via `KeychainHelper` using the macOS Security framework. Hyper and z.ai keys are stored separately.
192−8. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m); the app also refreshes on wake from sleep.
193−9. **Notifications**: When the Hyper balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
194−10. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
163+1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`. Left-click toggles the popover; right-click opens a context menu (Refresh, Open hyper.charm.land, Quit).
164+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with the balance, sparkline, API key field, and settings.
165+3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures (5xx, timeouts) with exponential backoff, and decodes `{"balance": <int>}` (tolerating fractional values by rounding).
166+4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework. `save()` returns a `Bool` indicating success.
167+5. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m) to refresh the balance; the app also refreshes on wake from sleep. Changing the interval in the popover restarts the timer immediately.
168+6. **State**: `ViewModel` owns the balance, loading/error state, balance history (for the sparkline), refresh task (cancellable to prevent races), and the refresh interval, and is shared between the menu bar item and the popover.
169+7. **Notifications**: When the balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
170+8. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
171+9. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. It is strictly read-only — it never refreshes the token and never writes credentials back. The token endpoint rotates the refresh token, so an app that also refreshed would race the CLI and sign the user out of their own terminal. When the token is spent (401/403), the app says so and points at `claude`; the next fetch (every 1–5 min, credentials re-read each time) picks up whatever the CLI refreshed on its own. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
172+10. **Independent fetches**: `ViewModel.refresh()` runs the Hyper and Claude requests concurrently with `async let`. Neither can fail, stall, or be unconfigured in a way that affects the other.
19517311. **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.
196174
197175## License
198176
199−MIT — see [LICENSE](LICENSE).
177+MIT — see [LICENSE](LICENSE).
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>soy.iko.usage-menubar</string>
10+ <string>com.ilyakooo0.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "soy.iko.usage-menubar"
85+ private static let service = "com.ilyakooo0.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112112/// Uses the same keychain service as the Hyper key but a different account, so the
113113/// two keys coexist without clobbering each other.
114114enum ZaiKeychainHelper {
115− private static let service = "soy.iko.usage-menubar"
115+ private static let service = "com.ilyakooo0.usage-menubar"
116116 private static let account = "zai-api-key"
117117
118118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+172−225
@@ -4,15 +4,21 @@ import SwiftUI
44struct MenuView: View {
55 @ObservedObject var viewModel: ViewModel
66
7+ /// Dims the balance while the pointer is over it, hinting that it is clickable.
8+ @State private var isHoveringBalance = false
9+
710 private static let websiteURL = URL(string: "https://hyper.charm.land")!
811 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
912
1013 // MARK: - Type Scale
1114
12− /// One rounded family, sized so that every provider's headline number reads at the
13− /// same weight. With three providers in the popover, the 26pt subhero is the right
14− /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15− /// numbers from jittering as their digits change.
15+ /// One rounded family, sized so that the balance is the only large element and
16+ /// everything else steps down from it — nothing competes with it for attention.
17+ /// The Claude percentage is the one exception, and it is deliberately set well
18+ /// below the balance so it reads as the second thing on the page, not the first.
19+ /// Monospaced digits keep numbers from jittering as their digits change.
20+ private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
21+ .monospacedDigit()
1622 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
1723 .monospacedDigit()
1824 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -22,9 +28,7 @@ struct MenuView: View {
2228
2329 var body: some View {
2430 VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
26− hyper
27− }
31+ hyper
2832 if showsClaude {
2933 serviceBreak
3034 claude
@@ -47,14 +51,10 @@ struct MenuView: View {
4751 .background(Color(nsColor: .windowBackgroundColor))
4852 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
4953 .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)
5254 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
5355 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
5456 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55− .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56− .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57− .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
5858 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
5959 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
6060 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,14 +66,15 @@ struct MenuView: View {
6666 Divider().opacity(0.3)
6767 }
6868
69− /// The rule between services. Wider than the others on purpose: the providers are
70− /// unrelated accounts, and the gap should say so before the headers do.
69+ /// The rule between the two services. Wider than the others on purpose: Hyper and
70+ /// Claude are two unrelated accounts, and the gap should say so before the headers do.
7171 private var serviceBreak: some View {
7272 hairline.padding(.vertical, 5)
7373 }
7474
7575 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three 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.
7778 private func sectionHeader(_ title: String, systemImage: String) -> some View {
7879 HStack(spacing: 5) {
7980 Image(systemName: systemImage)
@@ -84,62 +85,113 @@ struct MenuView: View {
8485 }
8586 }
8687
87− /// A reusable headline number row: the provider's main metric at subhero scale,
88− /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89− /// copy-confirmation slot so the layout doesn't shift.
90− private func headlineNumber(
91− text: String,
92− color: Color,
93− trend: MetricTrend,
94− copiedConfirmation: Bool,
95− captionText: String,
96− copyAction: @escaping () -> Void
97− ) -> some View {
98− VStack(spacing: 6) {
99− Button(action: copyAction) {
100− HStack(alignment: .firstTextBaseline, spacing: 6) {
101− Text(text)
102− .font(Self.subheroFont)
103− .foregroundColor(color)
104− .contentTransition(.opacity)
88+ // MARK: - Hyper
10589
106− if let symbol = trend.symbolName {
107− Image(systemName: symbol)
108− .font(.system(size: 12, weight: .semibold))
109− .foregroundColor(.secondary)
110− .transition(.opacity)
111− }
90+ /// The Hyper section: its header, then the balance as the popover's hero.
91+ private var hyper: some View {
92+ VStack(alignment: .leading, spacing: 12) {
93+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
94+
95+ hero
96+ }
97+ }
98+
99+ private var hero: some View {
100+ VStack(spacing: 8) {
101+ if viewModel.isLoading && viewModel.balance == nil {
102+ PulsingBolt()
103+ .transition(.opacity)
104+ } else if viewModel.balance == nil {
105+ VStack(spacing: 6) {
106+ Text("⚡?")
107+ .font(Self.heroFont)
108+ .foregroundColor(.secondary)
109+
110+ Text("Enter your API key below")
111+ .font(Self.captionFont)
112+ .foregroundColor(.secondary)
113+ }
114+ .transition(.opacity)
115+ } else {
116+ VStack(spacing: 6) {
117+ balanceNumber
118+ caption
119+ sparkline
112120 }
113− .contentShape(Rectangle())
121+ .transition(.opacity)
114122 }
115− .buttonStyle(.plain)
116− .help("Click to copy")
117−
118− // Caption doubles as copy confirmation so the layout doesn't shift.
119− Group {
120− if copiedConfirmation {
121− Text("✓ Copied")
122− .foregroundColor(.green)
123− } else {
124− Text(captionText)
123+
124+ if let lastUpdatedText = viewModel.lastUpdatedText {
125+ Text(lastUpdatedText)
126+ .font(Self.footnoteFont)
127+ .foregroundColor(.secondary)
128+ .transition(.opacity)
129+ }
130+
131+ if let error = viewModel.errorMessage {
132+ errorRow(error)
133+ .transition(.opacity)
134+ }
135+ }
136+ .frame(maxWidth: .infinity)
137+ }
138+
139+ /// The balance itself. Clicking it copies the raw number to the clipboard.
140+ private var balanceNumber: some View {
141+ Button(action: { viewModel.copyBalanceToClipboard() }) {
142+ HStack(alignment: .firstTextBaseline, spacing: 6) {
143+ Text(viewModel.formattedBalance)
144+ .font(Self.heroFont)
145+ .foregroundColor(viewModel.balanceColor)
146+ .contentTransition(.opacity)
147+
148+ if let symbol = viewModel.balanceTrend.symbolName {
149+ Image(systemName: symbol)
150+ .font(.system(size: 12, weight: .semibold))
125151 .foregroundColor(.secondary)
152+ .transition(.opacity)
126153 }
127154 }
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+ }
129176 }
177+ .font(Self.captionFont)
130178 }
131179
132− /// A sparkline shown when there are at least 2 history points.
133180 @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
135− if values.count >= 2 {
136− Sparkline(values: values, color: color)
181+ private var sparkline: some View {
182+ if historyValues.count >= 2 {
183+ Sparkline(values: historyValues, color: viewModel.balanceColor)
137184 .frame(height: 26)
138185 .padding(.top, 4)
139186 .transition(.opacity)
140187 }
141188 }
142189
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+
143195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144196 /// message, and a tinted rounded background with no border.
145197 private func errorRow(_ message: String) -> some View {
@@ -164,56 +216,6 @@ struct MenuView: View {
164216 .padding(.top, 2)
165217 }
166218
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−
217219 // MARK: - Claude
218220
219221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -225,8 +227,8 @@ struct MenuView: View {
225227 return false
226228 }
227229
228− /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229− /// headline number, bars under every window, a sparkline, and error row.
230+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
231+ /// with the individual windows underneath as quiet supporting detail.
230232 private var claude: some View {
231233 VStack(alignment: .leading, spacing: 12) {
232234 HStack(spacing: 8) {
@@ -253,31 +255,23 @@ struct MenuView: View {
253255
254256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255257 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.
257260 if let fiveHour = usage.fiveHour {
258261 let percent = Int(fiveHour.utilization.rounded())
259− VStack(spacing: 8) {
260− HStack(alignment: .firstTextBaseline, spacing: 8) {
261− headlineNumber(
262− text: "⁕ \\(percent)%",
263− color: Self.usageColor(percent),
264− trend: viewModel.claudeTrend,
265− copiedConfirmation: viewModel.copiedClaudeConfirmation,
266− captionText: "5-hour usage",
267− copyAction: { viewModel.copyClaudePercentToClipboard() }
268− )
262+ HStack(alignment: .firstTextBaseline, spacing: 8) {
263+ Text("🕐 \(percent)%")
264+ .font(Self.subheroFont)
265+ .foregroundColor(Self.usageColor(percent))
266+ .contentTransition(.opacity)
269267
270− Spacer(minLength: 0)
268+ Spacer(minLength: 0)
271269
272− if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
274− .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
276− }
270+ if let resets = fiveHour.resetsInFormatted {
271+ Text("resets in \(resets)")
272+ .font(Self.footnoteFont)
273+ .foregroundColor(.secondary)
277274 }
278−
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
281275 }
282276 }
283277
@@ -291,12 +285,6 @@ struct MenuView: View {
291285 window("7-day Opus", opus)
292286 }
293287 }
294−
295− // Sparkline for the Claude 5-hour % history.
296− sparkline(
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
299− )
300288 }
301289 }
302290
@@ -322,11 +310,6 @@ struct MenuView: View {
322310 }
323311 }
324312
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−
330313 /// The balance colors read the other way round here, because for a limit more is
331314 /// worse: green with room to spare, yellow closing in, red nearly spent.
332315 private static func usageColor(_ percent: Int) -> Color {
@@ -346,8 +329,8 @@ struct MenuView: View {
346329 return false
347330 }
348331
349− /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350− /// a subhero headline number, bars under every window, a sparkline, and error row.
332+ /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
333+ /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
351334 private var zai: some View {
352335 VStack(alignment: .leading, spacing: 12) {
353336 HStack(spacing: 8) {
@@ -374,30 +357,21 @@ struct MenuView: View {
374357
375358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376359 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.
378361 if let fiveHour = usage.fiveHourPercent {
379− VStack(spacing: 8) {
380− HStack(alignment: .firstTextBaseline, spacing: 8) {
381− headlineNumber(
382− text: "Z \\(fiveHour)%",
383− color: Self.usageColor(fiveHour),
384− trend: viewModel.zaiTrend,
385− copiedConfirmation: viewModel.copiedZaiConfirmation,
386− captionText: "5-hour usage",
387− copyAction: { viewModel.copyZaiPercentToClipboard() }
388− )
362+ HStack(alignment: .firstTextBaseline, spacing: 8) {
363+ Text("🤖 \(fiveHour)%")
364+ .font(Self.subheroFont)
365+ .foregroundColor(Self.usageColor(fiveHour))
366+ .contentTransition(.opacity)
389367
390− Spacer(minLength: 0)
368+ Spacer(minLength: 0)
391369
392− if let resets = usage.fiveHourResetsAt {
393− Text("resets in \(Self.zaiResetsIn(from: resets))")
394− .font(Self.footnoteFont)
395− .foregroundColor(.secondary)
396− }
370+ if let resets = usage.fiveHourResetsAt {
371+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372+ .font(Self.footnoteFont)
373+ .foregroundColor(.secondary)
397374 }
398−
399− // Bar under the 5-hour window too.
400− UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401375 }
402376 }
403377
@@ -406,35 +380,9 @@ struct MenuView: View {
406380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407381 }
408382 }
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− }
430383 }
431384 }
432385
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−
438386 /// One window row for z.ai: label, percentage, and a hairline bar.
439387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440388 VStack(alignment: .leading, spacing: 5) {
@@ -454,42 +402,6 @@ struct MenuView: View {
454402 }
455403 }
456404
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−
493405 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494406 private static func zaiResetsIn(from date: Date) -> String {
495407 let interval = date.timeIntervalSince(Date())
@@ -611,10 +523,45 @@ struct MenuView: View {
611523 .keyboardShortcut("q")
612524 }
613525 .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)
614532 }
615533 }
616534}
617535
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+
618565// MARK: - Usage Bar
619566
620567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -645,8 +592,8 @@ private struct UsageBar: View {
645592
646593// MARK: - Sparkline
647594
648−/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
649−/// takes the metric's color so the trend reads as part of the number above it, with a
595+/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
596+/// takes the balance's color so the trend reads as part of the number above it, with a
650597/// faint fade underneath for weight and a dot marking the latest point.
651598///
652599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -738,4 +685,4 @@ struct Sparkline: View {
738685 path.closeSubpath()
739686 }
740687 }
741−}
688+}
UsageMenubar/UsageMenubarApp.swift+1−2
@@ -89,8 +89,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
8989 claudeFiveHourPercent: fiveHour,
9090 claudeSevenDayPercent: sevenDay,
9191 zaiFiveHourPercent: zaiFiveHour,
92− zaiWeeklyPercent: zaiWeekly,
93− zaiInPeakHours: ViewModel.zaiInPeakHours
92+ zaiWeeklyPercent: zaiWeekly
9493 )
9594 }
9695 .removeDuplicates()
UsageMenubar/ViewModel.swift+42−223
@@ -7,11 +7,11 @@ import UserNotifications
77/// Posted when the user changes the refresh interval, so the app delegate can
88/// restart its timer with the new period.
99extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
1111}
1212
13−/// The direction a metric has moved over the most recent history points.
14−enum MetricTrend {
13+/// The direction the balance has moved over the most recent history points.
14+enum BalanceTrend {
1515 case increasing
1616 case decreasing
1717 case stable
@@ -29,11 +29,10 @@ enum MetricTrend {
2929 }
3030}
3131
32−/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33−/// and z.ai 5-hour % alike.
34−struct MetricPoint: Equatable {
32+/// A single balance observation.
33+struct BalancePoint: Equatable {
3534 let date: Date
36− let value: Int
35+ let balance: Int
3736}
3837
3938/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -112,20 +111,8 @@ final class ViewModel: ObservableObject {
112111 /// Brief "✓ Copied" confirmation shown after copying the balance.
113112 @Published var copiedConfirmation: Bool = false
114113
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−
121114 /// Successful balance observations from the last 24 hours, oldest first.
122− @Published private(set) var history: [MetricPoint] = []
123−
124− /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125− @Published private(set) var claudeHistory: [MetricPoint] = []
126−
127− /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128− @Published private(set) var zaiHistory: [MetricPoint] = []
115+ @Published private(set) var history: [BalancePoint] = []
129116
130117 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131118 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -176,12 +163,6 @@ final class ViewModel: ObservableObject {
176163 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177164 private var savedResetTask: Task<Void, Never>?
178165
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−
185166 // MARK: - History Configuration
186167
187168 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -189,9 +170,6 @@ final class ViewModel: ObservableObject {
189170 private static let maxHistoryEntries = 300
190171 private static let historyWindow: TimeInterval = 24 * 60 * 60
191172
192− /// The threshold above which a usage percentage triggers a notification.
193− private static let highUsageThreshold = 90
194−
195173 init(
196174 checker: CreditsChecking = CreditsChecker(),
197175 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -237,13 +215,11 @@ final class ViewModel: ObservableObject {
237215 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238216 }
239217
240− /// The text to show in the menu bar. Each configured provider contributes a
241− /// segment prefixed with a provider emoji; segments are separated by ` · `
242− /// (middle dot). Within a segment, windows are listed as bare percentages
243− /// separated by spaces. An unconfigured service produces no segment.
244− /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245− ///
246− /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
218+ /// The text to show in the menu bar: `⚡{balance}` (Hyper), `🕐{percent}%` (Claude
219+ /// 5-hour), `📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and
220+ /// `📆{percent}%` (z.ai weekly) — each omitted when absent or at 0%, and entirely
221+ /// absent when the service is not configured. An unconfigured service produces no
222+ /// placeholder in the menu bar.
247223 ///
248224 /// Takes its values as parameters rather than reading the properties: `@Published`
249225 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -255,50 +231,30 @@ final class ViewModel: ObservableObject {
255231 claudeFiveHourPercent: Int?,
256232 claudeSevenDayPercent: Int?,
257233 zaiFiveHourPercent: Int?,
258− zaiWeeklyPercent: Int?,
259− zaiInPeakHours: Bool
234+ zaiWeeklyPercent: Int?
260235 ) -> String {
261− var segments: [String] = []
262−
263− // Hyper
236+ var parts: [String] = []
264237 if hyperConfigured, let balance = balance {
265− segments.append("⚡\(formatBalance(balance))")
238+ parts.append("⚡\(formatBalance(balance))")
266239 }
267−
268− // Claude — provider emoji + bare percentages
269− var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent {
271− claudeWindows.append("\(fiveHour)%")
240+ if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
241+ parts.append("🕐\(fiveHour)%")
272242 }
273− if let sevenDay = claudeSevenDayPercent {
274− claudeWindows.append("\(sevenDay)%")
243+ if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
244+ parts.append("📅\(sevenDay)%")
275245 }
276− if !claudeWindows.isEmpty {
277− segments.append("⁕" + claudeWindows.joined(separator: " "))
246+ if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
247+ parts.append("🤖\(zaiFiveHour)%")
278248 }
279−
280− // z.ai — provider emoji + bare percentages
281− var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent {
283− zaiWindows.append("\(zaiFiveHour)%")
284− }
285− if let zaiWeekly = zaiWeeklyPercent {
286− zaiWindows.append("\(zaiWeekly)%")
249+ if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
250+ parts.append("📆\(zaiWeekly)%")
287251 }
288− if !zaiWindows.isEmpty {
289− var segment = "Z" + zaiWindows.joined(separator: " ")
290− if zaiInPeakHours {
291− segment += "🔶"
292− }
293− segments.append(segment)
294− }
295−
296− if segments.isEmpty {
252+ if parts.isEmpty {
297253 // No service is configured or has anything to show. While loading,
298254 // show an ellipsis; otherwise the title is empty.
299255 return isLoading ? "…" : ""
300256 }
301− return segments.joined(separator: " · ")
257+ return parts.joined(separator: " · ")
302258 }
303259
304260 /// The text to show in the menu bar for the current state.
@@ -310,17 +266,10 @@ final class ViewModel: ObservableObject {
310266 claudeFiveHourPercent: claudeFiveHourPercent,
311267 claudeSevenDayPercent: claudeSevenDayPercent,
312268 zaiFiveHourPercent: zaiFiveHourPercent,
313− zaiWeeklyPercent: zaiWeeklyPercent,
314− zaiInPeakHours: Self.zaiInPeakHours
269+ zaiWeeklyPercent: zaiWeeklyPercent
315270 )
316271 }
317272
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−
324273 /// The formatted balance string for display in the popover.
325274 var formattedBalance: String {
326275 guard let balance = balance else { return "?" }
@@ -341,69 +290,33 @@ final class ViewModel: ObservableObject {
341290 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342291 }
343292
344− // MARK: - History (Hyper)
293+ // MARK: - History
345294
346− /// The last 24 hours of successful balance fetches, oldest first.
295+ /// The last 24 hours of successful fetches, oldest first.
347296 var balanceHistory: [(date: Date, balance: Int)] {
348− history.map { (date: $0.date, balance: $0.value) }
297+ history.map { (date: $0.date, balance: $0.balance) }
349298 }
350299
351300 /// The direction of travel across the most recent handful of observations.
352301 /// `.unknown` until there are at least two points to compare.
353− var balanceTrend: MetricTrend {
354− metricTrend(history)
355− }
356−
357− /// The sparkline's data for the Hyper balance.
358− var balanceSparklineValues: [Int] {
359− history.map { $0.value }
360− }
361−
362− // MARK: - History (Claude)
363−
364− /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365− var claudeSparklineValues: [Int] {
366− claudeHistory.map { $0.value }
367− }
368−
369− /// The direction of travel for the Claude 5-hour %.
370− var claudeTrend: MetricTrend {
371− metricTrend(claudeHistory)
372− }
373−
374− // MARK: - History (z.ai)
375−
376− /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377− var zaiSparklineValues: [Int] {
378− zaiHistory.map { $0.value }
379− }
380−
381− /// The direction of travel for the z.ai 5-hour %.
382− var zaiTrend: MetricTrend {
383− metricTrend(zaiHistory)
384− }
385−
386− // MARK: - History Helpers
387−
388− /// Computes the trend from a generic history array.
389− private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
390− guard points.count >= 2 else { return .unknown }
391− let recent = points.suffix(5)
302+ var balanceTrend: BalanceTrend {
303+ guard history.count >= 2 else { return .unknown }
304+ let recent = history.suffix(5)
392305 guard let first = recent.first, let last = recent.last else { return .unknown }
393− if last.value > first.value { return .increasing }
394− if last.value < first.value { return .decreasing }
306+ if last.balance > first.balance { return .increasing }
307+ if last.balance < first.balance { return .decreasing }
395308 return .stable
396309 }
397310
398311 /// 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) {
400− array.append(MetricPoint(date: date, value: value))
312+ private func recordHistory(balance: Int, at date: Date) {
313+ history.append(BalancePoint(date: date, balance: balance))
401314
402315 let cutoff = date.addingTimeInterval(-Self.historyWindow)
403− array.removeAll { $0.date < cutoff }
316+ history.removeAll { $0.date < cutoff }
404317
405− if array.count > Self.maxHistoryEntries {
406− array.removeFirst(array.count - Self.maxHistoryEntries)
318+ if history.count > Self.maxHistoryEntries {
319+ history.removeFirst(history.count - Self.maxHistoryEntries)
407320 }
408321 }
409322
@@ -478,7 +391,7 @@ final class ViewModel: ObservableObject {
478391 errorMessage = nil
479392 let now = Date()
480393 lastUpdated = now
481− recordMetric(newBalance, into: &history, at: now)
394+ recordHistory(balance: newBalance, at: now)
482395
483396 // Low-balance notification: on threshold crossing (≥10 → <10)
484397 // OR on first successful fetch if already below threshold.
@@ -523,30 +436,12 @@ final class ViewModel: ObservableObject {
523436 claudeUsage = nil
524437 claudePlan = nil
525438 claudeError = nil
526− claudeHistory.removeAll()
527439
528440 case .usage(let report):
529− // Capture the previous 5-hour % for threshold-crossing notification.
530− let previousPercent = claudeFiveHourPercent
531441 claudeUsage = report.usage
532442 claudePlan = report.subscriptionType
533443 claudeError = nil
534444
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−
550445 case .failure(let message):
551446 // As with the balance: keep the last good numbers on screen and say what
552447 // went wrong, rather than blanking the section on a transient failure.
@@ -614,29 +509,11 @@ final class ViewModel: ObservableObject {
614509 case .notConfigured:
615510 zaiUsage = nil
616511 zaiError = nil
617− zaiHistory.removeAll()
618512
619513 case .usage(let report):
620− // Capture the previous 5-hour % for threshold-crossing notification.
621− let previousPercent = zaiFiveHourPercent
622514 zaiUsage = report
623515 zaiError = nil
624516
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−
640517 case .failure(let message):
641518 // As with the balance and Claude: keep the last good numbers on screen
642519 // and say what went wrong, rather than blanking the section on a
@@ -690,41 +567,7 @@ final class ViewModel: ObservableObject {
690567 }
691568 }
692569
693− /// Copies the current Claude 5-hour percentage to the clipboard.
694− func copyClaudePercentToClipboard() {
695− guard let percent = claudeFiveHourPercent else { return }
696−
697− let pasteboard = NSPasteboard.general
698− pasteboard.clearContents()
699− pasteboard.setString("\(percent)%", forType: .string)
700−
701− copiedClaudeConfirmation = true
702− copiedClaudeResetTask?.cancel()
703− copiedClaudeResetTask = Task { @MainActor in
704− try? await Task.sleep(nanoseconds: 1_500_000_000)
705− guard !Task.isCancelled else { return }
706− copiedClaudeConfirmation = false
707− }
708− }
709−
710− /// Copies the current z.ai 5-hour percentage to the clipboard.
711− func copyZaiPercentToClipboard() {
712− guard let percent = zaiFiveHourPercent else { return }
713−
714− let pasteboard = NSPasteboard.general
715− pasteboard.clearContents()
716− pasteboard.setString("\(percent)%", forType: .string)
717−
718− copiedZaiConfirmation = true
719− copiedZaiResetTask?.cancel()
720− copiedZaiResetTask = Task { @MainActor in
721− try? await Task.sleep(nanoseconds: 1_500_000_000)
722− guard !Task.isCancelled else { return }
723− copiedZaiConfirmation = false
724− }
725− }
726−
727− // MARK: - Notifications
570+ // MARK: - Low Balance Notification
728571
729572 private func sendLowBalanceNotification(balance: Int) {
730573 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -753,29 +596,6 @@ final class ViewModel: ObservableObject {
753596 }
754597 }
755598
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−
779599 // MARK: - API Key
780600
781601 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -858,7 +678,6 @@ final class ViewModel: ObservableObject {
858678 if key != activeZaiAPIKey {
859679 zaiUsage = nil
860680 zaiError = nil
861− zaiHistory.removeAll()
862681 }
863682 // Show the trimmed key that was actually stored.
864683 zaiAPIKeyInput = key
@@ -896,4 +715,4 @@ final class ViewModel: ObservableObject {
896715 isInitializingLaunchAtLogin = false
897716 }
898717 }
899−}
718+}
UsageMenubarTests/StatusBarTextTests.swift+46−106
@@ -15,10 +15,9 @@ final class StatusBarTextTests: XCTestCase {
1515 balance: 42, isLoading: false,
1616 hyperConfigured: true,
1717 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19− zaiInPeakHours: false
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
2019 )
21− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
20+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
2221 }
2322
2423 func testHyperOnly() {
@@ -26,8 +25,7 @@ final class StatusBarTextTests: XCTestCase {
2625 balance: 42, isLoading: false,
2726 hyperConfigured: true,
2827 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
29− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30− zaiInPeakHours: false
28+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
3129 )
3230 XCTAssertEqual(title, "⚡42")
3331 }
@@ -37,10 +35,9 @@ final class StatusBarTextTests: XCTestCase {
3735 balance: nil, isLoading: false,
3836 hyperConfigured: false,
3937 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
40− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41− zaiInPeakHours: false
38+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
4239 )
43− XCTAssertEqual(title, "\u{2055}62% 8%")
40+ XCTAssertEqual(title, "🕐62% · 📅8%")
4441 }
4542
4643 func testLoadingWithNothingToShow() {
@@ -48,8 +45,7 @@ final class StatusBarTextTests: XCTestCase {
4845 balance: nil, isLoading: true,
4946 hyperConfigured: false,
5047 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
51− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52− zaiInPeakHours: false
48+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
5349 )
5450 XCTAssertEqual(title, "…")
5551 }
@@ -59,8 +55,7 @@ final class StatusBarTextTests: XCTestCase {
5955 balance: nil, isLoading: false,
6056 hyperConfigured: false,
6157 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
62− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63− zaiInPeakHours: false
58+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
6459 )
6560 XCTAssertEqual(title, "")
6661 }
@@ -73,10 +68,9 @@ final class StatusBarTextTests: XCTestCase {
7368 balance: nil, isLoading: true,
7469 hyperConfigured: false,
7570 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
76− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77− zaiInPeakHours: false
71+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
7872 )
79− XCTAssertEqual(title, "\u{2055}62%")
73+ XCTAssertEqual(title, "🕐62%")
8074 }
8175
8276 func testLoadingKeepsAStaleBalanceOnScreen() {
@@ -84,21 +78,19 @@ final class StatusBarTextTests: XCTestCase {
8478 balance: 42, isLoading: true,
8579 hyperConfigured: true,
8680 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
87− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88− zaiInPeakHours: false
81+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
8982 )
90− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
83+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
9184 }
9285
93− func testZeroPercentIsShown() {
86+ func testZeroPercentIsShownRatherThanOmitted() {
9487 let title = ViewModel.statusBarText(
9588 balance: 42, isLoading: false,
9689 hyperConfigured: true,
9790 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
98− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99− zaiInPeakHours: false
91+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
10092 )
101− XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
93+ XCTAssertEqual(title, "⚡42 · 📅8%")
10294 }
10395
10496 func testBothClaudeWindowsZero() {
@@ -106,10 +98,9 @@ final class StatusBarTextTests: XCTestCase {
10698 balance: 42, isLoading: false,
10799 hyperConfigured: true,
108100 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
109− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110− zaiInPeakHours: false
101+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
111102 )
112− XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
103+ XCTAssertEqual(title, "⚡42")
113104 }
114105
115106 func testFiveHourOnly() {
@@ -117,10 +108,9 @@ final class StatusBarTextTests: XCTestCase {
117108 balance: nil, isLoading: false,
118109 hyperConfigured: false,
119110 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
120− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121− zaiInPeakHours: false
111+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
122112 )
123− XCTAssertEqual(title, "\u{2055}62%")
113+ XCTAssertEqual(title, "🕐62%")
124114 }
125115
126116 func testSevenDayOnly() {
@@ -128,10 +118,9 @@ final class StatusBarTextTests: XCTestCase {
128118 balance: nil, isLoading: false,
129119 hyperConfigured: false,
130120 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
131− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132− zaiInPeakHours: false
121+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
133122 )
134− XCTAssertEqual(title, "\u{2055}8%")
123+ XCTAssertEqual(title, "📅8%")
135124 }
136125
137126 // MARK: - Hyper not configured
@@ -143,8 +132,7 @@ final class StatusBarTextTests: XCTestCase {
143132 balance: 42, isLoading: false,
144133 hyperConfigured: false,
145134 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
146− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147− zaiInPeakHours: false
135+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
148136 )
149137 XCTAssertEqual(title, "")
150138 }
@@ -154,8 +142,7 @@ final class StatusBarTextTests: XCTestCase {
154142 balance: nil, isLoading: true,
155143 hyperConfigured: false,
156144 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158− zaiInPeakHours: false
145+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
159146 )
160147 XCTAssertEqual(title, "…")
161148 }
@@ -165,10 +152,9 @@ final class StatusBarTextTests: XCTestCase {
165152 balance: nil, isLoading: false,
166153 hyperConfigured: false,
167154 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
168− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169− zaiInPeakHours: false
155+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
170156 )
171− XCTAssertEqual(title, "\u{2055}62% 8%")
157+ XCTAssertEqual(title, "🕐62% · 📅8%")
172158 }
173159
174160 // MARK: - z.ai in Title
@@ -178,10 +164,9 @@ final class StatusBarTextTests: XCTestCase {
178164 balance: nil, isLoading: false,
179165 hyperConfigured: false,
180166 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
181− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182− zaiInPeakHours: false
167+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
183168 )
184− XCTAssertEqual(title, "\u{005A}12%")
169+ XCTAssertEqual(title, "🤖12%")
185170 }
186171
187172 func testZaiWithHyper() {
@@ -189,10 +174,9 @@ final class StatusBarTextTests: XCTestCase {
189174 balance: 42, isLoading: false,
190175 hyperConfigured: true,
191176 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
192− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193− zaiInPeakHours: false
177+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
194178 )
195− XCTAssertEqual(title, "⚡42 · \u{005A}12%")
179+ XCTAssertEqual(title, "⚡42 · 🤖12%")
196180 }
197181
198182 func testZaiWithClaude() {
@@ -200,10 +184,9 @@ final class StatusBarTextTests: XCTestCase {
200184 balance: nil, isLoading: false,
201185 hyperConfigured: false,
202186 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
203− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204− zaiInPeakHours: false
187+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
205188 )
206− XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
189+ XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
207190 }
208191
209192 func testAllThreeServices() {
@@ -211,21 +194,19 @@ final class StatusBarTextTests: XCTestCase {
211194 balance: 42, isLoading: false,
212195 hyperConfigured: true,
213196 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
214− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215− zaiInPeakHours: false
197+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
216198 )
217− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
199+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12% · 📆3%")
218200 }
219201
220− func testZaiZeroIsShown() {
202+ func testZaiZeroIsOmitted() {
221203 let title = ViewModel.statusBarText(
222204 balance: 42, isLoading: false,
223205 hyperConfigured: true,
224206 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
225− zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226− zaiInPeakHours: false
207+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0
227208 )
228− XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
209+ XCTAssertEqual(title, "⚡42")
229210 }
230211
231212 func testLoadingKeepsZaiPercentOnScreen() {
@@ -233,10 +214,9 @@ final class StatusBarTextTests: XCTestCase {
233214 balance: nil, isLoading: true,
234215 hyperConfigured: false,
235216 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
236− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237− zaiInPeakHours: false
217+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
238218 )
239− XCTAssertEqual(title, "\u{005A}12%")
219+ XCTAssertEqual(title, "🤖12%")
240220 }
241221
242222 // MARK: - z.ai Weekly
@@ -246,10 +226,9 @@ final class StatusBarTextTests: XCTestCase {
246226 balance: nil, isLoading: false,
247227 hyperConfigured: false,
248228 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250− zaiInPeakHours: false
229+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
251230 )
252− XCTAssertEqual(title, "\u{005A}3%")
231+ XCTAssertEqual(title, "📆3%")
253232 }
254233
255234 func testZaiWeeklyWithFiveHour() {
@@ -257,21 +236,19 @@ final class StatusBarTextTests: XCTestCase {
257236 balance: nil, isLoading: false,
258237 hyperConfigured: false,
259238 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
260− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261− zaiInPeakHours: false
239+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
262240 )
263− XCTAssertEqual(title, "\u{005A}12% 3%")
241+ XCTAssertEqual(title, "🤖12% · 📆3%")
264242 }
265243
266− func testZaiWeeklyZeroIsShown() {
244+ func testZaiWeeklyZeroIsOmitted() {
267245 let title = ViewModel.statusBarText(
268246 balance: nil, isLoading: false,
269247 hyperConfigured: false,
270248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
271− zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272− zaiInPeakHours: false
249+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0
273250 )
274− XCTAssertEqual(title, "\u{005A}12% 0%")
251+ XCTAssertEqual(title, "🤖12%")
275252 }
276253
277254 func testLoadingKeepsZaiWeeklyOnScreen() {
@@ -279,46 +256,9 @@ final class StatusBarTextTests: XCTestCase {
279256 balance: nil, isLoading: true,
280257 hyperConfigured: false,
281258 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
259+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
319260 )
320− // No z.ai segment, so peak marker has nothing to attach to.
321− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
261+ XCTAssertEqual(title, "📆3%")
322262 }
323263
324264 // MARK: - Claude Window Percentages
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: soy.iko
9+ bundleIdPrefix: com.ilyakooo0
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: soy.iko.usage-menubar
50+ CFBundleIdentifier: com.ilyakooo0.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"