@@ -4,15 +4,21 @@ import SwiftUI
4struct MenuView: View {
4struct MenuView: View {
5 @ObservedObject var viewModel: ViewModel
5 @ObservedObject var viewModel: ViewModel
7+ /// Dims the balance while the pointer is over it, hinting that it is clickable.
8+ @State private var isHoveringBalance = false
7 private static let websiteURL = URL(string: "https://hyper.charm.land")!
10 private static let websiteURL = URL(string: "https://hyper.charm.land")!
8 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
11 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
10 // MARK: - Type Scale
13 // MARK: - Type Scale
12− /// One rounded family, sized so that every provider's headline number reads at the
15+ /// One rounded family, sized so that the balance is the only large element and
13− /// same weight. With three providers in the popover, the 26pt subhero is the right
16+ /// everything else steps down from it — nothing competes with it for attention.
14− /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
17+ /// The Claude percentage is the one exception, and it is deliberately set well
18+ /// below the balance so it reads as the second thing on the page, not the first.
15− /// numbers from jittering as their digits change.
19+ /// Monospaced digits keep numbers from jittering as their digits change.
20+ private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
22 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
17 .monospacedDigit()
23 .monospacedDigit()
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
24 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -22,9 +28,7 @@ struct MenuView: View {
23 var body: some View {
29 var body: some View {
24 VStack(alignment: .leading, spacing: 18) {
30 VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
28 if showsClaude {
32 if showsClaude {
29 serviceBreak
33 serviceBreak
@@ -47,14 +51,10 @@ struct MenuView: View {
47 .background(Color(nsColor: .windowBackgroundColor))
51 .background(Color(nsColor: .windowBackgroundColor))
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
49 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
53 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
54 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
55 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
56 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55− .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
56− .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57− .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,14 +66,15 @@ struct MenuView: View {
66 Divider().opacity(0.3)
66 Divider().opacity(0.3)
69− /// The rule between services. Wider than the others on purpose: the providers are
69+ /// The rule between the two services. Wider than the others on purpose: Hyper and
70− /// unrelated accounts, and the gap should say so before the headers do.
70+ /// Claude are two unrelated accounts, and the gap should say so before the headers do.
71 private var serviceBreak: some View {
71 private var serviceBreak: some View {
72 hairline.padding(.vertical, 5)
72 hairline.padding(.vertical, 5)
75 /// A section header: an icon and a title, sized to match the "API Key" header below
75 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three equal sections rather than a hero with appendages.
76+ /// so the popover reads as three sections rather than a hero with two appendices.
77+ /// The icon is what tells the two services apart at a glance.
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
78 private func sectionHeader(_ title: String, systemImage: String) -> some View {
78 HStack(spacing: 5) {
79 HStack(spacing: 5) {
79 Image(systemName: systemImage)
80 Image(systemName: systemImage)
@@ -84,62 +85,113 @@ struct MenuView: View {
87− /// A reusable headline number row: the provider's main metric at subhero scale,
88+ // MARK: - Hyper
88− /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89− /// copy-confirmation slot so the layout doesn't shift.
90− private func headlineNumber(
94− copiedConfirmation: Bool,
96− copyAction: @escaping () -> Void
99− Button(action: copyAction) {
100− HStack(alignment: .firstTextBaseline, spacing: 6) {
102− .font(Self.subheroFont)
103− .foregroundColor(color)
104− .contentTransition(.opacity)
106− if let symbol = trend.symbolName {
90+ /// The Hyper section: its header, then the balance as the popover's hero.
107− Image(systemName: symbol)
91+ private var hyper: some View {
108− .font(.system(size: 12, weight: .semibold))
92+ VStack(alignment: .leading, spacing: 12) {
93+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
99+ private var hero: some View {
100+ VStack(spacing: 8) {
101+ if viewModel.isLoading && viewModel.balance == nil {
103+ .transition(.opacity)
104+ } else if viewModel.balance == nil {
105+ VStack(spacing: 6) {
107+ .font(Self.heroFont)
109− .foregroundColor(.secondary)
108+ .foregroundColor(.secondary)
110+ Text("Enter your API key below")
111+ .font(Self.captionFont)
112+ .foregroundColor(.secondary)
110− .transition(.opacity)
114+ .transition(.opacity)
116+ VStack(spacing: 6) {
113− .contentShape(Rectangle())
121+ .transition(.opacity)
115− .buttonStyle(.plain)
123+
116− .help("Click to copy")
124+ if let lastUpdatedText = viewModel.lastUpdatedText {
117−
125+ Text(lastUpdatedText)
118− // Caption doubles as copy confirmation so the layout doesn't shift.
126+ .font(Self.footnoteFont)
119− Group {
127+ .foregroundColor(.secondary)
120− if copiedConfirmation {
128+ .transition(.opacity)
121− Text("✓ Copied")
129+ }
122− .foregroundColor(.green)
130+
123− } else {
131+ if let error = viewModel.errorMessage {
124− Text(captionText)
132+ errorRow(error)
133+ .transition(.opacity)
136+ .frame(maxWidth: .infinity)
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)
148+ if let symbol = viewModel.balanceTrend.symbolName {
149+ Image(systemName: symbol)
150+ .font(.system(size: 12, weight: .semibold))
125 .foregroundColor(.secondary)
151 .foregroundColor(.secondary)
152+ .transition(.opacity)
128− .font(Self.captionFont)
155+ // Without this the hover area is only the glyphs themselves.
156+ .contentShape(Rectangle())
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)
166+ /// Doubles as the copy confirmation so the layout doesn't shift.
167+ private var caption: some View {
169+ if viewModel.copiedConfirmation {
171+ .foregroundColor(.green)
174+ .foregroundColor(.secondary)
177+ .font(Self.captionFont)
132− /// A sparkline shown when there are at least 2 history points.
133 @ViewBuilder
180 @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
181+ private var sparkline: some View {
135− if values.count >= 2 {
182+ if historyValues.count >= 2 {
136− Sparkline(values: values, color: color)
183+ Sparkline(values: historyValues, color: viewModel.balanceColor)
137 .frame(height: 26)
184 .frame(height: 26)
138 .padding(.top, 4)
185 .padding(.top, 4)
139 .transition(.opacity)
186 .transition(.opacity)
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 }
143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144 /// message, and a tinted rounded background with no border.
196 /// message, and a tinted rounded background with no border.
145 private func errorRow(_ message: String) -> some View {
197 private func errorRow(_ message: String) -> some View {
@@ -164,56 +216,6 @@ struct MenuView: View {
164 .padding(.top, 2)
216 .padding(.top, 2)
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
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")
180− if viewModel.isLoading && viewModel.balance == nil {
182− .font(Self.subheroFont)
183− .foregroundColor(.secondary)
184− } else if let balance = viewModel.balance {
185− VStack(spacing: 8) {
187− text: "⚡ \(viewModel.formattedBalance)",
188− color: viewModel.balanceColor,
189− trend: viewModel.balanceTrend,
190− copiedConfirmation: viewModel.copiedConfirmation,
191− captionText: "credits",
192− copyAction: { viewModel.copyBalanceToClipboard() }
196− values: viewModel.balanceSparklineValues,
197− color: viewModel.balanceColor
200− .transition(.opacity)
203− if let lastUpdatedText = viewModel.lastUpdatedText {
204− Text(lastUpdatedText)
205− .font(Self.footnoteFont)
206− .foregroundColor(.secondary)
207− .transition(.opacity)
210− if let error = viewModel.errorMessage {
212− .transition(.opacity)
217 // MARK: - Claude
219 // MARK: - Claude
219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -225,8 +227,8 @@ struct MenuView: View {
225 return false
227 return false
228− /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
230+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
229− /// headline number, bars under every window, a sparkline, and error row.
231+ /// with the individual windows underneath as quiet supporting detail.
230 private var claude: some View {
232 private var claude: some View {
231 VStack(alignment: .leading, spacing: 12) {
233 VStack(alignment: .leading, spacing: 12) {
232 HStack(spacing: 8) {
234 HStack(spacing: 8) {
@@ -253,31 +255,23 @@ struct MenuView: View {
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255 VStack(alignment: .leading, spacing: 12) {
257 VStack(alignment: .leading, spacing: 12) {
256− // The 5-hour window leads as the headline number.
258+ // The 5-hour window leads as the hero number, since it's the one
259+ // most likely to bind and the one shown in the menu bar title.
257 if let fiveHour = usage.fiveHour {
260 if let fiveHour = usage.fiveHour {
258 let percent = Int(fiveHour.utilization.rounded())
261 let percent = Int(fiveHour.utilization.rounded())
259− VStack(spacing: 8) {
260− HStack(alignment: .firstTextBaseline, spacing: 8) {
262+ HStack(alignment: .firstTextBaseline, spacing: 8) {
262− text: "⁕ \\(percent)%",
263+ Text("🕐 \(percent)%")
264+ .font(Self.subheroFont)
263− color: Self.usageColor(percent),
265+ .foregroundColor(Self.usageColor(percent))
264− trend: viewModel.claudeTrend,
266+ .contentTransition(.opacity)
265− copiedConfirmation: viewModel.copiedClaudeConfirmation,
266− captionText: "5-hour usage",
267− copyAction: { viewModel.copyClaudePercentToClipboard() }
270− Spacer(minLength: 0)
268+ Spacer(minLength: 0)
272− if let resets = fiveHour.resetsInFormatted {
270+ if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
271+ Text("resets in \(resets)")
274− .font(Self.footnoteFont)
272+ .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
273+ .foregroundColor(.secondary)
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
@@ -291,12 +285,6 @@ struct MenuView: View {
291 window("7-day Opus", opus)
285 window("7-day Opus", opus)
295− // Sparkline for the Claude 5-hour % history.
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
@@ -322,11 +310,6 @@ struct MenuView: View {
325− /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326− private var claudeFiveHourPercent: Int? {
327− viewModel.claudeFiveHourPercent
330 /// The balance colors read the other way round here, because for a limit more is
313 /// The balance colors read the other way round here, because for a limit more is
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
314 /// worse: green with room to spare, yellow closing in, red nearly spent.
332 private static func usageColor(_ percent: Int) -> Color {
315 private static func usageColor(_ percent: Int) -> Color {
@@ -346,8 +329,8 @@ struct MenuView: View {
346 return false
329 return false
349− /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
332+ /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
350− /// a subhero headline number, bars under every window, a sparkline, and error row.
333+ /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
351 private var zai: some View {
334 private var zai: some View {
352 VStack(alignment: .leading, spacing: 12) {
335 VStack(alignment: .leading, spacing: 12) {
353 HStack(spacing: 8) {
336 HStack(spacing: 8) {
@@ -374,30 +357,21 @@ struct MenuView: View {
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376 VStack(alignment: .leading, spacing: 12) {
359 VStack(alignment: .leading, spacing: 12) {
377− // The 5-hour window leads as the headline number.
360+ // The 5-hour window leads as the hero number, same as Claude's.
378 if let fiveHour = usage.fiveHourPercent {
361 if let fiveHour = usage.fiveHourPercent {
379− VStack(spacing: 8) {
380− HStack(alignment: .firstTextBaseline, spacing: 8) {
362+ HStack(alignment: .firstTextBaseline, spacing: 8) {
382− text: "Z \\(fiveHour)%",
363+ Text("🤖 \(fiveHour)%")
364+ .font(Self.subheroFont)
383− color: Self.usageColor(fiveHour),
365+ .foregroundColor(Self.usageColor(fiveHour))
384− trend: viewModel.zaiTrend,
366+ .contentTransition(.opacity)
385− copiedConfirmation: viewModel.copiedZaiConfirmation,
386− captionText: "5-hour usage",
387− copyAction: { viewModel.copyZaiPercentToClipboard() }
390− Spacer(minLength: 0)
368+ Spacer(minLength: 0)
392− if let resets = usage.fiveHourResetsAt {
370+ if let resets = usage.fiveHourResetsAt {
393− Text("resets in \(Self.zaiResetsIn(from: resets))")
371+ Text("resets in \(Self.zaiResetsIn(from: resets))")
394− .font(Self.footnoteFont)
372+ .font(Self.footnoteFont)
395− .foregroundColor(.secondary)
373+ .foregroundColor(.secondary)
399− // Bar under the 5-hour window too.
400− UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
@@ -406,35 +380,9 @@ struct MenuView: View {
406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
410− // Peak hours notice.
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 {
417− values: viewModel.zaiSparklineValues,
418− color: Self.usageColor(zaiFiveHourPercent ?? 0)
422− .transition(.opacity)
424− ViewModel.zaiInPeakHours
425− ? RoundedRectangle(cornerRadius: 4, style: .continuous)
426− .fill(Color.orange.opacity(0.08))
433− /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
434− private var zaiFiveHourPercent: Int? {
435− viewModel.zaiFiveHourPercent
438 /// One window row for z.ai: label, percentage, and a hairline bar.
386 /// One window row for z.ai: label, percentage, and a hairline bar.
439 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440 VStack(alignment: .leading, spacing: 5) {
388 VStack(alignment: .leading, spacing: 5) {
@@ -454,42 +402,6 @@ struct MenuView: View {
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
463− return HStack(spacing: 6) {
465− .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
466− .frame(width: 6, height: 6)
468− Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
469− .font(Self.footnoteFont)
470− .foregroundColor(inPeak ? .orange : .secondary)
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"
480− var utc = DateComponents()
481− utc.timeZone = TimeZone(identifier: "UTC")
483− var calendar = Calendar(identifier: .gregorian)
484− calendar.timeZone = TimeZone(identifier: "UTC")!
485− let start = calendar.date(from: utc)!
488− let end = calendar.date(from: utc)!
490− return "\(formatter.string(from: start))–\(formatter.string(from: end))"
493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
405 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494 private static func zaiResetsIn(from date: Date) -> String {
406 private static func zaiResetsIn(from date: Date) -> String {
495 let interval = date.timeIntervalSince(Date())
407 let interval = date.timeIntervalSince(Date())
@@ -611,10 +523,45 @@ struct MenuView: View {
611 .keyboardShortcut("q")
523 .keyboardShortcut("q")
613 .controlSize(.small)
525 .controlSize(.small)
527+ Link("hyper.charm.land", destination: Self.websiteURL)
528+ .font(Self.footnoteFont)
529+ .foregroundColor(.secondary)
531+ .frame(maxWidth: .infinity, alignment: .center)
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
543+ var body: some View {
544+ VStack(spacing: 6) {
546+ .font(.system(size: 34))
547+ .opacity(isPulsing ? 0.35 : 1)
548+ .scaleEffect(isPulsing ? 0.94 : 1)
550+ .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
553+ // Roughly the height of the balance it stands in for, so the hero
554+ // doesn't jump when the first fetch lands.
558+ .font(.system(size: 11, weight: .medium, design: .rounded))
559+ .foregroundColor(.secondary)
561+ .onAppear { isPulsing = true }
618// MARK: - Usage Bar
565// MARK: - Usage Bar
620/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -645,8 +592,8 @@ private struct UsageBar: View {
646// MARK: - Sparkline
593// MARK: - Sparkline
648−/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
595+/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
649−/// takes the metric's color so the trend reads as part of the number above it, with a
596+/// takes the balance's color so the trend reads as part of the number above it, with a
650/// faint fade underneath for weight and a dot marking the latest point.
597/// faint fade underneath for weight and a dot marking the latest point.
652/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -738,4 +685,4 @@ struct Sparkline: View {
738 path.closeSubpath()
685 path.closeSubpath()