3/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
5 @ObservedObject var viewModel: ViewModel
7 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")!
12 /// One rounded family, sized so that every provider's headline number reads at the
13 /// same weight. With three providers in the popover, the 26pt subhero is the right
14 /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15 /// numbers from jittering as their digits change.
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
19 private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
20 private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
21 private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
24 VStack(alignment: .leading, spacing: 18) {
25 if viewModel.hyperConfigured {
41 .padding(.horizontal, 18)
45 // Set as the environment font so the controls inherit the rounded design too.
46 .font(Self.controlFont)
47 .background(Color(nsColor: .windowBackgroundColor))
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
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)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55 .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56 .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57 .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
61 .animation(.easeInOut(duration: 0.2), value: viewModel.zaiError)
64 /// A section rule light enough to read as a pause rather than a border.
65 private var hairline: some View {
66 Divider().opacity(0.3)
69 /// The rule between services. Wider than the others on purpose: the providers are
70 /// unrelated accounts, and the gap should say so before the headers do.
71 private var serviceBreak: some View {
72 hairline.padding(.vertical, 5)
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.
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
79 Image(systemName: systemImage)
80 .font(.system(size: 11, weight: .semibold))
83 .font(Self.sectionFont)
87 /// A reusable headline number row: the provider's main metric at subhero scale,
88 /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89 /// copy-confirmation slot so the layout doesn't shift.
90 private func headlineNumber(
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 {
107 Image(systemName: symbol)
108 .font(.system(size: 12, weight: .semibold))
109 .foregroundColor(.secondary)
110 .transition(.opacity)
113 .contentShape(Rectangle())
116 .help("Click to copy")
118 // Caption doubles as copy confirmation so the layout doesn't shift.
120 if copiedConfirmation {
122 .foregroundColor(.green)
125 .foregroundColor(.secondary)
128 .font(Self.captionFont)
132 /// A sparkline shown when there are at least 2 history points.
134 private func sparkline(values: [Int], color: Color) -> some View {
135 if values.count >= 2 {
136 Sparkline(values: values, color: color)
139 .transition(.opacity)
143 /// 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.
145 private func errorRow(_ message: String) -> some View {
146 HStack(alignment: .top, spacing: 6) {
147 Image(systemName: "exclamationmark.triangle.fill")
148 .font(.system(size: 10, weight: .semibold))
151 .font(Self.captionFont)
152 // Long messages wrap instead of being truncated to one line.
153 .fixedSize(horizontal: false, vertical: true)
157 .foregroundColor(.red)
158 .padding(.horizontal, 10)
159 .padding(.vertical, 7)
161 RoundedRectangle(cornerRadius: 8, style: .continuous)
162 .fill(Color.red.opacity(0.1))
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 {
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 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
220 /// isn't signed in on this machine — the section doesn't appear, and no error is
221 /// shown for a service the user may simply not use.
222 private var showsClaude: Bool {
223 if viewModel.claudeError != nil { return true }
224 if let usage = viewModel.claudeUsage { return !usage.isEmpty }
228 /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229 /// headline number, bars under every window, a sparkline, and error row.
230 private var claude: some View {
231 VStack(alignment: .leading, spacing: 12) {
233 sectionHeader("Claude Code", systemImage: "sparkles")
237 if let plan = viewModel.claudePlanLabel {
239 .font(Self.captionFont)
240 .foregroundColor(.secondary)
244 if let usage = viewModel.claudeUsage, !usage.isEmpty {
248 if let error = viewModel.claudeError {
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255 VStack(alignment: .leading, spacing: 12) {
256 // The 5-hour window leads as the headline number.
257 if let fiveHour = usage.fiveHour {
258 let percent = Int(fiveHour.utilization.rounded())
260 HStack(alignment: .firstTextBaseline, spacing: 8) {
262 text: "🕐 \(percent)%",
263 color: Self.usageColor(percent),
264 trend: viewModel.claudeTrend,
265 copiedConfirmation: viewModel.copiedClaudeConfirmation,
266 captionText: "5-hour usage",
267 copyAction: { viewModel.copyClaudePercentToClipboard() }
272 if let resets = fiveHour.resetsInFormatted {
273 Text("resets in \(resets)")
274 .font(Self.footnoteFont)
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))
284 VStack(alignment: .leading, spacing: 9) {
285 if let sevenDay = usage.sevenDay {
286 window("7-day", sevenDay)
288 // Opus has its own weekly cap on Max plans, but it is only worth a row
289 // once it has actually been used.
290 if let opus = usage.sevenDayOpus, opus.utilization > 0 {
291 window("7-day Opus", opus)
295 // Sparkline for the Claude 5-hour % history.
297 values: viewModel.claudeSparklineValues,
298 color: Self.usageColor(claudeFiveHourPercent ?? 0)
303 /// One window: its name, its share of the allowance, and a hairline bar. The bar is
304 /// the only chrome here, and it carries the same color as the number beside it.
305 private func window(_ label: String, _ window: UsageWindow) -> some View {
306 let percent = Int(window.utilization.rounded())
308 return VStack(alignment: .leading, spacing: 5) {
311 .font(Self.captionFont)
312 .foregroundColor(.secondary)
317 .font(Self.captionFont.monospacedDigit())
318 .foregroundColor(Self.usageColor(percent))
321 UsageBar(percent: percent, color: Self.usageColor(percent))
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
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
332 private static func usageColor(_ percent: Int) -> Color {
333 if percent >= 90 { return .red }
334 if percent >= 70 { return .yellow }
340 /// Whether there is anything to say about z.ai. Nothing at all when no API key has
341 /// been entered — the section doesn't appear, and no error is shown for a service
342 /// the user may simply not use.
343 private var showsZai: Bool {
344 if viewModel.zaiError != nil { return true }
345 if let usage = viewModel.zaiUsage { return !usage.isEmpty }
349 /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350 /// a subhero headline number, bars under every window, a sparkline, and error row.
351 private var zai: some View {
352 VStack(alignment: .leading, spacing: 12) {
354 sectionHeader("z.ai Coding", systemImage: "bolt.horizontal")
358 if let plan = viewModel.zaiPlanLabel {
360 .font(Self.captionFont)
361 .foregroundColor(.secondary)
365 if let usage = viewModel.zaiUsage, !usage.isEmpty {
366 zaiUsageDetail(usage)
369 if let error = viewModel.zaiError {
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376 VStack(alignment: .leading, spacing: 12) {
377 // The 5-hour window leads as the headline number.
378 if let fiveHour = usage.fiveHourPercent {
380 HStack(alignment: .firstTextBaseline, spacing: 8) {
382 text: "🤖 \(fiveHour)%",
383 color: Self.usageColor(fiveHour),
384 trend: viewModel.zaiTrend,
385 copiedConfirmation: viewModel.copiedZaiConfirmation,
386 captionText: "5-hour usage",
387 copyAction: { viewModel.copyZaiPercentToClipboard() }
392 if let resets = usage.fiveHourResetsAt {
393 Text("resets in \(Self.zaiResetsIn(from: resets))")
394 .font(Self.footnoteFont)
395 .foregroundColor(.secondary)
399 // Bar under the 5-hour window too.
400 UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
404 VStack(alignment: .leading, spacing: 9) {
405 if let weekly = usage.weeklyPercent {
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
423 /// One window row for z.ai: label, percentage, and a hairline bar.
424 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
425 VStack(alignment: .leading, spacing: 5) {
428 .font(Self.captionFont)
429 .foregroundColor(.secondary)
434 .font(Self.captionFont.monospacedDigit())
435 .foregroundColor(Self.usageColor(percent))
438 UsageBar(percent: percent, color: Self.usageColor(percent))
442 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
443 private static func zaiResetsIn(from date: Date) -> String {
444 let interval = date.timeIntervalSince(Date())
445 guard interval > 0 else { return "soon" }
447 let seconds = Int(interval.rounded())
448 let days = seconds / 86_400
449 let hours = (seconds % 86_400) / 3_600
450 let minutes = (seconds % 3_600) / 60
452 if days > 0 { return "\(days)d \(hours)h" }
453 if hours > 0 { return "\(hours)h \(minutes)m" }
459 private var settings: some View {
460 VStack(alignment: .leading, spacing: 14) {
461 VStack(alignment: .leading, spacing: 8) {
463 sectionHeader("Hyper API Key", systemImage: "bolt.fill")
467 // The confirmation takes the link's place rather than adding a row
468 // of its own, so saving never resizes the popover.
469 if viewModel.savedConfirmation {
471 .font(Self.captionFont)
472 .foregroundColor(.green)
473 .transition(.opacity)
475 Link("Get key →", destination: Self.websiteURL)
476 .font(Self.captionFont)
477 .transition(.opacity)
482 SecureField("sk-…", text: $viewModel.apiKeyInput)
483 .textFieldStyle(.roundedBorder)
486 viewModel.saveAPIKey()
492 VStack(alignment: .leading, spacing: 8) {
494 sectionHeader("z.ai API Key", systemImage: "bolt.horizontal")
498 // The confirmation takes the link's place rather than adding a row
499 // of its own, so saving never resizes the popover.
500 if viewModel.savedConfirmation {
502 .font(Self.captionFont)
503 .foregroundColor(.green)
504 .transition(.opacity)
506 Link("Get key →", destination: Self.zaiKeyURL)
507 .font(Self.captionFont)
508 .transition(.opacity)
513 SecureField("your z.ai key", text: $viewModel.zaiAPIKeyInput)
514 .textFieldStyle(.roundedBorder)
517 viewModel.saveZaiAPIKey()
524 Text("Refresh every")
528 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
529 ForEach(RefreshInterval.options, id: \.self) { minutes in
530 Text("\(minutes)m").tag(minutes)
539 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
540 .toggleStyle(.switch)
547 private var footer: some View {
548 VStack(spacing: 14) {
550 Button(action: { viewModel.refresh() }) {
551 Label("Refresh", systemImage: "arrow.clockwise")
553 .disabled(viewModel.isLoading)
555 Spacer(minLength: 12)
558 NSApplication.shared.terminate(nil)
560 .keyboardShortcut("q")
569/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
570/// rule under the label rather than as a control.
571private struct UsageBar: View {
572 /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
576 private let height: CGFloat = 3
578 var body: some View {
579 GeometryReader { geometry in
580 let fraction = min(max(Double(percent) / 100, 0), 1)
582 ZStack(alignment: .leading) {
584 .fill(Color.secondary.opacity(0.15))
588 .frame(width: geometry.size.width * fraction)
591 .frame(height: height)
597/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
598/// takes the metric's color so the trend reads as part of the number above it, with a
599/// faint fade underneath for weight and a dot marking the latest point.
601/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
602/// readable when fetches are irregular (wake-ups, manual refreshes).
603struct Sparkline: View {
605 var color: Color = .secondary
607 /// Keeps the stroke and the dot from being clipped at the edges.
608 private let verticalInset: CGFloat = 3
609 private let dotRadius: CGFloat = 2.5
611 var body: some View {
612 GeometryReader { geometry in
613 let points = points(in: geometry.size)
615 if points.count >= 2 {
617 fill(under: points, height: geometry.size.height)
620 colors: [color.opacity(0.18), color.opacity(0)],
626 line(through: points)
629 style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
632 if let last = points.last {
635 .frame(width: dotRadius * 2, height: dotRadius * 2)
643 /// Normalizes the values into the available space, oldest at the left.
644 private func points(in size: CGSize) -> [CGPoint] {
645 guard values.count >= 2 else { return [] }
647 let minValue = values.min() ?? 0
648 let maxValue = values.max() ?? 0
649 let range = Double(maxValue - minValue)
651 // The dot is centered on the last point, so the line stops a radius short of
652 // the trailing edge rather than letting the dot spill outside the frame.
653 let usableWidth = max(size.width - dotRadius, 1)
654 let usableHeight = max(size.height - verticalInset * 2, 1)
655 let stepX = usableWidth / CGFloat(values.count - 1)
657 return values.enumerated().map { index, value in
658 // A flat series has no range to normalize against; center it.
659 let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
661 x: CGFloat(index) * stepX,
662 y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
667 private func line(through points: [CGPoint]) -> Path {
669 guard let first = points.first else { return }
671 for point in points.dropFirst() {
672 path.addLine(to: point)
677 /// The line closed down to the baseline, so it can be filled.
678 private func fill(under points: [CGPoint], height: CGFloat) -> Path {
680 guard let first = points.first, let last = points.last else { return }
681 path.move(to: CGPoint(x: first.x, y: height))
682 path.addLine(to: first)
683 for point in points.dropFirst() {
684 path.addLine(to: point)
686 path.addLine(to: CGPoint(x: last.x, y: height))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list