@@ -4,21 +4,15 @@ 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
10 private static let websiteURL = URL(string: "https://hyper.charm.land")!
7 private static let websiteURL = URL(string: "https://hyper.charm.land")!
11 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
8 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
13 // MARK: - Type Scale
10 // MARK: - Type Scale
15− /// One rounded family, sized so that the balance is the only large element and
12+ /// One rounded family, sized so that every provider's headline number reads at the
16− /// everything else steps down from it — nothing competes with it for attention.
13+ /// same weight. With three providers in the popover, the 26pt subhero is the right
17− /// The Claude percentage is the one exception, and it is deliberately set well
14+ /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
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.
15+ /// numbers from jittering as their digits change.
20− private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
22 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
23 .monospacedDigit()
17 .monospacedDigit()
24 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -28,7 +22,9 @@ struct MenuView: View {
29 var body: some View {
23 var body: some View {
30 VStack(alignment: .leading, spacing: 18) {
24 VStack(alignment: .leading, spacing: 18) {
25+ if viewModel.hyperConfigured {
32 if showsClaude {
28 if showsClaude {
33 serviceBreak
29 serviceBreak
@@ -51,10 +47,14 @@ struct MenuView: View {
51 .background(Color(nsColor: .windowBackgroundColor))
47 .background(Color(nsColor: .windowBackgroundColor))
52 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
53 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
49 .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)
54 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
55 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
56 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
57− .animation(.easeInOut(duration: 0.3), value: historyValues.count)
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)
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,15 +66,14 @@ struct MenuView: View {
66 Divider().opacity(0.3)
66 Divider().opacity(0.3)
69− /// The rule between the two services. Wider than the others on purpose: Hyper and
69+ /// The rule between services. Wider than the others on purpose: the providers are
70− /// Claude are two unrelated accounts, and the gap should say so before the headers do.
70+ /// 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 sections rather than a hero with two appendices.
76+ /// so the popover reads as three equal sections rather than a hero with appendages.
77− /// The icon is what tells the two services apart at a glance.
78 private func sectionHeader(_ title: String, systemImage: String) -> some View {
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
79 HStack(spacing: 5) {
78 HStack(spacing: 5) {
80 Image(systemName: systemImage)
79 Image(systemName: systemImage)
@@ -85,113 +84,62 @@ struct MenuView: View {
88− // MARK: - Hyper
87+ /// A reusable headline number row: the provider's main metric at subhero scale,
89−
88+ /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
90− /// The Hyper section: its header, then the balance as the popover's hero.
89+ /// copy-confirmation slot so the layout doesn't shift.
90+ private func headlineNumber(
94+ copiedConfirmation: Bool,
96+ copyAction: @escaping () -> Void
91− private var hyper: some View {
97+ ) -> some View {
92− VStack(alignment: .leading, spacing: 12) {
98+ VStack(spacing: 6) {
93− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
99+ Button(action: copyAction) {
94−
100+ HStack(alignment: .firstTextBaseline, spacing: 6) {
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) {
106− Text("⚡?")
101+ Text(text)
107− .font(Self.heroFont)
102+ .font(Self.subheroFont)
108− .foregroundColor(.secondary)
103+ .foregroundColor(color)
104+ .contentTransition(.opacity)
110− Text("Enter your API key below")
106+ if let symbol = trend.symbolName {
111− .font(Self.captionFont)
107+ Image(systemName: symbol)
108+ .font(.system(size: 12, weight: .semibold))
112− .foregroundColor(.secondary)
109+ .foregroundColor(.secondary)
114− .transition(.opacity)
110+ .transition(.opacity)
116− VStack(spacing: 6) {
121− .transition(.opacity)
113+ .contentShape(Rectangle())
123−
115+ .buttonStyle(.plain)
124− if let lastUpdatedText = viewModel.lastUpdatedText {
116+ .help("Click to copy")
125− Text(lastUpdatedText)
117+
126− .font(Self.footnoteFont)
118+ // Caption doubles as copy confirmation so the layout doesn't shift.
127− .foregroundColor(.secondary)
119+ Group {
128− .transition(.opacity)
120+ if copiedConfirmation {
129− }
121+ Text("✓ Copied")
130−
122+ .foregroundColor(.green)
131− if let error = viewModel.errorMessage {
123+ } else {
132− errorRow(error)
124+ Text(captionText)
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))
151 .foregroundColor(.secondary)
125 .foregroundColor(.secondary)
152− .transition(.opacity)
155− // Without this the hover area is only the glyphs themselves.
128+ .font(Self.captionFont)
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.
180 @ViewBuilder
133 @ViewBuilder
181− private var sparkline: some View {
134+ private func sparkline(values: [Int], color: Color) -> some View {
182− if historyValues.count >= 2 {
135+ if values.count >= 2 {
183− Sparkline(values: historyValues, color: viewModel.balanceColor)
136+ Sparkline(values: values, color: color)
184 .frame(height: 26)
137 .frame(height: 26)
185 .padding(.top, 4)
138 .padding(.top, 4)
186 .transition(.opacity)
139 .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 }
195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
196 /// message, and a tinted rounded background with no border.
144 /// message, and a tinted rounded background with no border.
197 private func errorRow(_ message: String) -> some View {
145 private func errorRow(_ message: String) -> some View {
@@ -216,6 +164,56 @@ struct MenuView: View {
216 .padding(.top, 2)
164 .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)
219 // MARK: - Claude
217 // MARK: - Claude
221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -227,8 +225,8 @@ struct MenuView: View {
227 return false
225 return false
230− /// Claude Code's plan limits. The percentage leads, as the balance does above it,
228+ /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
231− /// with the individual windows underneath as quiet supporting detail.
229+ /// headline number, bars under every window, a sparkline, and error row.
232 private var claude: some View {
230 private var claude: some View {
233 VStack(alignment: .leading, spacing: 12) {
231 VStack(alignment: .leading, spacing: 12) {
234 HStack(spacing: 8) {
232 HStack(spacing: 8) {
@@ -255,23 +253,31 @@ struct MenuView: View {
256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
257 VStack(alignment: .leading, spacing: 12) {
255 VStack(alignment: .leading, spacing: 12) {
258− // The 5-hour window leads as the hero number, since it's the one
256+ // The 5-hour window leads as the headline number.
259− // most likely to bind and the one shown in the menu bar title.
260 if let fiveHour = usage.fiveHour {
257 if let fiveHour = usage.fiveHour {
261 let percent = Int(fiveHour.utilization.rounded())
258 let percent = Int(fiveHour.utilization.rounded())
259+ VStack(spacing: 8) {
262− HStack(alignment: .firstTextBaseline, spacing: 8) {
260+ HStack(alignment: .firstTextBaseline, spacing: 8) {
263− Text("🕐 \(percent)%")
262+ text: "🕐 \(percent)%",
264− .font(Self.subheroFont)
265− .foregroundColor(Self.usageColor(percent))
263+ color: Self.usageColor(percent),
266− .contentTransition(.opacity)
264+ trend: viewModel.claudeTrend,
265+ copiedConfirmation: viewModel.copiedClaudeConfirmation,
266+ captionText: "5-hour usage",
267+ copyAction: { viewModel.copyClaudePercentToClipboard() }
268− Spacer(minLength: 0)
270+ Spacer(minLength: 0)
270− if let resets = fiveHour.resetsInFormatted {
272+ if let resets = fiveHour.resetsInFormatted {
271− Text("resets in \(resets)")
273+ Text("resets in \(resets)")
272− .font(Self.footnoteFont)
274+ .font(Self.footnoteFont)
273− .foregroundColor(.secondary)
275+ .foregroundColor(.secondary)
279+ // Bar under the 5-hour window too, same as 7-day windows below.
280+ UsageBar(percent: percent, color: Self.usageColor(percent))
@@ -285,6 +291,12 @@ struct MenuView: View {
285 window("7-day Opus", opus)
291 window("7-day Opus", opus)
295+ // Sparkline for the Claude 5-hour % history.
297+ values: viewModel.claudeSparklineValues,
298+ color: Self.usageColor(claudeFiveHourPercent ?? 0)
@@ -310,6 +322,11 @@ 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
313 /// The balance colors read the other way round here, because for a limit more is
330 /// The balance colors read the other way round here, because for a limit more is
314 /// worse: green with room to spare, yellow closing in, red nearly spent.
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
315 private static func usageColor(_ percent: Int) -> Color {
332 private static func usageColor(_ percent: Int) -> Color {
@@ -329,8 +346,8 @@ struct MenuView: View {
329 return false
346 return false
332− /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
349+ /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
333− /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
350+ /// a subhero headline number, bars under every window, a sparkline, and error row.
334 private var zai: some View {
351 private var zai: some View {
335 VStack(alignment: .leading, spacing: 12) {
352 VStack(alignment: .leading, spacing: 12) {
336 HStack(spacing: 8) {
353 HStack(spacing: 8) {
@@ -357,21 +374,30 @@ struct MenuView: View {
358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
359 VStack(alignment: .leading, spacing: 12) {
376 VStack(alignment: .leading, spacing: 12) {
360− // The 5-hour window leads as the hero number, same as Claude's.
377+ // The 5-hour window leads as the headline number.
361 if let fiveHour = usage.fiveHourPercent {
378 if let fiveHour = usage.fiveHourPercent {
379+ VStack(spacing: 8) {
362− HStack(alignment: .firstTextBaseline, spacing: 8) {
380+ HStack(alignment: .firstTextBaseline, spacing: 8) {
363− Text("🤖 \(fiveHour)%")
382+ text: "🤖 \(fiveHour)%",
364− .font(Self.subheroFont)
365− .foregroundColor(Self.usageColor(fiveHour))
383+ color: Self.usageColor(fiveHour),
366− .contentTransition(.opacity)
384+ trend: viewModel.zaiTrend,
385+ copiedConfirmation: viewModel.copiedZaiConfirmation,
386+ captionText: "5-hour usage",
387+ copyAction: { viewModel.copyZaiPercentToClipboard() }
368− Spacer(minLength: 0)
390+ Spacer(minLength: 0)
370− if let resets = usage.fiveHourResetsAt {
392+ if let resets = usage.fiveHourResetsAt {
371− Text("resets in \(Self.zaiResetsIn(from: resets))")
393+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372− .font(Self.footnoteFont)
394+ .font(Self.footnoteFont)
373− .foregroundColor(.secondary)
395+ .foregroundColor(.secondary)
399+ // Bar under the 5-hour window too.
400+ UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
@@ -380,9 +406,20 @@ struct MenuView: View {
380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
410+ // Sparkline for the z.ai 5-hour % history.
412+ values: viewModel.zaiSparklineValues,
413+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
418+ /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
419+ private var zaiFiveHourPercent: Int? {
420+ viewModel.zaiFiveHourPercent
386 /// One window row for z.ai: label, percentage, and a hairline bar.
423 /// One window row for z.ai: label, percentage, and a hairline bar.
387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
424 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388 VStack(alignment: .leading, spacing: 5) {
425 VStack(alignment: .leading, spacing: 5) {
@@ -523,45 +560,10 @@ struct MenuView: View {
523 .keyboardShortcut("q")
560 .keyboardShortcut("q")
525 .controlSize(.small)
562 .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 }
565// MARK: - Usage Bar
567// MARK: - Usage Bar
567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
569/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -592,8 +594,8 @@ private struct UsageBar: View {
593// MARK: - Sparkline
595// MARK: - Sparkline
595−/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
597+/// A minimal line chart of recent values: 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
598+/// takes the metric's color so the trend reads as part of the number above it, with a
597/// faint fade underneath for weight and a dot marking the latest point.
599/// faint fade underneath for weight and a dot marking the latest point.
599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
601/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -685,4 +687,4 @@ struct Sparkline: View {
685 path.closeSubpath()
687 path.closeSubpath()