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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ debug_*.swift
# Misc
.DS_Store
.vscode/

# Cursor / SpecStory local state (never commit)
.cursor/hooks/state/
.specstory/
.codex/environments/
.swiftpm-cache/

Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@
- Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.***
- Claude CLI status line is custom + user-configurable; never rely on it for usage parsing.
- Cookie imports: default Chrome-only when possible to avoid other browser prompts; override via browser list when needed.

## Learned User Preferences
- When extending provider usage models (e.g. MiniMax), mirror existing field and UI patterns; add new fields using the same conventions as neighboring code.

## Learned Workspace Facts
- MiniMax Coding Plan `model_remains` weekly fields may arrive as both zeros, or with only one of `current_weekly_total_count` / `current_weekly_usage_count` present and zero. CodexBar treats “at least one weekly key present and both sides numerically zero when missing counts as zero” as no weekly cap. Interval window lines that are 0/0 placeholders are suppressed in the menu card so they are not mistaken for weekly limits.
- `swift build -c release` only refreshes the `.build/.../CodexBar` binary. The launchable root `CodexBar.app` is recreated by `Scripts/package_app.sh` or `Scripts/compile_and_run.sh`; if UI behavior looks stale, compare the bundle `CodexGitCommit` in `Contents/Info.plist` with `git rev-parse --short HEAD`.
- MiniMax menu usage is rendered inside one hosted `NSMenuItem`, so height limiting, scrolling, and section collapsing must happen inside that card to keep the bottom app-level menu items visible. Current MiniMax behavior: collapse state is keyed by section title, 5+ row sections default to collapsed, and Preferences mirrors the sections with scrolling only (no collapse).
42 changes: 42 additions & 0 deletions Sources/CodexBar/MenuCardClickToCopy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import AppKit
import SwiftUI

// MARK: - Copy-on-click overlay

struct ClickToCopyOverlay: NSViewRepresentable {
let copyText: String

func makeNSView(context: Context) -> ClickToCopyView {
ClickToCopyView(copyText: self.copyText)
}

func updateNSView(_ nsView: ClickToCopyView, context: Context) {
nsView.copyText = self.copyText
}
}

final class ClickToCopyView: NSView {
var copyText: String

init(copyText: String) {
self.copyText = copyText
super.init(frame: .zero)
self.wantsLayer = false
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}

override func mouseDown(with event: NSEvent) {
_ = event
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(self.copyText, forType: .string)
}
}
188 changes: 110 additions & 78 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,31 @@ struct UsageMenuCardView: View {
let spendLine: String
}

/// Grouped Token Plan rows (`model_remains[]`) for MiniMax menu card.
struct MiniMaxSection {
let title: String
let rows: [MiniMaxRow]
}

struct MiniMaxRow: Identifiable, Equatable {
let id: String
let title: String
let percent: Double?
let percentStyle: PercentStyle
let resetText: String?
let detailText: String?
let secondaryLine: String?
}

let provider: UsageProvider
let providerName: String
let email: String
let subtitleText: String
let subtitleStyle: SubtitleStyle
let planText: String?
let metrics: [Metric]
/// Non-nil only for MiniMax when `model_remains` has more than one row or weekly detail.
let minimaxSections: [MiniMaxSection]?
let usageNotes: [String]
let creditsText: String?
let creditsRemaining: Double?
Expand All @@ -108,13 +126,12 @@ struct UsageMenuCardView: View {

let model: Model
let width: CGFloat
let onMiniMaxLayoutChange: (() -> Void)?
let miniMaxVisibleScreenHeight: CGFloat?
@Environment(\.menuItemHighlighted) private var isHighlighted

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

var body: some View {
Expand All @@ -125,7 +142,8 @@ struct UsageMenuCardView: View {
Divider()
}

if self.model.metrics.isEmpty {
let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false
if self.model.metrics.isEmpty, !hasMiniMaxSections {
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
Expand All @@ -134,22 +152,53 @@ struct UsageMenuCardView: View {
.font(.subheadline)
}
} else {
let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty
let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || hasMiniMaxSections
let hasCredits = self.model.creditsText != nil
let hasProviderCost = self.model.providerCost != nil
let hasCost = self.model.tokenUsage != nil || hasProviderCost

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)
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
Group {
if hasMiniMaxSections {
MiniMaxCappedScrollView(
maxHeight: MiniMaxUILayoutMetrics
.menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight))
{
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)
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
}
if let sections = self.model.minimaxSections, !sections.isEmpty {
MiniMaxTokenPlanSectionsView(
sections: sections,
progressColor: self.model.progressColor,
onLayoutChange: self.onMiniMaxLayoutChange)
}
}
}
} else {
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)
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
}
}
}
}
}
Expand Down Expand Up @@ -216,7 +265,8 @@ struct UsageMenuCardView: View {
private var hasDetails: Bool {
!self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil ||
self.model.tokenUsage != nil ||
self.model.providerCost != nil
self.model.providerCost != nil ||
(self.model.minimaxSections?.isEmpty == false)
}
}

Expand Down Expand Up @@ -456,28 +506,22 @@ struct UsageMenuCardUsageSectionView: View {
let showBottomDivider: Bool
let bottomPadding: CGFloat
let width: CGFloat
let onMiniMaxLayoutChange: (() -> Void)?
let miniMaxVisibleScreenHeight: CGFloat?
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false
VStack(alignment: .leading, spacing: 12) {
if self.model.metrics.isEmpty {
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.font(.subheadline)
if hasMiniMaxSections {
MiniMaxCappedScrollView(
maxHeight: MiniMaxUILayoutMetrics
.menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight))
{
self.usageContent(hasMiniMaxSections: hasMiniMaxSections)
}
} 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)
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
}
self.usageContent(hasMiniMaxSections: hasMiniMaxSections)
}
if self.showBottomDivider {
Divider()
Expand All @@ -488,6 +532,35 @@ struct UsageMenuCardUsageSectionView: View {
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}

@ViewBuilder
private func usageContent(hasMiniMaxSections: Bool) -> some View {
if self.model.metrics.isEmpty, !hasMiniMaxSections {
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.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)
}
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
}
}
if let sections = self.model.minimaxSections, !sections.isEmpty {
MiniMaxTokenPlanSectionsView(
sections: sections,
progressColor: self.model.progressColor,
onLayoutChange: self.onMiniMaxLayoutChange)
}
}
}

struct UsageMenuCardCreditsSectionView: View {
Expand Down Expand Up @@ -527,16 +600,13 @@ private struct CreditsBarContent: View {
let hintCopyText: String?
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted

private var percentLeft: Double? {
guard let creditsRemaining else { return nil }
let percent = (creditsRemaining / Self.fullScaleTokens) * 100
return min(100, max(0, percent))
return min(100, max(0, (creditsRemaining / Self.fullScaleTokens) * 100))
}

private var scaleText: String {
let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))
return "\(scale) tokens"
"\(UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))) tokens"
}

var body: some View {
Expand Down Expand Up @@ -753,6 +823,7 @@ extension UsageMenuCardView.Model {
lastError: input.lastError)
let redacted = Self.redactedText(input: input, subtitle: subtitle)
let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil
let minimaxSections = Self.miniMaxSections(input: input)

return UsageMenuCardView.Model(
provider: input.provider,
Expand All @@ -762,6 +833,7 @@ extension UsageMenuCardView.Model {
subtitleStyle: subtitle.style,
planText: planText,
metrics: metrics,
minimaxSections: minimaxSections,
usageNotes: usageNotes,
creditsText: creditsText,
creditsRemaining: input.credits?.remaining,
Expand Down Expand Up @@ -1509,7 +1581,7 @@ extension UsageMenuCardView.Model {
spendLine: "\(periodLabel): \(used) / \(limit)")
}

private static func clamped(_ value: Double) -> Double {
static func clamped(_ value: Double) -> Double {
min(100, max(0, value))
}

Expand All @@ -1526,43 +1598,3 @@ extension UsageMenuCardView.Model {
UsageFormatter.resetLine(for: window, style: style, now: now)
}
}

// MARK: - Copy-on-click overlay

private struct ClickToCopyOverlay: NSViewRepresentable {
let copyText: String

func makeNSView(context: Context) -> ClickToCopyView {
ClickToCopyView(copyText: self.copyText)
}

func updateNSView(_ nsView: ClickToCopyView, context: Context) {
nsView.copyText = self.copyText
}
}

private final class ClickToCopyView: NSView {
var copyText: String

init(copyText: String) {
self.copyText = copyText
super.init(frame: .zero)
self.wantsLayer = false
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}

override func mouseDown(with event: NSEvent) {
_ = event
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(self.copyText, forType: .string)
}
}
Loading