@@ -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
@@ -81,24 +80,6 @@ final class ViewModel: ObservableObject {
8180 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
8281 @Published var claudePlan: String?
8382
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?
87−
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?
91−
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 = ""
96−
97− /// Whether Hyper is configured — i.e. there is an API key in the Keychain.
98− /// Drives whether the `⚡` segment appears in the menu bar at all: an
99− /// unconfigured service should not take up space in the title.
100− @Published private(set) var hyperConfigured = false
101−
10283 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
10384 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
10485 @Published var apiKeyInput: String = ""
@@ -112,20 +93,8 @@ final class ViewModel: ObservableObject {
11293 /// Brief "✓ Copied" confirmation shown after copying the balance.
11394 @Published var copiedConfirmation: Bool = false
11495
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−
12196 /// 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] = []
97+ @Published private(set) var history: [BalancePoint] = []
12998
13099 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131100 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -156,13 +125,8 @@ final class ViewModel: ObservableObject {
156125 /// last-updated time and history out from under the user.
157126 private var activeAPIKey: String
158127
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
162−
163128 private let checker: CreditsChecking
164129 private let claudeChecker: ClaudeUsageChecking
165− private let zaiChecker: ZaiUsageChecking
166130 private let defaults: UserDefaults
167131
168132 /// The currently in-flight refresh task, if any. Used to cancel
@@ -176,12 +140,6 @@ final class ViewModel: ObservableObject {
176140 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177141 private var savedResetTask: Task<Void, Never>?
178142
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−
185143 // MARK: - History Configuration
186144
187145 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -189,26 +147,17 @@ final class ViewModel: ObservableObject {
189147 private static let maxHistoryEntries = 300
190148 private static let historyWindow: TimeInterval = 24 * 60 * 60
191149
192− /// The threshold above which a usage percentage triggers a notification.
193− private static let highUsageThreshold = 90
194−
195150 init(
196151 checker: CreditsChecking = CreditsChecker(),
197152 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198− zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
199153 defaults: UserDefaults = .standard
200154 ) {
201155 self.checker = checker
202156 self.claudeChecker = claudeChecker
203− self.zaiChecker = zaiChecker
204157 self.defaults = defaults
205158 let storedKey = KeychainHelper.load() ?? ""
206159 apiKeyInput = storedKey
207160 activeAPIKey = storedKey
208− hyperConfigured = !storedKey.isEmpty
209− let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210− zaiAPIKeyInput = storedZaiKey
211− activeZaiAPIKey = storedZaiKey
212161 // Assigning in init doesn't fire didSet, so this load doesn't re-post
213162 // the change notification or write back to defaults.
214163 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
@@ -237,68 +186,27 @@ final class ViewModel: ObservableObject {
237186 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238187 }
239188
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%🔶`
189+ /// The text to show in the menu bar: `⚡{balance} · {percent}%` with both services,
190+ /// and just the half that is there when only one of them is — an unconfigured or
191+ /// failing service drops out of the title rather than taking a slot to say nothing.
192+ /// With neither, the bolt stands in alone: `⚡…` while the first fetch is in flight,
193+ /// `⚡?` once it has come back empty-handed.
247194 ///
248195 /// Takes its values as parameters rather than reading the properties: `@Published`
249196 /// publishes in `willSet`, so a Combine subscriber that called back into the view
250197 /// model would see the *pre-change* value and render a title one update behind.
251− static func statusBarText(
252− balance: Int?,
253− isLoading: Bool,
254− hyperConfigured: Bool,
255− claudeFiveHourPercent: Int?,
256− claudeSevenDayPercent: Int?,
257− zaiFiveHourPercent: Int?,
258− zaiWeeklyPercent: Int?,
259− zaiInPeakHours: Bool
260− ) -> String {
261− var segments: [String] = []
262−
263− // Hyper
264− if hyperConfigured, let balance = balance {
265− segments.append("⚡\(formatBalance(balance))")
198+ static func statusBarText(balance: Int?, isLoading: Bool, claudePercent: Int?) -> String {
199+ var parts: [String] = []
200+ if let balance = balance {
201+ parts.append("⚡\(formatBalance(balance))")
266202 }
267−
268− // Claude — provider emoji + bare percentages
269− var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent {
271− claudeWindows.append("\(fiveHour)%")
272− }
273− if let sevenDay = claudeSevenDayPercent {
274− claudeWindows.append("\(sevenDay)%")
275− }
276− if !claudeWindows.isEmpty {
277− segments.append("⁕" + claudeWindows.joined(separator: " "))
278− }
279−
280− // z.ai — provider emoji + bare percentages
281− var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent {
283− zaiWindows.append("\(zaiFiveHour)%")
203+ if let claudePercent = claudePercent {
204+ parts.append("\(claudePercent)%")
284205 }
285− if let zaiWeekly = zaiWeeklyPercent {
286− zaiWindows.append("\(zaiWeekly)%")
206+ if parts.isEmpty {
207+ return isLoading ? "⚡…" : "⚡?"
287208 }
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 {
297− // No service is configured or has anything to show. While loading,
298− // show an ellipsis; otherwise the title is empty.
299− return isLoading ? "…" : ""
300− }
301− return segments.joined(separator: " · ")
209+ return parts.joined(separator: " · ")
302210 }
303211
304212 /// The text to show in the menu bar for the current state.
@@ -306,21 +214,10 @@ final class ViewModel: ObservableObject {
306214 Self.statusBarText(
307215 balance: balance,
308216 isLoading: isLoading,
309− hyperConfigured: hyperConfigured,
310− claudeFiveHourPercent: claudeFiveHourPercent,
311− claudeSevenDayPercent: claudeSevenDayPercent,
312− zaiFiveHourPercent: zaiFiveHourPercent,
313− zaiWeeklyPercent: zaiWeeklyPercent,
314− zaiInPeakHours: Self.zaiInPeakHours
217+ claudePercent: claudeStatusBarPercent
315218 )
316219 }
317220
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−
324221 /// The formatted balance string for display in the popover.
325222 var formattedBalance: String {
326223 guard let balance = balance else { return "?" }
@@ -341,88 +238,51 @@ final class ViewModel: ObservableObject {
341238 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342239 }
343240
344− // MARK: - History (Hyper)
241+ // MARK: - History
345242
346− /// The last 24 hours of successful balance fetches, oldest first.
243+ /// The last 24 hours of successful fetches, oldest first.
347244 var balanceHistory: [(date: Date, balance: Int)] {
348− history.map { (date: $0.date, balance: $0.value) }
245+ history.map { (date: $0.date, balance: $0.balance) }
349246 }
350247
351248 /// The direction of travel across the most recent handful of observations.
352249 /// `.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)
250+ var balanceTrend: BalanceTrend {
251+ guard history.count >= 2 else { return .unknown }
252+ let recent = history.suffix(5)
392253 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 }
254+ if last.balance > first.balance { return .increasing }
255+ if last.balance < first.balance { return .decreasing }
395256 return .stable
396257 }
397258
398259 /// 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))
260+ private func recordHistory(balance: Int, at date: Date) {
261+ history.append(BalancePoint(date: date, balance: balance))
401262
402263 let cutoff = date.addingTimeInterval(-Self.historyWindow)
403− array.removeAll { $0.date < cutoff }
264+ history.removeAll { $0.date < cutoff }
404265
405− if array.count > Self.maxHistoryEntries {
406− array.removeFirst(array.count - Self.maxHistoryEntries)
266+ if history.count > Self.maxHistoryEntries {
267+ history.removeFirst(history.count - Self.maxHistoryEntries)
407268 }
408269 }
409270
410271 // MARK: - Refresh
411272
412− /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
273+ /// Refreshes the Hyper balance and the Claude usage, both at once.
413274 ///
414− /// The three are independent: any can fail, or not be configured at all, without
415− /// touching the others. Cancels any previously in-flight refresh to prevent races.
275+ /// The two are independent: either can fail, or not be configured at all, without
276+ /// touching the other. Cancels any previously in-flight refresh to prevent races.
416277 func refresh() {
417278 refreshTask?.cancel()
418279
419280 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
420281 let key = activeAPIKey
421− let zaiKey = activeZaiAPIKey
422282 if key.isEmpty {
423283 // Nothing to fetch, and nothing worth keeping: the balance, its history and
424− // any error all belonged to a key that is no longer there. Claude and z.ai
425− // are left alone — they don't depend on the Hyper key.
284+ // any error all belonged to a key that is no longer there. Claude is left
285+ // alone — it doesn't depend on the Hyper key.
426286 balance = nil
427287 errorMessage = nil
428288 lastUpdated = nil
@@ -433,25 +293,22 @@ final class ViewModel: ObservableObject {
433293 // Check cancellation before setting isLoading — a previous
434294 // refresh() may have cancelled us and already set isLoading = false.
435295 guard !Task.isCancelled else { return }
436− // Loading state covers all fetches: the popover's loading view depends
437− // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
296+ // Loading state covers both fetches: the popover's loading view depends
297+ // on it, and Claude usage is fetched even when there's no Hyper key.
438298 isLoading = true
439299 // Don't clear the errors here — keep showing the last one
440300 // until we have a successful result.
441301
442− // All three requests go out at once; none waits on the others.
302+ // Both requests go out at once; neither waits on the other.
443303 async let balanceOutcome = fetchBalance(key: key)
444304 async let claudeOutcome = fetchClaudeUsage()
445− async let zaiOutcome = fetchZaiUsage(key: zaiKey)
446305
447306 let balanceResult = await balanceOutcome
448307 let claudeResult = await claudeOutcome
449− let zaiResult = await zaiOutcome
450308 guard !Task.isCancelled else { return }
451309
452310 applyBalance(balanceResult)
453311 applyClaude(claudeResult)
454− applyZai(zaiResult)
455312 isLoading = false
456313 }
457314 }
@@ -478,7 +335,7 @@ final class ViewModel: ObservableObject {
478335 errorMessage = nil
479336 let now = Date()
480337 lastUpdated = now
481− recordMetric(newBalance, into: &history, at: now)
338+ recordHistory(balance: newBalance, at: now)
482339
483340 // Low-balance notification: on threshold crossing (≥10 → <10)
484341 // OR on first successful fetch if already below threshold.
@@ -523,30 +380,12 @@ final class ViewModel: ObservableObject {
523380 claudeUsage = nil
524381 claudePlan = nil
525382 claudeError = nil
526− claudeHistory.removeAll()
527383
528384 case .usage(let report):
529− // Capture the previous 5-hour % for threshold-crossing notification.
530− let previousPercent = claudeFiveHourPercent
531385 claudeUsage = report.usage
532386 claudePlan = report.subscriptionType
533387 claudeError = nil
534388
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−
550389 case .failure(let message):
551390 // As with the balance: keep the last good numbers on screen and say what
552391 // went wrong, rather than blanking the section on a transient failure.
@@ -554,25 +393,29 @@ final class ViewModel: ObservableObject {
554393 }
555394 }
556395
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())
561− }
562−
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())
396+ /// The number Claude gets judged by: whichever limit the server itself flags as
397+ /// binding, falling back to the 5-hour window when it flags none. `nil` when there
398+ /// is no usage to summarize.
399+ ///
400+ /// Shared by the popover and the menu bar title so the two can never disagree about
401+ /// which of the windows is the one that matters.
402+ static func claudeHeadline(for usage: ClaudeUsage?) -> (percent: Int, resetsIn: String?)? {
403+ guard let usage = usage else { return nil }
404+ if let active = usage.activeLimit {
405+ return (active.percent, active.resetsInFormatted)
569406 }
570− if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
571− return Int(opus.utilization.rounded())
407+ if let fiveHour = usage.fiveHour {
408+ return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
572409 }
573410 return nil
574411 }
575412
413+ /// The Claude percentage for the menu bar title, or `nil` when Claude Code isn't
414+ /// signed in on this machine — in which case the title is Hyper's alone.
415+ var claudeStatusBarPercent: Int? {
416+ Self.claudeHeadline(for: claudeUsage)?.percent
417+ }
418+
576419 /// Display label for the Claude plan, e.g. "Pro" or "Max".
577420 var claudePlanLabel: String? {
578421 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -588,88 +431,6 @@ final class ViewModel: ObservableObject {
588431 }
589432 }
590433
591− // MARK: - z.ai Usage
592−
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 {
596− case notConfigured
597− case usage(ZaiUsageReport)
598− case failure(String)
599− }
600−
601− private func fetchZaiUsage(key: String) async -> ZaiOutcome {
602− guard !key.isEmpty else { return .notConfigured }
603− do {
604− return .usage(try await zaiChecker.fetchUsage(apiKey: key))
605− } catch ZaiUsageError.noAPIKey {
606− return .notConfigured
607− } catch {
608− return .failure(error.localizedDescription)
609− }
610− }
611−
612− private func applyZai(_ outcome: ZaiOutcome) {
613− switch outcome {
614− case .notConfigured:
615− zaiUsage = nil
616− zaiError = nil
617− zaiHistory.removeAll()
618−
619− case .usage(let report):
620− // Capture the previous 5-hour % for threshold-crossing notification.
621− let previousPercent = zaiFiveHourPercent
622− zaiUsage = report
623− zaiError = nil
624−
625− // Record history and fire high-usage notification based on the 5-hour %.
626− if let percent = zaiFiveHourPercent {
627− let now = Date()
628− recordMetric(percent, into: &zaiHistory, at: now)
629−
630− // High-usage notification: on threshold crossing (<90 → ≥90)
631− // OR on first successful fetch if already above threshold.
632− if let previous = previousPercent, previous < Self.highUsageThreshold,
633− percent >= Self.highUsageThreshold {
634− sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
635− } else if previousPercent == nil, percent >= Self.highUsageThreshold {
636− sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
637− }
638− }
639−
640− case .failure(let message):
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.
644− zaiError = message
645− }
646− }
647−
648− /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
649− var zaiFiveHourPercent: Int? {
650− zaiUsage?.fiveHourPercent
651− }
652−
653− /// The weekly window percentage for the popover, or `nil` when absent.
654− var zaiWeeklyPercent: Int? {
655− zaiUsage?.weeklyPercent
656− }
657−
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 }
663−
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
671− }
672−
673434 // MARK: - Clipboard
674435
675436 /// Copies the current balance to the clipboard as plain digits (no grouping
@@ -690,41 +451,7 @@ final class ViewModel: ObservableObject {
690451 }
691452 }
692453
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
454+ // MARK: - Low Balance Notification
728455
729456 private func sendLowBalanceNotification(balance: Int) {
730457 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -753,29 +480,6 @@ final class ViewModel: ObservableObject {
753480 }
754481 }
755482
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−
779483 // MARK: - API Key
780484
781485 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -790,7 +494,6 @@ final class ViewModel: ObservableObject {
790494 }
791495 apiKeyInput = ""
792496 activeAPIKey = ""
793− hyperConfigured = false
794497 // refresh() with an empty key clears balance, error, history, and
795498 // cancels any in-flight task from the previous key.
796499 refresh()
@@ -814,7 +517,6 @@ final class ViewModel: ObservableObject {
814517 // Show the trimmed key that was actually stored.
815518 apiKeyInput = key
816519 activeAPIKey = key
817− hyperConfigured = true
818520 refresh()
819521 }
820522
@@ -830,53 +532,6 @@ final class ViewModel: ObservableObject {
830532 }
831533 }
832534
833− // MARK: - z.ai API Key
834−
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)
839− if key.isEmpty {
840− let deleted = ZaiKeychainHelper.delete()
841− guard deleted else {
842− zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
843− return
844− }
845− zaiAPIKeyInput = ""
846− activeZaiAPIKey = ""
847− // refresh() with an empty key clears zai usage and error, and
848− // cancels any in-flight task from the previous key.
849− refresh()
850− } else {
851− let saved = ZaiKeychainHelper.save(key)
852− guard saved else {
853− zaiError = "Could not save z.ai API key to Keychain. It may be locked."
854− return
855− }
856− // A different key is a different account, so everything derived from the
857− // old one has to go.
858− if key != activeZaiAPIKey {
859− zaiUsage = nil
860− zaiError = nil
861− zaiHistory.removeAll()
862− }
863− // Show the trimmed key that was actually stored.
864− zaiAPIKeyInput = key
865− activeZaiAPIKey = key
866− refresh()
867− }
868−
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
877− }
878− }
879−
880535 // MARK: - Launch at Login
881536
882537 func updateLaunchAtLogin(_ enabled: Bool) {
@@ -896,4 +551,4 @@ final class ViewModel: ObservableObject {
896551 isInitializingLaunchAtLogin = false
897552 }
898553 }
899−}
554+}