@@ -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("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
13−/// The direction the balance has moved over the most recent history points.
13+/// The direction a metric has moved over the most recent history points.
14−enum BalanceTrend {
14+enum MetricTrend {
15 case increasing
15 case increasing
16 case decreasing
16 case decreasing
17 case stable
17 case stable
@@ -29,10 +29,11 @@ enum BalanceTrend {
32−/// A single balance observation.
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
33−struct BalancePoint: Equatable {
34+struct MetricPoint: Equatable {
34 let date: Date
35 let date: Date
35− let balance: Int
36+ let value: Int
38/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
39/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -80,6 +81,24 @@ final class ViewModel: ObservableObject {
80 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
81 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
81 @Published var claudePlan: String?
82 @Published var claudePlan: String?
84+ /// z.ai Coding Plan usage. `nil` when no z.ai API key has been entered, in which
85+ /// case the popover leaves the section out entirely.
86+ @Published var zaiUsage: ZaiUsageReport?
88+ /// A human-readable error message from the z.ai fetch. Independent of
89+ /// `errorMessage` and `claudeError`: the three services fail separately.
90+ @Published var zaiError: String?
92+ /// The z.ai API key draft bound to the text field. Two-way bound to the
93+ /// `SecureField`, so it changes on every keystroke — never fetch with it, use
94+ /// `activeZaiAPIKey`.
95+ @Published var zaiAPIKeyInput: String = ""
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
83 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
102 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
84 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
103 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
85 @Published var apiKeyInput: String = ""
104 @Published var apiKeyInput: String = ""
@@ -93,8 +112,20 @@ final class ViewModel: ObservableObject {
93 /// Brief "✓ Copied" confirmation shown after copying the balance.
112 /// Brief "✓ Copied" confirmation shown after copying the balance.
94 @Published var copiedConfirmation: Bool = false
113 @Published var copiedConfirmation: Bool = false
115+ /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116+ @Published var copiedClaudeConfirmation: Bool = false
118+ /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119+ @Published var copiedZaiConfirmation: Bool = false
96 /// Successful balance observations from the last 24 hours, oldest first.
121 /// Successful balance observations from the last 24 hours, oldest first.
97− @Published private(set) var history: [BalancePoint] = []
122+ @Published private(set) var history: [MetricPoint] = []
124+ /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125+ @Published private(set) var claudeHistory: [MetricPoint] = []
127+ /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128+ @Published private(set) var zaiHistory: [MetricPoint] = []
99 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
100 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -125,8 +156,13 @@ final class ViewModel: ObservableObject {
125 /// last-updated time and history out from under the user.
156 /// last-updated time and history out from under the user.
126 private var activeAPIKey: String
157 private var activeAPIKey: String
159+ /// The z.ai key requests actually go out with: what is in the Keychain, not what
160+ /// is currently in the text field. Same rationale as `activeAPIKey`.
161+ private var activeZaiAPIKey: String
128 private let checker: CreditsChecking
163 private let checker: CreditsChecking
129 private let claudeChecker: ClaudeUsageChecking
164 private let claudeChecker: ClaudeUsageChecking
165+ private let zaiChecker: ZaiUsageChecking
130 private let defaults: UserDefaults
166 private let defaults: UserDefaults
132 /// The currently in-flight refresh task, if any. Used to cancel
168 /// The currently in-flight refresh task, if any. Used to cancel
@@ -140,6 +176,12 @@ final class ViewModel: ObservableObject {
140 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
141 private var savedResetTask: Task<Void, Never>?
177 private var savedResetTask: Task<Void, Never>?
179+ /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180+ private var copiedClaudeResetTask: Task<Void, Never>?
182+ /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183+ private var copiedZaiResetTask: Task<Void, Never>?
143 // MARK: - History Configuration
185 // MARK: - History Configuration
145 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -147,17 +189,26 @@ final class ViewModel: ObservableObject {
147 private static let maxHistoryEntries = 300
189 private static let maxHistoryEntries = 300
148 private static let historyWindow: TimeInterval = 24 * 60 * 60
190 private static let historyWindow: TimeInterval = 24 * 60 * 60
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
151 checker: CreditsChecking = CreditsChecker(),
196 checker: CreditsChecking = CreditsChecker(),
152 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198+ zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
153 defaults: UserDefaults = .standard
199 defaults: UserDefaults = .standard
155 self.checker = checker
201 self.checker = checker
156 self.claudeChecker = claudeChecker
202 self.claudeChecker = claudeChecker
203+ self.zaiChecker = zaiChecker
157 self.defaults = defaults
204 self.defaults = defaults
158 let storedKey = KeychainHelper.load() ?? ""
205 let storedKey = KeychainHelper.load() ?? ""
159 apiKeyInput = storedKey
206 apiKeyInput = storedKey
160 activeAPIKey = storedKey
207 activeAPIKey = storedKey
208+ hyperConfigured = !storedKey.isEmpty
209+ let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210+ zaiAPIKeyInput = storedZaiKey
211+ activeZaiAPIKey = storedZaiKey
161 // Assigning in init doesn't fire didSet, so this load doesn't re-post
212 // Assigning in init doesn't fire didSet, so this load doesn't re-post
162 // the change notification or write back to defaults.
213 // the change notification or write back to defaults.
163 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
214 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
@@ -186,25 +237,88 @@ final class ViewModel: ObservableObject {
186 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
189− /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
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.
246+ /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
191− /// Takes the balance and loading flag as parameters rather than reading the
248+ /// Takes its values as parameters rather than reading the properties: `@Published`
192− /// properties: `@Published` publishes in `willSet`, so a Combine subscriber that
249+ /// publishes in `willSet`, so a Combine subscriber that called back into the view
193− /// called back into the view model would see the *pre-change* value and render a
250+ /// model would see the *pre-change* value and render a title one update behind.
194− /// title one update behind.
195− static func statusBarText(balance: Int?, isLoading: Bool) -> String {
251+ static func statusBarText(
196− if isLoading && balance == nil {
252+ balance: Int?,
197− return "⚡…"
253+ isLoading: Bool,
254+ hyperConfigured: Bool,
255+ claudeFiveHourPercent: Int?,
256+ claudeSevenDayPercent: Int?,
257+ zaiFiveHourPercent: Int?,
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
261+ var segments: [String] = []
264+ if hyperConfigured, let balance = balance {
265+ segments.append("⚡\(formatBalance(balance))")
199− if let balance = balance {
267+
200− return "⚡\(formatBalance(balance))"
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
270+ if let fiveHour = claudeFiveHourPercent {
271+ claudeWindows.append("\(fiveHour)%")
273+ if let sevenDay = claudeSevenDayPercent {
274+ claudeWindows.append("\(sevenDay)%")
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
202− return "⚡?"
285+ if let zaiWeekly = zaiWeeklyPercent {
286+ zaiWindows.append("\(zaiWeekly)%")
288+ if !zaiWindows.isEmpty {
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
293+ segments.append(segment)
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 ? "…" : ""
301+ return segments.joined(separator: " · ")
205 /// The text to show in the menu bar for the current state.
304 /// The text to show in the menu bar for the current state.
206 var statusBarItemText: String {
305 var statusBarItemText: String {
207− Self.statusBarText(balance: balance, isLoading: isLoading)
306+ Self.statusBarText(
308+ isLoading: isLoading,
309+ hyperConfigured: hyperConfigured,
310+ claudeFiveHourPercent: claudeFiveHourPercent,
311+ claudeSevenDayPercent: claudeSevenDayPercent,
312+ zaiFiveHourPercent: zaiFiveHourPercent,
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
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
210 /// The formatted balance string for display in the popover.
324 /// The formatted balance string for display in the popover.
@@ -227,51 +341,88 @@ final class ViewModel: ObservableObject {
227 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
341 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
230− // MARK: - History
344+ // MARK: - History (Hyper)
232− /// The last 24 hours of successful fetches, oldest first.
346+ /// The last 24 hours of successful balance fetches, oldest first.
233 var balanceHistory: [(date: Date, balance: Int)] {
347 var balanceHistory: [(date: Date, balance: Int)] {
234− history.map { (date: $0.date, balance: $0.balance) }
348+ history.map { (date: $0.date, balance: $0.value) }
237 /// The direction of travel across the most recent handful of observations.
351 /// The direction of travel across the most recent handful of observations.
238 /// `.unknown` until there are at least two points to compare.
352 /// `.unknown` until there are at least two points to compare.
239− var balanceTrend: BalanceTrend {
353+ var balanceTrend: MetricTrend {
354+ metricTrend(history)
357+ /// The sparkline's data for the Hyper balance.
358+ var balanceSparklineValues: [Int] {
359+ history.map { $0.value }
362+ // MARK: - History (Claude)
364+ /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365+ var claudeSparklineValues: [Int] {
366+ claudeHistory.map { $0.value }
369+ /// The direction of travel for the Claude 5-hour %.
370+ var claudeTrend: MetricTrend {
371+ metricTrend(claudeHistory)
374+ // MARK: - History (z.ai)
376+ /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377+ var zaiSparklineValues: [Int] {
378+ zaiHistory.map { $0.value }
381+ /// The direction of travel for the z.ai 5-hour %.
382+ var zaiTrend: MetricTrend {
383+ metricTrend(zaiHistory)
386+ // MARK: - History Helpers
388+ /// Computes the trend from a generic history array.
389+ private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
240− guard history.count >= 2 else { return .unknown }
390+ guard points.count >= 2 else { return .unknown }
241− let recent = history.suffix(5)
391+ let recent = points.suffix(5)
242 guard let first = recent.first, let last = recent.last else { return .unknown }
392 guard let first = recent.first, let last = recent.last else { return .unknown }
243− if last.balance > first.balance { return .increasing }
393+ if last.value > first.value { return .increasing }
244− if last.balance < first.balance { return .decreasing }
394+ if last.value < first.value { return .decreasing }
245 return .stable
395 return .stable
248 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
398 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
249− private func recordHistory(balance: Int, at date: Date) {
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
250− history.append(BalancePoint(date: date, balance: balance))
400+ array.append(MetricPoint(date: date, value: value))
252 let cutoff = date.addingTimeInterval(-Self.historyWindow)
402 let cutoff = date.addingTimeInterval(-Self.historyWindow)
253− history.removeAll { $0.date < cutoff }
403+ array.removeAll { $0.date < cutoff }
255− if history.count > Self.maxHistoryEntries {
405+ if array.count > Self.maxHistoryEntries {
256− history.removeFirst(history.count - Self.maxHistoryEntries)
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
260 // MARK: - Refresh
410 // MARK: - Refresh
262− /// Refreshes the Hyper balance and the Claude usage, both at once.
412+ /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
264− /// The two are independent: either can fail, or not be configured at all, without
414+ /// The three are independent: any can fail, or not be configured at all, without
265− /// touching the other. Cancels any previously in-flight refresh to prevent races.
415+ /// touching the others. Cancels any previously in-flight refresh to prevent races.
266 func refresh() {
416 func refresh() {
267 refreshTask?.cancel()
417 refreshTask?.cancel()
269 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
419 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
270 let key = activeAPIKey
420 let key = activeAPIKey
421+ let zaiKey = activeZaiAPIKey
271 if key.isEmpty {
422 if key.isEmpty {
272 // Nothing to fetch, and nothing worth keeping: the balance, its history and
423 // Nothing to fetch, and nothing worth keeping: the balance, its history and
273− // any error all belonged to a key that is no longer there. Claude is left
424+ // any error all belonged to a key that is no longer there. Claude and z.ai
274− // alone — it doesn't depend on the Hyper key.
425+ // are left alone — they don't depend on the Hyper key.
275 balance = nil
426 balance = nil
276 errorMessage = nil
427 errorMessage = nil
277 lastUpdated = nil
428 lastUpdated = nil
@@ -282,22 +433,25 @@ final class ViewModel: ObservableObject {
282 // Check cancellation before setting isLoading — a previous
433 // Check cancellation before setting isLoading — a previous
283 // refresh() may have cancelled us and already set isLoading = false.
434 // refresh() may have cancelled us and already set isLoading = false.
284 guard !Task.isCancelled else { return }
435 guard !Task.isCancelled else { return }
285− // Loading state covers both fetches: the popover's loading view depends
436+ // Loading state covers all fetches: the popover's loading view depends
286− // on it, and Claude usage is fetched even when there's no Hyper key.
437+ // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
287 isLoading = true
438 isLoading = true
288 // Don't clear the errors here — keep showing the last one
439 // Don't clear the errors here — keep showing the last one
289 // until we have a successful result.
440 // until we have a successful result.
291− // Both requests go out at once; neither waits on the other.
442+ // All three requests go out at once; none waits on the others.
292 async let balanceOutcome = fetchBalance(key: key)
443 async let balanceOutcome = fetchBalance(key: key)
293 async let claudeOutcome = fetchClaudeUsage()
444 async let claudeOutcome = fetchClaudeUsage()
445+ async let zaiOutcome = fetchZaiUsage(key: zaiKey)
295 let balanceResult = await balanceOutcome
447 let balanceResult = await balanceOutcome
296 let claudeResult = await claudeOutcome
448 let claudeResult = await claudeOutcome
449+ let zaiResult = await zaiOutcome
297 guard !Task.isCancelled else { return }
450 guard !Task.isCancelled else { return }
299 applyBalance(balanceResult)
452 applyBalance(balanceResult)
300 applyClaude(claudeResult)
453 applyClaude(claudeResult)
301 isLoading = false
455 isLoading = false
@@ -324,7 +478,7 @@ final class ViewModel: ObservableObject {
324 errorMessage = nil
478 errorMessage = nil
325 let now = Date()
479 let now = Date()
326 lastUpdated = now
480 lastUpdated = now
327− recordHistory(balance: newBalance, at: now)
481+ recordMetric(newBalance, into: &history, at: now)
329 // Low-balance notification: on threshold crossing (≥10 → <10)
483 // Low-balance notification: on threshold crossing (≥10 → <10)
330 // OR on first successful fetch if already below threshold.
484 // OR on first successful fetch if already below threshold.
@@ -369,12 +523,30 @@ final class ViewModel: ObservableObject {
369 claudeUsage = nil
523 claudeUsage = nil
370 claudePlan = nil
524 claudePlan = nil
371 claudeError = nil
525 claudeError = nil
526+ claudeHistory.removeAll()
373 case .usage(let report):
528 case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
374 claudeUsage = report.usage
531 claudeUsage = report.usage
375 claudePlan = report.subscriptionType
532 claudePlan = report.subscriptionType
376 claudeError = nil
533 claudeError = nil
535+ // Record history and fire high-usage notification based on the 5-hour %.
536+ if let percent = claudeFiveHourPercent {
538+ recordMetric(percent, into: &claudeHistory, at: now)
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)
378 case .failure(let message):
550 case .failure(let message):
379 // As with the balance: keep the last good numbers on screen and say what
551 // As with the balance: keep the last good numbers on screen and say what
380 // went wrong, rather than blanking the section on a transient failure.
552 // went wrong, rather than blanking the section on a transient failure.
@@ -382,6 +554,25 @@ final class ViewModel: ObservableObject {
557+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
558+ var claudeFiveHourPercent: Int? {
559+ guard let fiveHour = claudeUsage?.fiveHour else { return nil }
560+ return Int(fiveHour.utilization.rounded())
563+ /// The 7-day window percentage for the menu bar title, or `nil` when absent.
564+ /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
565+ /// has been used — on Max plans the Opus cap is the one that binds first.
566+ var claudeSevenDayPercent: Int? {
567+ if let sevenDay = claudeUsage?.sevenDay {
568+ return Int(sevenDay.utilization.rounded())
570+ if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
571+ return Int(opus.utilization.rounded())
385 /// Display label for the Claude plan, e.g. "Pro" or "Max".
576 /// Display label for the Claude plan, e.g. "Pro" or "Max".
386 var claudePlanLabel: String? {
577 var claudePlanLabel: String? {
387 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
578 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -397,6 +588,88 @@ final class ViewModel: ObservableObject {
591+ // MARK: - z.ai Usage
593+ /// What a z.ai fetch produced. Missing API key is deliberately not a failure:
594+ /// plenty of people don't use z.ai, and an error telling them so would be noise.
595+ private enum ZaiOutcome {
597+ case usage(ZaiUsageReport)
598+ case failure(String)
601+ private func fetchZaiUsage(key: String) async -> ZaiOutcome {
602+ guard !key.isEmpty else { return .notConfigured }
604+ return .usage(try await zaiChecker.fetchUsage(apiKey: key))
605+ } catch ZaiUsageError.noAPIKey {
606+ return .notConfigured
608+ return .failure(error.localizedDescription)
612+ private func applyZai(_ outcome: ZaiOutcome) {
614+ case .notConfigured:
617+ zaiHistory.removeAll()
619+ case .usage(let report):
620+ // Capture the previous 5-hour % for threshold-crossing notification.
621+ let previousPercent = zaiFiveHourPercent
625+ // Record history and fire high-usage notification based on the 5-hour %.
626+ if let percent = zaiFiveHourPercent {
628+ recordMetric(percent, into: &zaiHistory, at: now)
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)
640+ case .failure(let message):
641+ // 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
643+ // transient failure.
648+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
649+ var zaiFiveHourPercent: Int? {
650+ zaiUsage?.fiveHourPercent
653+ /// The weekly window percentage for the popover, or `nil` when absent.
654+ var zaiWeeklyPercent: Int? {
655+ zaiUsage?.weeklyPercent
658+ /// Display label for the z.ai plan, e.g. "Lite", "Pro", or "Max".
659+ /// Falls back to the raw `planLevel` string if it doesn't match a known tier.
660+ var zaiPlanLabel: String? {
661+ guard let level = zaiUsage?.planLevel?.trimmingCharacters(in: .whitespacesAndNewlines),
662+ !level.isEmpty else { return nil }
664+ // The subscription endpoint returns a product name like "GLM Coding Max";
665+ // extract the tier from it. The quota endpoint returns just the tier.
666+ let lowered = level.lowercased()
667+ if lowered.contains("max") { return "Max" }
668+ if lowered.contains("pro") { return "Pro" }
669+ if lowered.contains("lite") { return "Lite" }
670+ return level.capitalized
400 // MARK: - Clipboard
673 // MARK: - Clipboard
402 /// Copies the current balance to the clipboard as plain digits (no grouping
675 /// Copies the current balance to the clipboard as plain digits (no grouping
@@ -417,7 +690,41 @@ final class ViewModel: ObservableObject {
420− // MARK: - Low Balance Notification
693+ /// Copies the current Claude 5-hour percentage to the clipboard.
694+ func copyClaudePercentToClipboard() {
695+ guard let percent = claudeFiveHourPercent else { return }
697+ let pasteboard = NSPasteboard.general
698+ pasteboard.clearContents()
699+ pasteboard.setString("\(percent)%", forType: .string)
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
710+ /// Copies the current z.ai 5-hour percentage to the clipboard.
711+ func copyZaiPercentToClipboard() {
712+ guard let percent = zaiFiveHourPercent else { return }
714+ let pasteboard = NSPasteboard.general
715+ pasteboard.clearContents()
716+ pasteboard.setString("\(percent)%", forType: .string)
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
727+ // MARK: - Notifications
422 private func sendLowBalanceNotification(balance: Int) {
729 private func sendLowBalanceNotification(balance: Int) {
423 // Build the body here, on the main actor. `getNotificationSettings` calls back on
730 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -446,6 +753,29 @@ final class ViewModel: ObservableObject {
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"
760+ let center = UNUserNotificationCenter.current()
761+ center.getNotificationSettings { settings in
762+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
765+ let content = UNMutableNotificationContent()
766+ content.title = "High Usage Menubar"
768+ content.sound = .default
770+ let request = UNNotificationRequest(
771+ identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
449 // MARK: - API Key
779 // MARK: - API Key
451 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
781 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -460,6 +790,7 @@ final class ViewModel: ObservableObject {
461 apiKeyInput = ""
791 apiKeyInput = ""
462 activeAPIKey = ""
792 activeAPIKey = ""
793+ hyperConfigured = false
463 // refresh() with an empty key clears balance, error, history, and
794 // refresh() with an empty key clears balance, error, history, and
464 // cancels any in-flight task from the previous key.
795 // cancels any in-flight task from the previous key.
465 refresh()
796 refresh()
@@ -483,6 +814,7 @@ final class ViewModel: ObservableObject {
483 // Show the trimmed key that was actually stored.
814 // Show the trimmed key that was actually stored.
484 apiKeyInput = key
815 apiKeyInput = key
485 activeAPIKey = key
816 activeAPIKey = key
817+ hyperConfigured = true
486 refresh()
818 refresh()
@@ -498,6 +830,53 @@ final class ViewModel: ObservableObject {
833+ // MARK: - z.ai API Key
835+ /// Saves the current `zaiAPIKeyInput` to the Keychain and triggers a refresh.
836+ /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
837+ func saveZaiAPIKey() {
838+ let key = zaiAPIKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
840+ let deleted = ZaiKeychainHelper.delete()
841+ guard deleted else {
842+ zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
846+ activeZaiAPIKey = ""
847+ // refresh() with an empty key clears zai usage and error, and
848+ // cancels any in-flight task from the previous key.
851+ let saved = ZaiKeychainHelper.save(key)
853+ zaiError = "Could not save z.ai API key to Keychain. It may be locked."
856+ // A different key is a different account, so everything derived from the
857+ // old one has to go.
858+ if key != activeZaiAPIKey {
861+ zaiHistory.removeAll()
863+ // Show the trimmed key that was actually stored.
864+ zaiAPIKeyInput = key
865+ activeZaiAPIKey = key
869+ // Reuse the same "✓ Saved" confirmation — the user sees one per save action,
870+ // regardless of which key it was for.
871+ savedConfirmation = true
872+ savedResetTask?.cancel()
873+ savedResetTask = Task { @MainActor in
874+ try? await Task.sleep(nanoseconds: 2_000_000_000)
875+ guard !Task.isCancelled else { return }
876+ savedConfirmation = false
501 // MARK: - Launch at Login
880 // MARK: - Launch at Login
503 func updateLaunchAtLogin(_ enabled: Bool) {
882 func updateLaunchAtLogin(_ enabled: Bool) {
@@ -517,4 +896,4 @@ final class ViewModel: ObservableObject {
517 isInitializingLaunchAtLogin = false
896 isInitializingLaunchAtLogin = false