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
43 changes: 43 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,13 @@ extension UsageMenuCardView.Model {
if input.provider == .antigravity {
return Self.antigravityMetrics(input: input, snapshot: snapshot)
}
if input.provider == .minimax {
if let minimaxUsage = snapshot.minimaxUsage {
if let services = minimaxUsage.services, !services.isEmpty {
return Self.minimaxMetrics(services: services, input: input)
}
}
}
var metrics: [Metric] = []
let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil
Expand Down Expand Up @@ -1095,6 +1102,42 @@ extension UsageMenuCardView.Model {
percentStyle: percentStyle),
]
}

private static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] {
let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
var metrics: [Metric] = []

for (index, service) in services.enumerated() {
let id = "minimax-service-\(index)"
let title = service.displayName
let used = service.limit - service.usage // usage is remaining, calculate used
let remaining = service.usage // service.usage is remaining quota
let percent = service.percent // This is used percent

// Adjust display based on usageBarsShowUsed setting
let displayValue: Int = input.usageBarsShowUsed ? used : remaining
let detailText = "\(displayValue)/\(service.limit)"

// Adjust percentage display - BOTH for progress bar AND text
let displayPercent = input.usageBarsShowUsed ? percent : (100 - percent)
let detailLeftText = service.windowType
let detailRightText = String(format: "%.0f%%", displayPercent)

metrics.append(Metric(
id: id,
title: title,
percent: Self.clamped(displayPercent), // Use displayPercent for progress bar too
percentStyle: percentStyle,
resetText: service.resetDescription,
detailText: detailText,
detailLeftText: detailLeftText,
detailRightText: detailRightText,
pacePercent: nil,
paceOnTop: true))
}

return metrics
}

private static func antigravityMetric(
id: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation {
source: context.settings.minimaxCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
auto: "Automatic imports browser cookies and local storage tokens.",
manual: "Paste a Cookie header or cURL capture from the Coding Plan page.",
manual: "Paste a Cookie header or cURL capture from the Token Plan page.",
off: "MiniMax cookies are disabled.")
}

Expand Down Expand Up @@ -122,7 +122,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation {
actions: [
ProviderSettingsActionDescriptor(
id: "minimax-open-dashboard",
title: "Open Coding Plan",
title: "Open Token Plan",
style: .link,
isVisible: nil,
perform: {
Expand All @@ -141,7 +141,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation {
actions: [
ProviderSettingsActionDescriptor(
id: "minimax-open-dashboard-cookie",
title: "Open Coding Plan",
title: "Open Token Plan",
style: .link,
isVisible: nil,
perform: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,4 @@ extension SettingsStore {
return .manual
}
}

3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ extension StatusItemController {
if provider == .alibaba {
return self.settings.alibabaCodingPlanAPIRegion.dashboardURL
}
if provider == .minimax {
return self.settings.minimaxAPIRegion.dashboardURL
}

let meta = self.store.metadata(for: provider)
let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() {
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,11 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable {
public var apiRemainsURL: URL {
URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.remainsPath)
}

public var dashboardURL: URL {
var components = URLComponents(string: self.baseURLString)!
components.path = "/" + Self.codingPlanPath
components.query = Self.codingPlanQuery
return components.url!
}
}
190 changes: 190 additions & 0 deletions Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//
// MiniMaxServiceUsage.swift
// CodexBarCore
//
// Created by Sisyphus on 2026-03-25.
//

import Foundation

/// Represents the usage information for a specific MiniMax service.
///
/// This struct encapsulates all the relevant details about how much of a particular
/// MiniMax service has been used within its quota window, including reset timing
/// and localized display strings.
public struct MiniMaxServiceUsage: Sendable {
/// The service identifier (e.g., "text-generation", "text-to-speech", "image")
public let serviceType: String

/// The type of time window for the quota (e.g., "5 hours" or "Today")
/// This should be a localized string.
public let windowType: String

/// The specific time range for the current quota window.
/// For hourly quotas: "10:00-15:00(UTC+8)"
/// For daily quotas: full date range string
public let timeRange: String

/// The amount of quota that has been used
public let usage: Int

/// The total quota limit for this service in the current window
public let limit: Int

/// The percentage of quota used (0-100)
public let percent: Double

/// The timestamp when the quota will reset, if available
public let resetsAt: Date?

/// A localized description of when the quota resets (e.g., "Resets in 2 hours 30 minutes")
public let resetDescription: String

/// The remaining quota available (limit - usage)
public var remaining: Int {
return limit - usage
}

/// The display name for this service
public var displayName: String {
switch serviceType {
case "text-generation":
return "Text Generation"
case "text-to-speech":
return "Text to Speech"
case "image":
return "Image"
default:
return serviceType
}
}

/// Creates a new MiniMaxServiceUsage instance.
///
/// - Parameters:
/// - serviceType: The service identifier
/// - windowType: The type of time window (localized)
/// - timeRange: The specific time range string
/// - usage: The amount of quota used
/// - limit: The total quota limit
/// - percent: The percentage used (0-100)
/// - resetsAt: Optional reset timestamp
/// - resetDescription: Localized reset description
public init(
serviceType: String,
windowType: String,
timeRange: String,
usage: Int,
limit: Int,
percent: Double,
resetsAt: Date?,
resetDescription: String
) {
self.serviceType = serviceType
self.windowType = windowType
self.timeRange = timeRange
self.usage = usage
self.limit = limit
self.percent = percent
self.resetsAt = resetsAt
self.resetDescription = resetDescription
}
}

extension MiniMaxServiceUsage {
public static func parseWindowType(_ windowType: String) -> (windowType: String, windowMinutes: Int?) {
switch windowType.lowercased() {
case "5 hours", "5 小时":
return ("5 hours", 300)
case "today", "今日":
return ("Today", 1440)
default:
// Try to extract hours from string like "X hours"
if let hours = Int(windowType.components(separatedBy: .whitespaces).first ?? "") {
return (windowType, hours * 60)
}
return (windowType, nil)
}
}

public static func parseTimeRange(_ timeRange: String, now: Date) -> Date? {
let calendar = Calendar.current

// Handle "10:00-15:00(UTC+8)" format
if timeRange.contains("-") && timeRange.contains("(") && timeRange.contains(")") {
// Extract the time part before the timezone
let components = timeRange.split(separator: "(")
guard components.count >= 1 else { return nil }
let timePart = String(components[0]).trimmingCharacters(in: .whitespaces)

// Split by "-" to get start and end times
let timeComponents = timePart.split(separator: "-")
guard timeComponents.count == 2 else { return nil }

let endTimeStr = String(timeComponents[1]).trimmingCharacters(in: .whitespaces)

// Parse end time (HH:mm format)
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"
timeFormatter.timeZone = TimeZone.current

guard let endTime = timeFormatter.date(from: endTimeStr) else { return nil }

// Get today's date components
let nowComponents = calendar.dateComponents([.year, .month, .day], from: now)
let endTimeComponents = calendar.dateComponents([.hour, .minute], from: endTime)

// Combine today's date with end time
var combinedComponents = DateComponents()
combinedComponents.year = nowComponents.year
combinedComponents.month = nowComponents.month
combinedComponents.day = nowComponents.day
combinedComponents.hour = endTimeComponents.hour
combinedComponents.minute = endTimeComponents.minute

guard let resultDate = calendar.date(from: combinedComponents) else { return nil }

// If the result date is in the past (before now), add one day
if resultDate < now {
return calendar.date(byAdding: .day, value: 1, to: resultDate)
}

return resultDate
}

// Handle "2026/03/25 00:00 - 2026/03/26 00:00" format
if timeRange.contains(" - ") {
let dateComponents = timeRange.split(separator: " - ")
guard dateComponents.count == 2 else { return nil }

let endDateStr = String(dateComponents[1]).trimmingCharacters(in: .whitespaces)

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd HH:mm"
dateFormatter.timeZone = TimeZone.current

return dateFormatter.date(from: endDateStr)
}

return nil
}

public static func generateResetDescription(resetsAt: Date, now: Date = Date()) -> String {
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: now, to: resetsAt)

guard let hours = components.hour, let minutes = components.minute else {
return "Resets soon"
}

if hours > 0 && minutes > 0 {
return "Resets in \(hours) hours \(minutes) minutes"
} else if hours > 0 {
return "Resets in \(hours) hour\(hours > 1 ? "s" : "")"
} else if minutes > 0 {
return "Resets in \(minutes) minute\(minutes > 1 ? "s" : "")"
} else {
return "Resets now"
}
}
}
Loading