Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 260 additions & 58 deletions Sources/CodexBar/CostHistoryChartMenuView.swift

Large diffs are not rendered by default.

218 changes: 202 additions & 16 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import AppKit
import CodexBarCore
import SwiftUI

extension View {
fileprivate func menuCardSectionTitleStyle() -> some View {
self
.font(.body)
.fontWeight(.medium)
}
}

/// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels.
struct UsageMenuCardView: View {
struct Model {
Expand All @@ -27,6 +35,8 @@ struct UsageMenuCardView: View {
struct Metric: Identifiable {
let id: String
let title: String
let groupID: String?
let groupTitle: String?
let percent: Double
let percentStyle: PercentStyle
let resetText: String?
Expand All @@ -41,6 +51,27 @@ struct UsageMenuCardView: View {
}
}

struct MetricGroup: Identifiable {
enum Kind {
case builtInPrimary
case providerBucket
}

let id: String
let title: String?
let kind: Kind
let metrics: [Metric]

var internalID: String {
switch self.kind {
case .builtInPrimary:
"builtInPrimary"
case .providerBucket:
"providerBucket:\(self.id)"
}
}
}

enum SubtitleStyle {
case info
case loading
Expand Down Expand Up @@ -82,6 +113,7 @@ struct UsageMenuCardView: View {
let model: Model
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
private static let builtInPrimaryMetricGroupID = "__builtInPrimary"

static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String {
if provider == .openrouter, metric.id == "primary" {
Expand All @@ -90,6 +122,63 @@ struct UsageMenuCardView: View {
return metric.title
}

static func metricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] {
guard !metrics.isEmpty else { return [] }
var groups: [Model.MetricGroup] = []
let primaryMetrics = metrics.filter { $0.groupID == nil }
if !primaryMetrics.isEmpty {
groups.append(.init(
id: self.builtInPrimaryMetricGroupID,
title: nil,
kind: .builtInPrimary,
metrics: primaryMetrics))
}

var supplementalGroups: [String: [Model.Metric]] = [:]
var supplementalOrder: [String] = []
for metric in metrics {
guard let groupID = metric.groupID else { continue }
if supplementalGroups[groupID] == nil {
supplementalOrder.append(groupID)
}
supplementalGroups[groupID, default: []].append(metric)
}

for groupID in supplementalOrder {
guard let metrics = supplementalGroups[groupID], !metrics.isEmpty else { continue }
groups.append(.init(
id: groupID,
title: metrics.first?.groupTitle,
kind: .providerBucket,
metrics: metrics))
}

return groups
}

static func primaryMetricGroup(metrics: [Model.Metric]) -> Model.MetricGroup? {
self.metricGroups(metrics: metrics).first { $0.kind == .builtInPrimary }
}

static func supplementalMetricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] {
self.metricGroups(metrics: metrics).filter { $0.kind == .providerBucket }
}

static func emptyPrimaryMetricGroup() -> Model.MetricGroup {
.init(
id: self.builtInPrimaryMetricGroupID,
title: nil,
kind: .builtInPrimary,
metrics: [])
}

static func displayMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String {
if provider == .openrouter, metric.id == "primary" {
return "API key limit"
}
return metric.title
}

var body: some View {
VStack(alignment: .leading, spacing: 6) {
UsageMenuCardHeaderView(model: self.model)
Expand All @@ -111,15 +200,29 @@ struct UsageMenuCardView: View {
let hasCredits = self.model.creditsText != nil
let hasProviderCost = self.model.providerCost != nil
let hasCost = self.model.tokenUsage != nil || hasProviderCost
let metricGroups = Self.metricGroups(metrics: self.model.metrics)

VStack(alignment: .leading, spacing: 12) {
if hasUsage {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.model.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: Self.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
ForEach(Array(metricGroups.enumerated()), id: \.element.internalID) { index, group in
if index > 0 {
Divider()
}
VStack(alignment: .leading, spacing: 12) {
if let title = group.title {
Text(title)
.menuCardSectionTitleStyle()
}
ForEach(group.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: Self.displayMetricTitle(
provider: self.model.provider,
metric: metric),
progressColor: self.model.progressColor)
}
}
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
Expand Down Expand Up @@ -301,8 +404,7 @@ private struct ProviderCostContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(self.section.title)
.font(.body)
.fontWeight(.medium)
.menuCardSectionTitleStyle()
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
Expand All @@ -328,8 +430,7 @@ private struct MetricRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(self.title)
.font(.body)
.fontWeight(.medium)
.menuCardSectionTitleStyle()
UsageProgressBar(
percent: self.metric.percent,
tint: self.progressColor,
Expand Down Expand Up @@ -425,6 +526,7 @@ struct UsageMenuCardUsageSectionView: View {
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
let metricGroups = UsageMenuCardView.metricGroups(metrics: self.model.metrics)
VStack(alignment: .leading, spacing: 12) {
if self.model.metrics.isEmpty {
if !self.model.usageNotes.isEmpty {
Expand All @@ -435,11 +537,24 @@ struct UsageMenuCardUsageSectionView: View {
.font(.subheadline)
}
} else {
ForEach(self.model.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
ForEach(Array(metricGroups.enumerated()), id: \.element.internalID) { index, group in
if index > 0 {
Divider()
}
VStack(alignment: .leading, spacing: 12) {
if let title = group.title {
Text(title)
.menuCardSectionTitleStyle()
}
ForEach(group.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView.displayMetricTitle(
provider: self.model.provider,
metric: metric),
progressColor: self.model.progressColor)
}
}
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
Expand All @@ -456,6 +571,52 @@ struct UsageMenuCardUsageSectionView: View {
}
}

struct UsageMenuCardMetricGroupSectionView: View {
let provider: UsageProvider
let group: UsageMenuCardView.Model.MetricGroup
let usageNotes: [String]
let placeholder: String?
let topPadding: CGFloat
let bottomPadding: CGFloat
let width: CGFloat
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let title = self.group.title {
Text(title)
.menuCardSectionTitleStyle()
}
if self.group.metrics.isEmpty {
if !self.usageNotes.isEmpty {
UsageNotesContent(notes: self.usageNotes)
} else if let placeholder = self.placeholder {
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.font(.subheadline)
}
} else {
ForEach(self.group.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView.displayMetricTitle(
provider: self.provider,
metric: metric),
progressColor: self.progressColor)
}
if !self.usageNotes.isEmpty {
UsageNotesContent(notes: self.usageNotes)
}
}
}
.padding(.horizontal, 16)
.padding(.top, self.topPadding)
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}
}

struct UsageMenuCardCreditsSectionView: View {
let model: UsageMenuCardView.Model
let showBottomDivider: Bool
Expand Down Expand Up @@ -508,8 +669,7 @@ private struct CreditsBarContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Credits")
.font(.body)
.fontWeight(.medium)
.menuCardSectionTitleStyle()
if let percentLeft {
UsageProgressBar(
percent: percentLeft,
Expand Down Expand Up @@ -924,6 +1084,8 @@ extension UsageMenuCardView.Model {
metrics.append(Metric(
id: "primary",
title: input.metadata.sessionLabel,
groupID: nil,
groupTitle: nil,
percent: Self.clamped(
input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent),
percentStyle: percentStyle,
Expand Down Expand Up @@ -961,6 +1123,8 @@ extension UsageMenuCardView.Model {
metrics.append(Metric(
id: "secondary",
title: input.metadata.weeklyLabel,
groupID: nil,
groupTitle: nil,
percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
percentStyle: percentStyle,
resetText: weeklyResetText,
Expand All @@ -986,6 +1150,8 @@ extension UsageMenuCardView.Model {
metrics.append(Metric(
id: "tertiary",
title: input.metadata.opusLabel ?? "Sonnet",
groupID: nil,
groupTitle: nil,
percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now),
Expand All @@ -995,12 +1161,32 @@ extension UsageMenuCardView.Model {
pacePercent: nil,
paceOnTop: true))
}
for bucketGroup in snapshot.usageBucketGroups {
for bucket in bucketGroup.buckets {
metrics.append(Metric(
id: bucket.id,
title: bucket.title,
groupID: bucketGroup.id,
groupTitle: bucketGroup.title,
percent: Self.clamped(
input.usageBarsShowUsed ? bucket.window.usedPercent : bucket.window.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: bucket.window, style: input.resetTimeDisplayStyle, now: input.now),
detailText: nil,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
}
}

if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent {
let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining
metrics.append(Metric(
id: "code-review",
title: "Code review",
groupID: nil,
groupTitle: nil,
percent: Self.clamped(percent),
percentStyle: percentStyle,
resetText: nil,
Expand Down
29 changes: 29 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ struct MenuDescriptor {
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
}
Self.appendUsageBucketGroupEntries(
entries: &entries,
snapshot: snap,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)

if let cost = snap.providerCost {
if cost.currencyCode == "Quota" {
Expand Down Expand Up @@ -407,6 +412,30 @@ struct MenuDescriptor {
return false
}

private static func appendUsageBucketGroupEntries(
entries: inout [Entry],
snapshot: UsageSnapshot,
resetStyle: ResetTimeDisplayStyle,
showUsed: Bool)
{
for group in snapshot.usageBucketGroups {
guard !group.buckets.isEmpty else { continue }
if entries.count > 1 {
entries.append(.divider)
}
entries.append(.text(group.title, .headline))

for bucket in group.buckets {
self.appendRateWindow(
entries: &entries,
title: bucket.title,
window: bucket.window,
resetStyle: resetStyle,
showUsed: showUsed)
}
}
}

private static func appendRateWindow(
entries: inout [Entry],
title: String,
Expand Down
Loading