Skip to content
Closed
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
252 changes: 228 additions & 24 deletions Sources/CodexBar/StatusItemController+MenuTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,46 +14,250 @@ extension ProviderSwitcherSelection {
}

struct OverviewMenuCardRowView: View {
struct LiteSummary: Equatable {
let title: String
let primaryText: String
let secondaryText: String?
let progressPercent: Double?
let progressAccessibilityLabel: String?
let pacePercent: Double?
let paceOnTop: Bool
let warningMarkerPercents: [Double]

@MainActor
static func make(for model: UsageMenuCardView.Model) -> Self? {
if let metric = model.metrics.first {
let primaryText = metric.statusText ?? metric.percentLabel
let secondaryText = [
metric.detailLeftText,
metric.detailText,
metric.detailRightText,
metric.resetText,
]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty }
return Self(
title: UsageMenuCardView.popupMetricTitle(provider: model.provider, metric: metric),
primaryText: primaryText,
secondaryText: secondaryText,
progressPercent: metric.statusText == nil ? metric.percent : nil,
progressAccessibilityLabel: metric.statusText == nil ? metric.percentStyle.accessibilityLabel : nil,
pacePercent: metric.statusText == nil ? metric.pacePercent : nil,
paceOnTop: metric.paceOnTop,
warningMarkerPercents: metric.statusText == nil ? metric.warningMarkerPercents : [])
}

if let providerCost = model.providerCost {
return Self(
title: providerCost.title,
primaryText: providerCost.percentLine ?? providerCost.spendLine,
secondaryText: providerCost.percentLine == nil ? nil : providerCost.spendLine,
progressPercent: providerCost.percentUsed,
progressAccessibilityLabel: L("Extra usage spent"),
pacePercent: nil,
paceOnTop: true,
warningMarkerPercents: [])
}

if let tokenUsage = model.tokenUsage {
return Self(
title: L("cost_header_estimated"),
primaryText: tokenUsage.sessionLine,
secondaryText: tokenUsage.monthLine,
progressPercent: nil,
progressAccessibilityLabel: nil,
pacePercent: nil,
paceOnTop: true,
warningMarkerPercents: [])
}

if let creditsText = model.creditsText {
return Self(
title: L("Credits"),
primaryText: creditsText,
secondaryText: model.creditsHintText,
progressPercent: nil,
progressAccessibilityLabel: nil,
pacePercent: nil,
paceOnTop: true,
warningMarkerPercents: [])
}

if let resetCredits = model.codexResetCreditsText {
return Self(
title: L("Credits"),
primaryText: resetCredits,
secondaryText: model.codexResetCreditsDetailText,
progressPercent: nil,
progressAccessibilityLabel: nil,
pacePercent: nil,
paceOnTop: true,
warningMarkerPercents: [])
}

if let placeholder = model.placeholder {
return Self(
title: L("Usage"),
primaryText: placeholder,
secondaryText: nil,
progressPercent: nil,
progressAccessibilityLabel: nil,
pacePercent: nil,
paceOnTop: true,
warningMarkerPercents: [])
}

if let note = model.usageNotes.first?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
return Self(
title: L("Usage"),
primaryText: note,
secondaryText: nil,
progressPercent: nil,
progressAccessibilityLabel: nil,
pacePercent: nil,
paceOnTop: true,
warningMarkerPercents: [])
}

return nil
}
}

let model: UsageMenuCardView.Model
let storageText: String?
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor

var body: some View {
VStack(alignment: .leading, spacing: 0) {
UsageMenuCardHeaderSectionView(
model: self.model,
showDivider: self.hasUsageBlock,
width: self.width)
if self.hasUsageBlock {
UsageMenuCardUsageSectionView(
model: self.model,
showBottomDivider: false,
bottomPadding: 6,
width: self.width)
let liveModel = self.resolvedLiveModel(refreshMonitor: self.refreshMonitor)
VStack(alignment: .leading, spacing: 8) {
self.header(model: liveModel, subtitle: self.liveSubtitle(model: liveModel))
if let summary = Self.LiteSummary.make(for: liveModel) {
self.summary(summary, tint: liveModel.progressColor)
}
if let storageText {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(L("Storage")):")
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
Text(storageText)
self.storageLine(storageText)
}
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.vertical, 8)
.frame(width: self.width, alignment: .leading)
}

var liteSummary: LiteSummary? {
Self.LiteSummary.make(for: self.model)
}

@MainActor
func liteSummary(refreshMonitor: MenuCardRefreshMonitor?) -> LiteSummary? {
Self.LiteSummary.make(for: self.resolvedLiveModel(refreshMonitor: refreshMonitor))
}

@MainActor
func resolvedLiveModel(refreshMonitor: MenuCardRefreshMonitor?) -> UsageMenuCardView.Model {
guard self.model.usesLiveSubtitle else { return self.model }
return refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model
}

private func liveSubtitle(model: UsageMenuCardView.Model) -> MenuCardLiveSubtitle {
let fallback = MenuCardLiveSubtitle(text: model.subtitleText, style: model.subtitleStyle)
guard self.model.usesLiveSubtitle else { return fallback }
return self.refreshMonitor?.subtitle(for: self.model.provider, fallback: fallback) ?? fallback
}

private func header(model: UsageMenuCardView.Model, subtitle: MenuCardLiveSubtitle) -> some View {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) {
Text(model.providerName)
.font(.headline)
.fontWeight(.semibold)
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
Spacer(minLength: 8)
Text(model.email)
.font(.subheadline)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.middle)
}

HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) {
Text(subtitle.text)
.font(.footnote)
.foregroundStyle(self.subtitleColor(for: subtitle.style))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
Comment on lines +188 to +193
Spacer(minLength: 8)
if let plan = model.planText {
Text(plan)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
Spacer()
.truncationMode(.tail)
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.top, self.hasUsageBlock ? 0 : 8)
.padding(.bottom, 6)
.frame(width: self.width, alignment: .leading)
}
}
.frame(width: self.width, alignment: .leading)
}

private var hasUsageBlock: Bool {
self.model.hasUsageContent
private func summary(_ summary: LiteSummary, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(summary.title)
.font(.footnote.weight(.medium))
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
Spacer(minLength: 8)
Text(summary.primaryText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.tail)
}
if let progressPercent = summary.progressPercent,
let progressAccessibilityLabel = summary.progressAccessibilityLabel
{
UsageProgressBar(
percent: progressPercent,
tint: tint,
accessibilityLabel: progressAccessibilityLabel,
pacePercent: summary.pacePercent,
paceOnTop: summary.paceOnTop,
warningMarkerPercents: summary.warningMarkerPercents)
}
if let secondaryText = summary.secondaryText {
Text(secondaryText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.tail)
}
}
}

private func storageLine(_ storageText: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(L("Storage")):")
Text(storageText)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
}
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
Comment on lines +243 to +253

private func subtitleColor(for style: UsageMenuCardView.Model.SubtitleStyle) -> Color {
switch style {
case .info: MenuHighlightStyle.secondary(self.isHighlighted)
case .loading: MenuHighlightStyle.secondary(self.isHighlighted)
case .error: MenuHighlightStyle.error(self.isHighlighted)
}
}
}

Expand Down
113 changes: 113 additions & 0 deletions Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import CodexBarCore
import Foundation
import SwiftUI
import Testing
@testable import CodexBar

struct OverviewMenuCardRowViewTests {
@Test
@MainActor
func `overview lite summary uses first metric progress`() throws {
let model = try Self.makeClaudeModel(usedPercent: 25)
let row = OverviewMenuCardRowView(model: model, storageText: nil, width: 310)

let summary = try #require(row.liteSummary)
#expect(summary.progressPercent == 75)
#expect(summary.progressAccessibilityLabel == "Usage remaining")
}

@Test
@MainActor
func `overview lite summary uses monitor resolved refreshed model`() throws {
let staleModel = try Self.makeClaudeModel(usedPercent: 25, updatedAt: Date(timeIntervalSince1970: 1))
let refreshedModel = try Self.makeClaudeModel(usedPercent: 60, updatedAt: Date(timeIntervalSince1970: 2))
let monitor = MenuCardRefreshMonitor { provider in
provider == .claude ? refreshedModel : nil
}
let row = OverviewMenuCardRowView(model: staleModel, storageText: nil, width: 310)

#expect(row.liteSummary?.progressPercent == 75)
let liveSummary = try #require(row.liteSummary(refreshMonitor: monitor))
#expect(liveSummary.progressPercent == 40)
}

@Test
@MainActor
func `overview lite summary ignores inline dashboard only content`() {
let dashboard = InlineUsageDashboardModel(
accessibilityLabel: "Claude usage trend",
valueStyle: .currencyUSD,
kpis: [
InlineUsageDashboardModel.KPI(title: "30d", value: "$1.25", emphasis: true),
],
points: [
InlineUsageDashboardModel.Point(
id: "2023-11-14",
label: "Nov 14",
value: 1.25,
accessibilityValue: "2023-11-14: $1.25"),
],
detailLines: ["Top model: claude-sonnet-4"],
barColor: .orange)
let model = UsageMenuCardView.Model(
provider: .claude,
providerName: "Claude",
email: "user@example.com",
subtitleText: "Updated now",
subtitleStyle: .info,
planText: "Pro",
metrics: [],
usageNotes: [],
openAIAPIUsage: nil,
inlineUsageDashboard: dashboard,
creditsText: nil,
creditsRemaining: nil,
creditsHintText: nil,
creditsHintCopyText: nil,
codexResetCreditsText: nil,
codexResetCreditsDetailText: nil,
providerCost: nil,
tokenUsage: nil,
placeholder: nil,
progressColor: .orange)
let row = OverviewMenuCardRowView(model: model, storageText: nil, width: 310)

#expect(row.liteSummary == nil)
}

private static func makeClaudeModel(
usedPercent: Double,
updatedAt: Date = Date(timeIntervalSince1970: 1_700_000_000)) throws -> UsageMenuCardView.Model
{
let metadata = try #require(ProviderDefaults.metadata[.claude])
let snapshot = UsageSnapshot(
primary: RateWindow(
usedPercent: usedPercent,
windowMinutes: nil,
resetsAt: updatedAt.addingTimeInterval(3600),
resetDescription: nil),
secondary: nil,
updatedAt: updatedAt,
identity: nil)
return UsageMenuCardView.Model.make(.init(
provider: .claude,
metadata: metadata,
snapshot: snapshot,
credits: nil,
creditsError: nil,
dashboard: nil,
dashboardError: nil,
tokenSnapshot: nil,
tokenError: nil,
account: AccountInfo(email: nil, plan: nil),
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
resetTimeDisplayStyle: .countdown,
tokenCostUsageEnabled: false,
showOptionalCreditsAndExtraUsage: true,
hidePersonalInfo: false,
usesLiveSubtitle: true,
now: updatedAt))
}
}