From 41288a8081aaa1d321024874df5d12cb97a48188 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 19:19:00 +0000 Subject: [PATCH 01/10] Expand MiniMax Token Plan with multi-model remains and menu sections Co-authored-by: c.skeleton --- Sources/CodexBar/MenuCardView.swift | 222 +++++++++++++++++- .../Providers/MiniMax/MiniMaxModelUsage.swift | 58 +++++ .../MiniMax/MiniMaxUsageFetcher.swift | 152 +++++++++++- .../MiniMax/MiniMaxUsageSnapshot.swift | 6 +- .../CodexBarTests/MiniMaxMenuCardTests.swift | 143 +++++++++++ .../CodexBarTests/MiniMaxProviderTests.swift | 66 ++++++ docs/minimax.md | 7 + 7 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift create mode 100644 Tests/CodexBarTests/MiniMaxMenuCardTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 4d9c05127..9d87f362d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -88,6 +88,22 @@ 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 @@ -95,6 +111,8 @@ struct UsageMenuCardView: View { 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? @@ -125,7 +143,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 { @@ -134,7 +153,7 @@ 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 @@ -151,6 +170,11 @@ struct UsageMenuCardView: View { 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) + } } } if hasUsage, hasCredits || hasCost { @@ -216,7 +240,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) } } @@ -346,6 +371,70 @@ private struct ProviderCostContent: View { } } +private struct MiniMaxTokenPlanSectionsView: View { + let sections: [UsageMenuCardView.Model.MiniMaxSection] + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(Array(self.sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + ForEach(section.rows) { row in + MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor) + } + } + } + } + .padding(.top, 4) + } +} + +private struct MiniMaxTokenPlanRowView: View { + let row: UsageMenuCardView.Model.MiniMaxRow + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(self.row.title) + .font(.footnote) + .fontWeight(.medium) + if let statusText = self.row.detailText, statusText.isEmpty == false { + Text(statusText) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + UsageProgressBar( + percent: self.row.percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) + .font(.caption2) + Spacer() + if let reset = self.row.resetText { + Text(reset) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + } + } + if let secondary = self.row.secondaryLine, !secondary.isEmpty { + Text(secondary) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String @@ -459,8 +548,9 @@ struct UsageMenuCardUsageSectionView: View { @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.metrics.isEmpty, !hasMiniMaxSections { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { @@ -479,6 +569,11 @@ struct UsageMenuCardUsageSectionView: View { UsageNotesContent(notes: self.model.usageNotes) } } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor) + } if self.showBottomDivider { Divider() } @@ -753,6 +848,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, @@ -762,6 +858,7 @@ extension UsageMenuCardView.Model { subtitleStyle: subtitle.style, planText: planText, metrics: metrics, + minimaxSections: minimaxSections, usageNotes: usageNotes, creditsText: creditsText, creditsRemaining: input.credits?.remaining, @@ -773,6 +870,123 @@ extension UsageMenuCardView.Model { progressColor: Self.progressColor(for: input.provider)) } + private static func miniMaxSections(input: Input) -> [MiniMaxSection]? { + guard input.provider == .minimax, + let models = input.snapshot?.minimaxUsage?.models, + !models.isEmpty + else { + return nil + } + let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil } + guard models.count > 1 || hasWeeklyDetail else { + return nil + } + + let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false } + let daily = models.filter { if case .daily = $0.window { return true }; return false } + let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false } + let other = models.filter { if case .other = $0.window { return true }; return false } + + var sections: [MiniMaxSection] = [] + if !fiveHour.isEmpty { + sections.append(MiniMaxSection( + title: "5-hour window", + rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !daily.isEmpty { + sections.append(MiniMaxSection( + title: "Daily quota", + rows: daily.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !weeklyOnly.isEmpty { + sections.append(MiniMaxSection( + title: "Weekly quota", + rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !other.isEmpty { + sections.append(MiniMaxSection( + title: "Other windows", + rows: other.map { Self.miniMaxRow(model: $0, input: input) })) + } + return sections.isEmpty ? nil : sections + } + + private static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + let used = model.usedPercent ?? 0 + let barPercent = percentStyle == .used ? used : (100 - used) + let resetText: String? = if let at = model.resetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: used, + windowMinutes: model.windowMinutes, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let detailText = Self.miniMaxDetailLine(model: model) + let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input) + return MiniMaxRow( + id: model.identifier, + title: model.displayName, + percent: Self.clamped(barPercent), + percentStyle: percentStyle, + resetText: resetText, + detailText: detailText, + secondaryLine: secondaryLine) + } + + private static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { + guard let total = model.availablePrompts else { return nil } + let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) + let remaining = model.remainingPrompts + let usedStr = UsageFormatter.tokenCountString(used) + let totalStr = UsageFormatter.tokenCountString(total) + if let remaining { + let remStr = UsageFormatter.tokenCountString(remaining) + return "\(usedStr)/\(totalStr) (\(remStr) remaining)" + } + return "\(usedStr)/\(totalStr)" + } + + private static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? { + guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil } + let total = model.weeklyTotal + let used = model.weeklyUsed + let remaining = model.weeklyRemaining + let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—" + let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—" + let pctStr: String = if let p = model.weeklyUsedPercent { + String(format: "%.1f%%", p) + } else { + "—" + } + let weeklyReset: String? = if let at = model.weeklyResetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: model.weeklyUsedPercent ?? 0, + windowMinutes: 7 * 24 * 60, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let remStr = remaining.map { UsageFormatter.tokenCountString($0) } + var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)" + if let remStr { + line += " · \(remStr) remaining" + } + if let weeklyReset { + line += " · \(weeklyReset)" + } + return line + } + private static func usageNotes(input: Input) -> [String] { if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift new file mode 100644 index 000000000..63df7701e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift @@ -0,0 +1,58 @@ +import Foundation + +/// One quota row from `coding_plan/remains` `model_remains[]` (Token Plan). +public struct MiniMaxModelUsage: Sendable, Equatable { + public enum WindowKind: Sendable, Equatable { + case fiveHour + case daily + case weekly + case other(minutes: Int?) + } + + public let identifier: String + public let displayName: String + public let availablePrompts: Int? + public let currentPrompts: Int? + public let remainingPrompts: Int? + public let windowMinutes: Int? + public let usedPercent: Double? + public let resetsAt: Date? + public let weeklyTotal: Int? + public let weeklyUsed: Int? + public let weeklyRemaining: Int? + public let weeklyUsedPercent: Double? + public let weeklyResetsAt: Date? + public let window: WindowKind + + public init( + identifier: String, + displayName: String, + availablePrompts: Int?, + currentPrompts: Int?, + remainingPrompts: Int?, + windowMinutes: Int?, + usedPercent: Double?, + resetsAt: Date?, + weeklyTotal: Int?, + weeklyUsed: Int?, + weeklyRemaining: Int?, + weeklyUsedPercent: Double?, + weeklyResetsAt: Date?, + window: WindowKind) + { + self.identifier = identifier + self.displayName = displayName + self.availablePrompts = availablePrompts + self.currentPrompts = currentPrompts + self.remainingPrompts = remainingPrompts + self.windowMinutes = windowMinutes + self.usedPercent = usedPercent + self.resetsAt = resetsAt + self.weeklyTotal = weeklyTotal + self.weeklyUsed = weeklyUsed + self.weeklyRemaining = weeklyRemaining + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyResetsAt = weeklyResetsAt + self.window = window + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 1b78131d5..62a91c4c1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -393,6 +393,15 @@ struct MiniMaxModelRemains: Decodable { let startTime: Int? let endTime: Int? let remainsTime: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? + let modelName: String? + let modelType: String? + let modelId: String? + let modelTitle: String? + let displayName: String? private enum CodingKeys: String, CodingKey { case currentIntervalTotalCount = "current_interval_total_count" @@ -400,6 +409,16 @@ struct MiniMaxModelRemains: Decodable { case startTime = "start_time" case endTime = "end_time" case remainsTime = "remains_time" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" + case modelName = "model_name" + case modelType = "model_type" + case modelId = "model_id" + case modelTitle = "model_title" + case displayName = "name" + case title = "title" } init(from decoder: Decoder) throws { @@ -409,6 +428,19 @@ struct MiniMaxModelRemains: Decodable { self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + self.modelType = try container.decodeIfPresent(String.self, forKey: .modelType) + self.modelId = try container.decodeIfPresent(String.self, forKey: .modelId) + self.modelTitle = try container.decodeIfPresent(String.self, forKey: .modelTitle) + if let name = try container.decodeIfPresent(String.self, forKey: .displayName) { + self.displayName = name + } else { + self.displayName = try container.decodeIfPresent(String.self, forKey: .title) + } } } @@ -487,7 +519,8 @@ enum MiniMaxUsageParser { windowMinutes: available?.windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + models: []) } static func parseCodingPlanRemains( @@ -504,7 +537,8 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + let rows = payload.data.modelRemains + guard let first = rows.first else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } @@ -533,6 +567,10 @@ enum MiniMaxUsageParser { nil } + let models = rows.enumerated().map { index, row in + self.buildModelUsage(row: row, index: index, now: now) + } + return MiniMaxUsageSnapshot( planName: planName, availablePrompts: total, @@ -541,7 +579,115 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + models: models) + } + + private static func buildModelUsage(row: MiniMaxModelRemains, index: Int, now: Date) -> MiniMaxModelUsage { + let total = row.currentIntervalTotalCount + let remaining = row.currentIntervalUsageCount + let usedPercent = self.usedPercent(total: total, remaining: remaining) + let startDate = self.dateFromEpoch(row.startTime) + let endDate = self.dateFromEpoch(row.endTime) + let windowMinutes = self.windowMinutes(start: startDate, end: endDate) + let resetsAt = self.resetsAt(end: endDate, remains: row.remainsTime, now: now) + + let currentPrompts: Int? = if let total, let remaining { + max(0, total - remaining) + } else { + nil + } + + let weeklyTotal = row.currentWeeklyTotalCount + let weeklyRemaining = row.currentWeeklyUsageCount + let weeklyUsed: Int? = if let weeklyTotal, let weeklyRemaining { + max(0, weeklyTotal - weeklyRemaining) + } else { + nil + } + let weeklyUsedPercent = self.usedPercent(total: weeklyTotal, remaining: weeklyRemaining) + let weeklyEndDate = self.dateFromEpoch(row.weeklyEndTime) + let weeklyResetsAt = self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + + let identifier = self.modelIdentifier(row: row, index: index) + let displayName = self.modelDisplayName(row: row, identifier: identifier) + let windowKind = self.classifyWindowKind( + windowMinutes: windowMinutes, + start: startDate, + end: endDate, + hasWeeklyQuota: weeklyTotal != nil || weeklyRemaining != nil, + hasIntervalQuota: total != nil || remaining != nil) + + return MiniMaxModelUsage( + identifier: identifier, + displayName: displayName, + availablePrompts: total, + currentPrompts: currentPrompts, + remainingPrompts: remaining, + windowMinutes: windowMinutes, + usedPercent: usedPercent, + resetsAt: resetsAt, + weeklyTotal: weeklyTotal, + weeklyUsed: weeklyUsed, + weeklyRemaining: weeklyRemaining, + weeklyUsedPercent: weeklyUsedPercent, + weeklyResetsAt: weeklyResetsAt, + window: windowKind) + } + + private static func modelIdentifier(row: MiniMaxModelRemains, index: Int) -> String { + let candidates = [ + row.modelName, + row.modelType, + row.modelId, + row.modelTitle, + row.displayName, + ] + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + } + return "model-\(index + 1)" + } + + private static func modelDisplayName(row: MiniMaxModelRemains, identifier: String) -> String { + let candidates = [ + row.modelName, + row.displayName, + row.modelTitle, + row.modelType, + row.modelId, + ] + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + } + return identifier + } + + private static func classifyWindowKind( + windowMinutes: Int?, + start: Date?, + end: Date?, + hasWeeklyQuota: Bool, + hasIntervalQuota: Bool) -> MiniMaxModelUsage.WindowKind + { + if let windowMinutes, windowMinutes == 300 { + return .fiveHour + } + if let windowMinutes, windowMinutes == 24 * 60 { + return .daily + } + if let start, let end { + let mins = Int(end.timeIntervalSince(start) / 60) + if mins >= 1380, mins <= 1500 { + return .daily + } + } + if hasWeeklyQuota, !hasIntervalQuota { + return .weekly + } + return .other(minutes: windowMinutes) } private static func usedPercent(total: Int?, remaining: Int?) -> Double? { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e2..2e5732f77 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -9,6 +9,8 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + /// All rows from `model_remains` (first row mirrors the scalar fields above). + public let models: [MiniMaxModelUsage] public init( planName: String?, @@ -18,7 +20,8 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + models: [MiniMaxModelUsage] = []) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,6 +31,7 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.models = models } } diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift new file mode 100644 index 000000000..28a5cbde6 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -0,0 +1,143 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MiniMaxMenuCardTests { + @Test + func `minimax sections group fiveHour and daily rows`() throws { + let now = Date() + let models: [MiniMaxModelUsage] = [ + MiniMaxModelUsage( + identifier: "text-gen", + displayName: "Text", + availablePrompts: 4500, + currentPrompts: 100, + remainingPrompts: 4400, + windowMinutes: 300, + usedPercent: 2.2, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour), + MiniMaxModelUsage( + identifier: "image-01", + displayName: "image-01", + availablePrompts: 120, + currentPrompts: 0, + remainingPrompts: 120, + windowMinutes: 1440, + usedPercent: 0, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .daily), + ] + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan", + availablePrompts: 4500, + currentPrompts: 100, + remainingPrompts: 4400, + windowMinutes: 300, + usedPercent: 2.2, + resetsAt: nil, + updatedAt: now, + models: models) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let sections = try #require(model.minimaxSections) + #expect(sections.count == 2) + #expect(sections[0].title == "5-hour window") + #expect(sections[0].rows.count == 1) + #expect(sections[0].rows[0].title == "Text") + #expect(sections[1].title == "Daily quota") + #expect(sections[1].rows[0].title == "image-01") + } + + @Test + func `minimax shows sections when single row has weekly quota`() throws { + let now = Date() + let models: [MiniMaxModelUsage] = [ + MiniMaxModelUsage( + identifier: "speech-hd", + displayName: "Speech HD", + availablePrompts: 11000, + currentPrompts: 10995, + remainingPrompts: 5, + windowMinutes: 1440, + usedPercent: 99.95, + resetsAt: nil, + weeklyTotal: 77000, + weeklyUsed: 6354, + weeklyRemaining: 70646, + weeklyUsedPercent: 91.7, + weeklyResetsAt: nil, + window: .daily), + ] + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan", + availablePrompts: 11000, + currentPrompts: 10995, + remainingPrompts: 5, + windowMinutes: 1440, + usedPercent: 99.95, + resetsAt: nil, + updatedAt: now, + models: models) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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, + now: now)) + + let sections = try #require(model.minimaxSections) + #expect(sections.count == 1) + let row = try #require(sections.first?.rows.first) + #expect(row.secondaryLine?.contains("Weekly") == true) + #expect(row.secondaryLine?.contains("70646") == true) + } +} diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 7cf0524bc..5cb6e31e0 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -113,6 +113,8 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(snapshot.usedPercent == 75) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) + #expect(snapshot.models.first?.window == .fiveHour) } @Test @@ -149,6 +151,69 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(abs((snapshot.usedPercent ?? 0) - expectedUsed) < 0.01) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) + } + + @Test + func `parses multiple model_remains rows and weekly fields`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start5h = 1_700_000_000_000 + let end5h = start5h + 5 * 60 * 60 * 1000 + let dayStart = 1_700_000_000_000 + let dayEnd = dayStart + 24 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Token Plan", + "model_remains": [ + { + "model_name": "text-gen", + "current_interval_total_count": 4500, + "current_interval_usage_count": 4381, + "start_time": \(start5h), + "end_time": \(end5h), + "remains_time": 240000 + }, + { + "model_name": "image-01", + "current_interval_total_count": 120, + "current_interval_usage_count": 120, + "start_time": \(dayStart), + "end_time": \(dayEnd), + "remains_time": 3600000 + }, + { + "model_name": "speech-hd", + "current_interval_total_count": 11000, + "current_interval_usage_count": 5, + "current_weekly_total_count": "77000", + "current_weekly_usage_count": "70646", + "start_time": \(dayStart), + "end_time": \(dayEnd), + "remains_time": 3600000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan") + #expect(snapshot.availablePrompts == 4500) + #expect(snapshot.models.count == 3) + + let text = snapshot.models.first { $0.identifier == "text-gen" } + #expect(text?.window == .fiveHour) + #expect(text?.currentPrompts == 119) + + let image = snapshot.models.first { $0.identifier == "image-01" } + #expect(image?.window == .daily) + #expect(image?.usedPercent == 0) + + let speech = snapshot.models.first { $0.identifier == "speech-hd" } + #expect(speech?.weeklyTotal == 77000) + #expect(speech?.weeklyRemaining == 70646) + #expect(speech?.weeklyUsed == 6354) } @Test @@ -193,6 +258,7 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(snapshot.usedPercent == 75) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) } @Test diff --git a/docs/minimax.md b/docs/minimax.md index 6b7b7d647..58d62309c 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -80,7 +80,14 @@ either a Bearer API token or a session cookie header. - Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …". - Plan/tier: best-effort from response fields or HTML title. +### Coding Plan multi-model (`model_remains[]`) +- The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. +- Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). +- Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. +- Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. + ## Key files - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` From 835caaa0237cc084220686f1cfaf48ae458d2b76 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 14:08:52 +0800 Subject: [PATCH 02/10] checkpoint before checking out cursor/minimax-multi-model-usage-ef73 --- .cursor/hooks/state/continual-learning.json | 8 + .specstory/.project.json | 7 + .specstory/cli/config.toml | 79 +++++ ...19_04-53-42Z-cursor-branch-code-testing.md | 324 ++++++++++++++++++ .specstory/statistics.json | 13 + 5 files changed, 431 insertions(+) create mode 100644 .cursor/hooks/state/continual-learning.json create mode 100644 .specstory/.project.json create mode 100644 .specstory/cli/config.toml create mode 100644 .specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md create mode 100644 .specstory/statistics.json diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json new file mode 100644 index 000000000..8a91eee07 --- /dev/null +++ b/.cursor/hooks/state/continual-learning.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "lastRunAtMs": 0, + "turnsSinceLastRun": 2, + "lastTranscriptMtimeMs": null, + "lastProcessedGenerationId": "d1ca6c4d-0a87-4bff-b994-8f4b64215c5c", + "trialStartedAtMs": null +} diff --git a/.specstory/.project.json b/.specstory/.project.json new file mode 100644 index 000000000..503fb60d0 --- /dev/null +++ b/.specstory/.project.json @@ -0,0 +1,7 @@ +{ + "workspace_id": "a1bf-ca39-9a6f-0f1e", + "workspace_id_at": "2026-04-19T06:04:20Z", + "git_id": "23d9-7492-e24f-7e50", + "git_id_at": "2026-04-19T06:04:20Z", + "project_name": "CodexBar" +} \ No newline at end of file diff --git a/.specstory/cli/config.toml b/.specstory/cli/config.toml new file mode 100644 index 000000000..9336aae89 --- /dev/null +++ b/.specstory/cli/config.toml @@ -0,0 +1,79 @@ +# SpecStory CLI Configuration +# +# This is the project-level config file for SpecStory CLI. +# All settings here apply to this project unless overridden by CLI flags. +# +# Uncomment (remove the #) the line and edit any setting below to change the default behavior. +# For more information, see: https://docs.specstory.com/integrations/terminal-coding-agents/usage + +[local_sync] +# Write markdown files locally. (default: true) +# enabled = false # equivalent to --only-cloud-sync + +# Custom output directory for markdown files. +# Default: ./.specstory/history (relative to the project directory) +# output_dir = "~/.specstory/history" # equivalent to --output-dir "~/.specstory/history" + +# Use local timezone for file name and content timestamps (default: false, UTC) +# local_time_zone = true # equivalent to --local-time-zone + +[cloud_sync] +# Sync session data to SpecStory Cloud. (default: true, when logged in to SpecStory Cloud) +# enabled = false # equivalent to --no-cloud-sync + +[logging] +# Write logs to .specstory/debug/debug.log (default: false) +# log = true # equivalent to --log + +# Debug-level output, requires console or log (default: false) +# debug = true # equivalent to --debug + +# Custom output directory for debug data. +# Default: ./.specstory/debug (relative to the project directory) +# debug_dir = "~/.specstory/debug" # equivalent to --debug-dir "~/.specstory/debug" + +# Error/warn/info output to stdout (default: false) +# console = true # equivalent to --console + +# Suppress all non-error output (default: false) +# silent = true # equivalent to --silent + +[version_check] +# Check for new versions of the CLI on startup. +# Default: true +# enabled = false # equivalent to --no-version-check + +[analytics] +# Send anonymous product usage analytics to help improve SpecStory. +# Default: true +# enabled = false # equivalent to --no-usage-analytics + +[telemetry] +# OTLP gRPC collector endpoint (e.g., "localhost:4317" or "http://localhost:4317") +# endpoint = "localhost:4317" + +# Override the default service name (default: "specstory-cli") +# service_name = "my-service-name" + +# Include user prompt text in telemetry spans (default: true) +# prompts = false + +[providers] +# Agent execution commands by provider (used by specstory run) +# Pass custom flags (e.g. claude_cmd = "claude --allow-dangerously-skip-permissions") +# Use of these is equivalent to -c "custom command" + +# Claude Code command +# claude_cmd = "claude" + +# Codex CLI command +# codex_cmd = "codex" + +# Cursor CLI command +# cursor_cmd = "cursor-agent" + +# Droid CLI command +# droid_cmd = "droid" + +# Gemini CLI command +# gemini_cmd = "gemini" diff --git a/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md b/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md new file mode 100644 index 000000000..021426232 --- /dev/null +++ b/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md @@ -0,0 +1,324 @@ + + +# 2026-04-19 04:53:42Z + + + +_**User**_ + +测试cursor分支的代码,应该如何做 + +--- + +_**Agent (default)**_ + + +正在查看项目结构与测试配置,以便说明如何测试 `cursor` 分支的代码。 + + + +_**Agent (default)**_ + + +
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/AGENTS.md + +
+
+ +
+Tool use: **read_file_v2** + +
+ +_**Agent (default)**_ + + +
+Tool use: **glob_file_search** • Searched codebase "" • **1** result in **1** directory + +Directory: **/Users/gc/Dev/Opensource/CodexBar** (1 file) + +| File | +|------| +| `package.json` | + +
+
+ +
+Tool use: **glob_file_search** + +
+ +_**Agent (default)**_ + + +
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 + +```bash +cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 +``` +
+
+ +
+Tool use: **run_terminal_command_v2** + +
+ +_**Agent (default)**_ + +
Thought Process +The git command failed with exit code 69, indicating git may not be installed or the current directory is not a Git repository.
+ +_**Agent (default)**_ + + + + + + +_**Agent (default)**_ + + +
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/package.json + +
+
+ +
+Tool use: **read_file_v2** + +
+ +_**Agent (default)**_ + + +### 🧠 分析与说明 + +当前工作区可能**不是 Git 仓库**(本机 `git` 命令未返回分支信息)。若你要测的是**名为 `cursor` 的分支**,通用做法是:先切到该分支,再按本仓库约定跑构建与测试。 + +CodexBar 是 **SwiftPM + XCTest** 项目,`AGENTS.md` 和 `package.json` 里已经写清了推荐流程。 + +--- + +### 1. 切到 `cursor` 分支(在克隆的仓库里) + +在项目根目录执行: + +```bash +git fetch origin +git checkout cursor +# 若本地已有同名分支且需对齐远端: +# git checkout -b cursor origin/cursor +``` + +确认当前分支: + +```bash +git branch --show-current +git log -1 --oneline +``` + +--- + +### 2. 跑自动化测试(推荐顺序) + +| 目的 | 命令 | +|------|------| +| 只跑单元测试 | `pnpm test` 或 `swift test` | +| TTY 相关集成测试 | `pnpm run test:tty` | +| 需真实账号的 live 测试 | `pnpm run test:live`(会设 `LIVE_TEST=1`) | +| 格式/静态检查(提交前) | `pnpm check`(`AGENTS.md` 要求改代码后跑) | + +--- + +### 3. 完整开发闭环(构建 + 测试 + 打包 + 启动 App) + +与文档一致的首选: + +```bash +./Scripts/compile_and_run.sh +``` + +或: + +```bash +pnpm start +``` + +脚本会结束旧进程、`swift build`、`swift test`、打包并重新启动 `CodexBar.app`,适合验证**菜单栏应用**是否按预期工作。 + +--- + +### 4. 若本目录没有 Git / 没有 `cursor` 分支 + +需要先**克隆**带 `cursor` 分支的远程仓库,或让维护者提供该分支的提交/补丁,再在那一套代码上执行上面的 `swift test` / `compile_and_run.sh`。 + +--- + +**小结**:在 `cursor` 分支上,先 `git checkout cursor`,再 **`swift test`(或 `pnpm test`)** 做自动化验证;需要连 UI/真机行为时用 **`./Scripts/compile_and_run.sh`**;提交前跑 **`pnpm check`**。若你希望我根据你本机实际远程名(`origin` 或其它)写一条「从当前状态拉到 `cursor`」的准确命令,可以说一下仓库是 fork 还是主仓、以及 `git remote -v` 的输出。 + +--- + +_**User**_ + +我需要把cursor/minimax-multi-model-usage-ef73分支拉下来应该怎么做 + +--- + +_**Agent (default)**_ + +
Thought Process +To fetch the specified branch, use the command `git fetch origin cursor/minimax-multi-model-usage-ef73`. +If you need to switch to this branch, run `git checkout cursor/minimax-multi-model-usage-ef73`.
+ +_**Agent (default)**_ + + +正在检查仓库的远程与分支情况,以便给出准确命令。 + + + +_**Agent (default)**_ + + +
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 + +```bash +cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 +``` + +``` +true +origin https://github.com/cskeleton/CodexBar.git (fetch) +origin https://github.com/cskeleton/CodexBar.git (push) +upstream https://github.com/steipete/CodexBar.git (fetch) +upstream https://github.com/steipete/CodexBar.git (push) +* main + remotes/origin/HEAD -> origin/main + remotes/origin/cursor/minimax-multi-model-usage-ef73 + remotes/origin/main + remotes/upstream/HEAD -> upstream/main + remotes/upstream/antigravity + remotes/upstream/ci/pin-lint-tools-and-format-baseline + remotes/upstream/claude-keychain-refresh-fix + remotes/upstream/co-pilot + remotes/upstream/codex/auto-keychain-prompts-user-action + remotes/upstream/codex/claude-auto-cli-before-web + remotes/upstream/codex/claude-keychain-policy-hard-enforcement + remotes/upstream/codex/claude-keychain-policy-ux + remotes/upstream/codex/claude-oauth-keychain-policy + remotes/upstream/codex/claude-oauth-security-cli-experimental + remotes/upstream/codex/fix-opencode-null-subscription-273 + remotes/upstream/codex/main-debug-reconcile-split + remotes/upstream/codex/menu-open-no-settings-write + remotes/upstream/codex/pr-275-scripts-only + remotes/upstream/codex/pr-286-audit + remotes/upstream/codex/pr-294-warp-provider + remotes/upstream/codex/pr-298-audit + remotes/upstream/codex/pr-310-audit + remotes/upstream/codex/pr-393-audit + remotes/upstream/codex/pr-397-audit + remotes/upstream/codex/pr-409-audit + remotes/upstream/codex/pr-438-audit + remotes/upstream/codex/pr-449-audit + remotes/upstream/codex/pr-453-audit + remotes/upstream/codex/pr-529-audit-20260409-152104 + remotes/upstream/codex/pr-565-audit + remotes/upstream/codex/pr-632-audit + remotes/upstream/codex/pr-673-audit + remotes/upstream/codex/provider-account-identity + remotes/upstream/codex/rat-185-multi-account-foundation + remotes/upstream/codex/rat-186 + remotes/upstream/codex/rat-187 + remotes/upstream/codex/rat-189-codex-parity-lock + remotes/upstream/cost/pr33-ccusage + remotes/upstream/cursor-fixes + +``` +
+
+ +
+Tool use: **run_terminal_command_v2** + +
+ +_**Agent (default)**_ + +
Thought Process +The remote branch exists as `remotes/origin/cursor/minimax-multi-model-usage-ef73`.
+ +_**Agent (default)**_ + + +远程已有该分支:`origin/cursor/minimax-multi-model-usage-ef73`。正在拉取并检出: + + + +_**Agent (default)**_ + + +
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 + +```bash +cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 +``` + +``` +From https://github.com/cskeleton/CodexBar + * branch cursor/minimax-multi-model-usage-ef73 -> FETCH_HEAD +Switched to a new branch 'cursor/minimax-multi-model-usage-ef73' +branch 'cursor/minimax-multi-model-usage-ef73' set up to track 'origin/cursor/minimax-multi-model-usage-ef73'. + +``` +
+
+ +
+Tool use: **run_terminal_command_v2** + +
+ +_**Agent (default)**_ + + +### 已在你的仓库里完成操作 + +当前目录已 **`fetch` 并 `checkout`** 到分支 `cursor/minimax-multi-model-usage-ef73`,且已设置跟踪 `origin/cursor/minimax-multi-model-usage-ef73`。 + +--- + +### 以后在别的机器 / 新克隆里可以自己执行 + +**已有克隆、只拉这条分支并切换:** + +```bash +cd /path/to/CodexBar +git fetch origin cursor/minimax-multi-model-usage-ef73 +git checkout cursor/minimax-multi-model-usage-ef73 +``` + +**若本地还没有这个分支,用跟踪分支显式创建(等价写法):** + +```bash +git fetch origin +git checkout -b cursor/minimax-multi-model-usage-ef73 origin/cursor/minimax-multi-model-usage-ef73 +``` + +**全新克隆并直接落在该分支:** + +```bash +git clone -b cursor/minimax-multi-model-usage-ef73 https://github.com/cskeleton/CodexBar.git +``` + +--- + +### 更新分支(之后别人又推了新提交) + +```bash +git pull origin cursor/minimax-multi-model-usage-ef73 +``` + +需要的话可以继续在该分支上跑 `swift test` 或 `./Scripts/compile_and_run.sh` 做验证。 diff --git a/.specstory/statistics.json b/.specstory/statistics.json new file mode 100644 index 000000000..c91f97a4c --- /dev/null +++ b/.specstory/statistics.json @@ -0,0 +1,13 @@ +{ + "sessions": { + "9b5175d8-f354-417b-b218-5c6d9ac572fc": { + "user_message_count": 2, + "agent_message_count": 15, + "start_timestamp": "2026-04-19T12:53:42+08:00", + "end_timestamp": "2026-04-19T12:53:42+08:00", + "markdown_size_bytes": 10037, + "provider": "cursoride", + "last_updated": "2026-04-19T06:06:33Z" + } + } +} From bfa422b3e1d32945eb0ef363872ce7cf1d520f5e Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 17:10:20 +0800 Subject: [PATCH 03/10] Add MiniMax Token Plan API usage and collapsible scrolled UI - Show full MiniMax Token Plan API usage in menu bar card and provider settings; hide weekly cap rows when the plan has no weekly limit (legacy plans).\n- MiniMax menu card: section collapse defaults, in-card scrolling, layout metrics; Preferences mirrors grouped sections with scrolling.\n- Extend MiniMaxUsageFetcher parsing, tests, and docs/minimax.md; note behavior in AGENTS.md. Made-with: Cursor --- AGENTS.md | 8 + Sources/CodexBar/MenuCardView.swift | 314 ++++++------------ Sources/CodexBar/MiniMaxMenuCardViews.swift | 120 +++++++ .../MiniMaxSectionCollapseStore.swift | 33 ++ Sources/CodexBar/MiniMaxUILayoutMetrics.swift | 24 ++ .../PreferencesProviderDetailView.swift | 114 ++++++- .../CodexBar/StatusItemController+Menu.swift | 64 +++- .../StatusItemController+MenuTypes.swift | 13 +- .../UsageMenuCardViewModel+MiniMax.swift | 128 +++++++ .../Antigravity/AntigravityStatusProbe.swift | 12 +- .../MiniMax/MiniMaxUsageFetcher.swift | 19 +- .../CodexBarTests/MiniMaxMenuCardTests.swift | 96 +++++- .../CodexBarTests/MiniMaxProviderTests.swift | 65 ++++ .../MiniMaxUILayoutMetricsTests.swift | 28 ++ ...enAIDashboardNavigationDelegateTests.swift | 7 +- docs/minimax.md | 80 +++-- 16 files changed, 844 insertions(+), 281 deletions(-) create mode 100644 Sources/CodexBar/MiniMaxMenuCardViews.swift create mode 100644 Sources/CodexBar/MiniMaxSectionCollapseStore.swift create mode 100644 Sources/CodexBar/MiniMaxUILayoutMetrics.swift create mode 100644 Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift create mode 100644 Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift diff --git a/AGENTS.md b/AGENTS.md index a4c8e630a..0a5563d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 9d87f362d..5236f3e3e 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -126,8 +126,15 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted + init(model: Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { + self.model = model + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + } + static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { return "API key limit" @@ -135,6 +142,10 @@ struct UsageMenuCardView: View { return metric.title } + static func miniMaxUsageScrollMaxHeight() -> CGFloat { + MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: NSScreen.main?.visibleFrame.height) + } + var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) @@ -160,20 +171,43 @@ struct UsageMenuCardView: View { 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) - } - if let sections = self.model.minimaxSections, !sections.isEmpty { - MiniMaxTokenPlanSectionsView( - sections: sections, - progressColor: self.model.progressColor) + Group { + if hasMiniMaxSections { + MiniMaxCappedScrollView(maxHeight: Self.miniMaxUsageScrollMaxHeight()) { + 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) + } + } } } } @@ -371,70 +405,6 @@ private struct ProviderCostContent: View { } } -private struct MiniMaxTokenPlanSectionsView: View { - let sections: [UsageMenuCardView.Model.MiniMaxSection] - let progressColor: Color - @Environment(\.menuItemHighlighted) private var isHighlighted - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - ForEach(Array(self.sections.enumerated()), id: \.offset) { _, section in - VStack(alignment: .leading, spacing: 8) { - Text(section.title) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - ForEach(section.rows) { row in - MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor) - } - } - } - } - .padding(.top, 4) - } -} - -private struct MiniMaxTokenPlanRowView: View { - let row: UsageMenuCardView.Model.MiniMaxRow - let progressColor: Color - @Environment(\.menuItemHighlighted) private var isHighlighted - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(self.row.title) - .font(.footnote) - .fontWeight(.medium) - if let statusText = self.row.detailText, statusText.isEmpty == false { - Text(statusText) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } - UsageProgressBar( - percent: self.row.percent, - tint: self.progressColor, - accessibilityLabel: self.row.percentStyle.accessibilityLabel) - HStack(alignment: .firstTextBaseline) { - Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) - .font(.caption2) - Spacer() - if let reset = self.row.resetText { - Text(reset) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(2) - } - } - if let secondary = self.row.secondaryLine, !secondary.isEmpty { - Text(secondary) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(3) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String @@ -545,34 +515,32 @@ struct UsageMenuCardUsageSectionView: View { let showBottomDivider: Bool let bottomPadding: CGFloat let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted + init( + model: UsageMenuCardView.Model, + showBottomDivider: Bool, + bottomPadding: CGFloat, + width: CGFloat, + onMiniMaxLayoutChange: (() -> Void)? = nil) + { + self.model = model + self.showBottomDivider = showBottomDivider + self.bottomPadding = bottomPadding + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + } + var body: some View { let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false VStack(alignment: .leading, spacing: 12) { - 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) + if hasMiniMaxSections { + MiniMaxCappedScrollView(maxHeight: UsageMenuCardView.miniMaxUsageScrollMaxHeight()) { + 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) - } - } - if let sections = self.model.minimaxSections, !sections.isEmpty { - MiniMaxTokenPlanSectionsView( - sections: sections, - progressColor: self.model.progressColor) + self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } if self.showBottomDivider { Divider() @@ -583,6 +551,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 { @@ -870,123 +867,6 @@ extension UsageMenuCardView.Model { progressColor: Self.progressColor(for: input.provider)) } - private static func miniMaxSections(input: Input) -> [MiniMaxSection]? { - guard input.provider == .minimax, - let models = input.snapshot?.minimaxUsage?.models, - !models.isEmpty - else { - return nil - } - let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil } - guard models.count > 1 || hasWeeklyDetail else { - return nil - } - - let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false } - let daily = models.filter { if case .daily = $0.window { return true }; return false } - let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false } - let other = models.filter { if case .other = $0.window { return true }; return false } - - var sections: [MiniMaxSection] = [] - if !fiveHour.isEmpty { - sections.append(MiniMaxSection( - title: "5-hour window", - rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) })) - } - if !daily.isEmpty { - sections.append(MiniMaxSection( - title: "Daily quota", - rows: daily.map { Self.miniMaxRow(model: $0, input: input) })) - } - if !weeklyOnly.isEmpty { - sections.append(MiniMaxSection( - title: "Weekly quota", - rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) })) - } - if !other.isEmpty { - sections.append(MiniMaxSection( - title: "Other windows", - rows: other.map { Self.miniMaxRow(model: $0, input: input) })) - } - return sections.isEmpty ? nil : sections - } - - private static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { - let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left - let used = model.usedPercent ?? 0 - let barPercent = percentStyle == .used ? used : (100 - used) - let resetText: String? = if let at = model.resetsAt { - UsageFormatter.resetLine( - for: RateWindow( - usedPercent: used, - windowMinutes: model.windowMinutes, - resetsAt: at, - resetDescription: nil), - style: input.resetTimeDisplayStyle, - now: input.now) - } else { - nil - } - let detailText = Self.miniMaxDetailLine(model: model) - let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input) - return MiniMaxRow( - id: model.identifier, - title: model.displayName, - percent: Self.clamped(barPercent), - percentStyle: percentStyle, - resetText: resetText, - detailText: detailText, - secondaryLine: secondaryLine) - } - - private static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { - guard let total = model.availablePrompts else { return nil } - let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) - let remaining = model.remainingPrompts - let usedStr = UsageFormatter.tokenCountString(used) - let totalStr = UsageFormatter.tokenCountString(total) - if let remaining { - let remStr = UsageFormatter.tokenCountString(remaining) - return "\(usedStr)/\(totalStr) (\(remStr) remaining)" - } - return "\(usedStr)/\(totalStr)" - } - - private static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? { - guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil } - let total = model.weeklyTotal - let used = model.weeklyUsed - let remaining = model.weeklyRemaining - let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—" - let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—" - let pctStr: String = if let p = model.weeklyUsedPercent { - String(format: "%.1f%%", p) - } else { - "—" - } - let weeklyReset: String? = if let at = model.weeklyResetsAt { - UsageFormatter.resetLine( - for: RateWindow( - usedPercent: model.weeklyUsedPercent ?? 0, - windowMinutes: 7 * 24 * 60, - resetsAt: at, - resetDescription: nil), - style: input.resetTimeDisplayStyle, - now: input.now) - } else { - nil - } - let remStr = remaining.map { UsageFormatter.tokenCountString($0) } - var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)" - if let remStr { - line += " · \(remStr) remaining" - } - if let weeklyReset { - line += " · \(weeklyReset)" - } - return line - } - private static func usageNotes(input: Input) -> [String] { if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) @@ -1616,7 +1496,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)) } diff --git a/Sources/CodexBar/MiniMaxMenuCardViews.swift b/Sources/CodexBar/MiniMaxMenuCardViews.swift new file mode 100644 index 000000000..ec4327e7a --- /dev/null +++ b/Sources/CodexBar/MiniMaxMenuCardViews.swift @@ -0,0 +1,120 @@ +import CodexBarCore +import SwiftUI + +struct MiniMaxCappedScrollView: View { + let maxHeight: CGFloat + @ViewBuilder let content: () -> Content + + var body: some View { + ScrollView { + self.content() + } + .frame(maxHeight: self.maxHeight, alignment: .top) + } +} + +struct MiniMaxTokenPlanSectionsView: View { + let sections: [UsageMenuCardView.Model.MiniMaxSection] + let progressColor: Color + let onLayoutChange: (() -> Void)? + @Environment(\.menuItemHighlighted) private var isHighlighted + @Bindable private var collapseStore = MiniMaxSectionCollapseStore.shared + + init( + sections: [UsageMenuCardView.Model.MiniMaxSection], + progressColor: Color, + onLayoutChange: (() -> Void)? = nil) + { + self.sections = sections + self.progressColor = progressColor + self.onLayoutChange = onLayoutChange + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(self.sections, id: \.title) { section in + let rowCount = section.rows.count + let collapsed = self.collapseStore.isCollapsed(sectionTitle: section.title, rowCount: rowCount) + VStack(alignment: .leading, spacing: 8) { + Button { + self.collapseStore.toggle(sectionTitle: section.title, rowCount: rowCount) + if let onLayoutChange = self.onLayoutChange { + Task { @MainActor in + await Task.yield() + onLayoutChange() + } + } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(section.title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Spacer(minLength: 8) + if collapsed { + Text("\(rowCount) items") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .monospacedDigit() + } + Image(systemName: collapsed ? "chevron.right" : "chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(collapsed ? "展开 \(section.title)" : "折叠 \(section.title)") + + if !collapsed { + ForEach(section.rows) { row in + MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor) + } + } + } + } + } + .padding(.top, 4) + } +} + +struct MiniMaxTokenPlanRowView: View { + let row: UsageMenuCardView.Model.MiniMaxRow + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(self.row.title) + .font(.footnote) + .fontWeight(.medium) + if let statusText = self.row.detailText, statusText.isEmpty == false { + Text(statusText) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + UsageProgressBar( + percent: self.row.percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) + .font(.caption2) + Spacer() + if let reset = self.row.resetText { + Text(reset) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + } + } + if let secondary = self.row.secondaryLine, !secondary.isEmpty { + Text(secondary) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/Sources/CodexBar/MiniMaxSectionCollapseStore.swift b/Sources/CodexBar/MiniMaxSectionCollapseStore.swift new file mode 100644 index 000000000..6126ccad6 --- /dev/null +++ b/Sources/CodexBar/MiniMaxSectionCollapseStore.swift @@ -0,0 +1,33 @@ +import Foundation +import Observation + +/// 进程内保存 MiniMax 菜单卡各分组折叠状态;`sectionTitle` 为 key,未覆盖时使用「行数 ≥ 5 默认折叠」。 +@MainActor +@Observable +final class MiniMaxSectionCollapseStore { + static let shared = MiniMaxSectionCollapseStore() + + private var overrides: [String: Bool] = [:] + + private init() {} + + /// - Parameters: + /// - sectionTitle: 分组标题(与 `MiniMaxSection.title` 一致)。 + /// - rowCount: 该分组内行数;≥ `MiniMaxUILayoutMetrics.collapseThreshold` 时默认折叠。 + func isCollapsed(sectionTitle: String, rowCount: Int) -> Bool { + if let stored = self.overrides[sectionTitle] { + return stored + } + return rowCount >= MiniMaxUILayoutMetrics.collapseThreshold + } + + func toggle(sectionTitle: String, rowCount: Int) { + let current = self.isCollapsed(sectionTitle: sectionTitle, rowCount: rowCount) + self.overrides[sectionTitle] = !current + } + + /// 单测重置覆盖,避免用例互相污染。 + func resetOverridesForTesting() { + self.overrides.removeAll() + } +} diff --git a/Sources/CodexBar/MiniMaxUILayoutMetrics.swift b/Sources/CodexBar/MiniMaxUILayoutMetrics.swift new file mode 100644 index 000000000..66536c0c9 --- /dev/null +++ b/Sources/CodexBar/MiniMaxUILayoutMetrics.swift @@ -0,0 +1,24 @@ +import AppKit + +enum MiniMaxUILayoutMetrics { + static let collapseThreshold = 5 + static let settingsEmbeddedScrollThreshold = 6 + static let settingsEmbeddedScrollMaxHeight: CGFloat = 360 + static let settingsTitleWidthReference = "code-plan-search" + static let menuScrollFallbackHeight: CGFloat = 560 + + static func menuUsageScrollMaxHeight(visibleScreenHeight: CGFloat?) -> CGFloat { + guard let height = visibleScreenHeight else { + return self.menuScrollFallbackHeight + } + return min(640, max(320, height - 310)) + } + + static func preferredMenuUsageHeight(contentHeight: CGFloat, visibleScreenHeight: CGFloat?) -> CGFloat { + min(max(1, ceil(contentHeight)), self.menuUsageScrollMaxHeight(visibleScreenHeight: visibleScreenHeight)) + } + + static func settingsTitleWidthCap(font: NSFont = ProviderSettingsMetrics.metricLabelFont()) -> CGFloat { + ProviderSettingsMetrics.labelWidth(for: [self.settingsTitleWidthReference], font: font) + } +} diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 5ecff079d..d37be61a0 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -349,13 +349,14 @@ struct ProviderMetricsInlineView: View { let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil + let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false ProviderSettingsSection( title: "Usage", spacing: 8, verticalPadding: 6, horizontalPadding: 0) { - if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { + if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage, !hasMiniMaxSections { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -375,6 +376,13 @@ struct ProviderMetricsInlineView: View { alignsWithMetricContent: hasMetrics) } + if let sections = self.model.minimaxSections, !sections.isEmpty { + ProviderMiniMaxSectionsInlineView( + sections: sections, + progressColor: self.model.progressColor, + labelWidth: self.labelWidth) + } + if let credits = self.model.creditsText { ProviderMetricInlineTextRow( title: "Credits", @@ -563,3 +571,107 @@ private struct ProviderMetricInlineCostRow: View { .padding(.vertical, 2) } } + +// MARK: - MiniMax `model_remains[]`(设置页镜像菜单卡分组,无折叠) + +private struct ProviderMiniMaxSectionsInlineView: View { + let sections: [UsageMenuCardView.Model.MiniMaxSection] + let progressColor: Color + let labelWidth: CGFloat + + private var totalRowCount: Int { + self.sections.reduce(0) { $0 + $1.rows.count } + } + + var body: some View { + Group { + if self.totalRowCount >= MiniMaxUILayoutMetrics.settingsEmbeddedScrollThreshold { + ScrollView { + self.sectionStack + } + .frame(maxHeight: MiniMaxUILayoutMetrics.settingsEmbeddedScrollMaxHeight) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.06)) + } + } else { + self.sectionStack + } + } + } + + private var sectionStack: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(self.sections, id: \.title) { section in + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(section.rows) { row in + ProviderMiniMaxRowInlineView( + row: row, + progressColor: self.progressColor, + labelWidth: self.labelWidth) + } + } + } + } + .padding(.top, 4) + } +} + +private struct ProviderMiniMaxRowInlineView: View { + let row: UsageMenuCardView.Model.MiniMaxRow + let progressColor: Color + let labelWidth: CGFloat + + private static var titleWidthCap: CGFloat { + MiniMaxUILayoutMetrics.settingsTitleWidthCap() + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text(self.row.title) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .help(self.row.title) + .frame(width: Self.titleWidthCap, alignment: .leading) + + VStack(alignment: .leading, spacing: 4) { + if let detail = self.row.detailText, !detail.isEmpty { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + UsageProgressBar( + percent: self.row.percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer(minLength: 8) + if let resetText = self.row.resetText, !resetText.isEmpty { + Text(resetText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if let secondary = self.row.secondaryLine, !secondary.isEmpty { + Text(secondary) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55ff40b7a..7b5bc86d1 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -66,9 +66,9 @@ extension StatusItemController { if didRefresh { self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure } self.openMenus[ObjectIdentifier(menu)] = menu + self.refreshMenuCardHeights(in: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -357,7 +357,10 @@ extension StatusItemController { for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, width: menuWidth), + OverviewMenuCardRowView( + model: row.model, + width: menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: identifier, width: menuWidth, onClick: { [weak self, weak menu] in @@ -403,14 +406,20 @@ extension StatusItemController { } if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: "menuCard", width: context.menuWidth)) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: "menuCard-\(index)", width: context.menuWidth)) if index < cards.count - 1 { @@ -441,7 +450,10 @@ extension StatusItemController { } menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: "menuCard", width: context.menuWidth)) if context.openAIContext.canShowBuyCredits { @@ -803,7 +815,7 @@ extension StatusItemController { let provider = self.menuProvider(for: menu) self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure + self.refreshMenuCardHeights(in: menu) } } } @@ -842,6 +854,7 @@ extension StatusItemController { guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + self.refreshMenuCardHeights(in: menu) self.applyIcon(phase: nil) #if DEBUG self._test_openMenuRebuildObserver?(menu) @@ -904,18 +917,13 @@ extension StatusItemController { } private func refreshMenuCardHeights(in menu: NSMenu) { - // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content - // changes (e.g. dashboard error lines causing wrapping). - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) + let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + for item in menu.items { + let isOverviewRow = (item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true + let isMenuCard = (item.representedObject as? String)?.hasPrefix("menuCard") == true + guard isOverviewRow || isMenuCard, let view = item.view else { continue } + let height = self.remeasuredMenuCardHeight(for: view, width: width) + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } @@ -1282,6 +1290,26 @@ extension StatusItemController { } } + private func remeasuredMenuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { + let basePadding: CGFloat = 6 + let descenderSafety: CGFloat = 1 + + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + let fitted = view.fittingSize + return max(1, ceil(fitted.height + basePadding + descenderSafety)) + } + + private func makeMiniMaxLayoutRefreshAction(for menu: NSMenu) -> () -> Void { + { [weak self, weak menu] in + Task { @MainActor [weak self, weak menu] in + guard let self, let menu else { return } + await Task.yield() + self.rebuildOpenMenuIfStillVisible(menu, provider: self.menuProvider(for: menu)) + } + } + } + func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 3e8c6c3cc..e56199801 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -16,6 +16,13 @@ extension ProviderSwitcherSelection { struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? + + init(model: UsageMenuCardView.Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { + self.model = model + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -28,14 +35,16 @@ struct OverviewMenuCardRowView: View { model: self.model, showBottomDivider: false, bottomPadding: 6, - width: self.width) + width: self.width, + onMiniMaxLayoutChange: self.onMiniMaxLayoutChange) } } .frame(width: self.width, alignment: .leading) } private var hasUsageBlock: Bool { - !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil + !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || + (self.model.minimaxSections?.isEmpty == false) } } diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift new file mode 100644 index 000000000..3b87c284a --- /dev/null +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -0,0 +1,128 @@ +import CodexBarCore +import Foundation +import SwiftUI + +extension UsageMenuCardView.Model { + static func miniMaxSections(input: Input) -> [MiniMaxSection]? { + guard input.provider == .minimax, + let models = input.snapshot?.minimaxUsage?.models, + !models.isEmpty + else { + return nil + } + let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil } + guard models.count > 1 || hasWeeklyDetail else { + return nil + } + + let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false } + let daily = models.filter { if case .daily = $0.window { return true }; return false } + let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false } + let other = models.filter { if case .other = $0.window { return true }; return false } + + var sections: [MiniMaxSection] = [] + if !fiveHour.isEmpty { + sections.append(MiniMaxSection( + title: "5-hour window", + rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !daily.isEmpty { + sections.append(MiniMaxSection( + title: "Daily quota", + rows: daily.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !weeklyOnly.isEmpty { + sections.append(MiniMaxSection( + title: "Weekly quota", + rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !other.isEmpty { + sections.append(MiniMaxSection( + title: "Other windows", + rows: other.map { Self.miniMaxRow(model: $0, input: input) })) + } + return sections.isEmpty ? nil : sections + } + + static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + let used = model.usedPercent ?? 0 + let barPercent = percentStyle == .used ? used : (100 - used) + let resetText: String? = if let at = model.resetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: used, + windowMinutes: model.windowMinutes, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let detailText = Self.miniMaxDetailLine(model: model) + let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input) + return MiniMaxRow( + id: model.identifier, + title: model.displayName, + percent: Self.clamped(barPercent), + percentStyle: percentStyle, + resetText: resetText, + detailText: detailText, + secondaryLine: secondaryLine) + } + + static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { + guard let total = model.availablePrompts else { return nil } + let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) + let remaining = model.remainingPrompts + // 区间额度占位 0/0 时不展示误导性用量(与周限 0/0 同类问题)。 + if total == 0, used == 0, remaining == nil || remaining == 0 { + return nil + } + let usedStr = UsageFormatter.tokenCountString(used) + let totalStr = UsageFormatter.tokenCountString(total) + if let remaining { + let remStr = UsageFormatter.tokenCountString(remaining) + return "\(usedStr)/\(totalStr) (\(remStr) remaining)" + } + return "\(usedStr)/\(totalStr)" + } + + static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? { + guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil } + // 与解析层一致:任一侧为 0、另一侧缺省时按 0 计;全零即无周限,不展示误导性周限行。 + if (model.weeklyTotal ?? 0) == 0, (model.weeklyRemaining ?? 0) == 0 { return nil } + let total = model.weeklyTotal + let used = model.weeklyUsed + let remaining = model.weeklyRemaining + let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—" + let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—" + let pctStr = if let p = model.weeklyUsedPercent { + String(format: "%.1f%%", p) + } else { + "—" + } + let weeklyReset: String? = if let at = model.weeklyResetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: model.weeklyUsedPercent ?? 0, + windowMinutes: 7 * 24 * 60, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let remStr = remaining.map { UsageFormatter.tokenCountString($0) } + var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)" + if let remStr { + line += " · \(remStr) remaining" + } + if let weeklyReset { + line += " · \(weeklyReset)" + } + return line + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 359029d91..3d3d50192 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -897,12 +897,10 @@ extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } @@ -911,12 +909,10 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 62a91c4c1..b7fe788d8 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -418,7 +418,7 @@ struct MiniMaxModelRemains: Decodable { case modelId = "model_id" case modelTitle = "model_title" case displayName = "name" - case title = "title" + case title } init(from decoder: Decoder) throws { @@ -598,8 +598,15 @@ enum MiniMaxUsageParser { nil } - let weeklyTotal = row.currentWeeklyTotalCount - let weeklyRemaining = row.currentWeeklyUsageCount + // API 在无周限套餐上可能返回周限占位 0(仅 total、仅 remaining、或两者均为 0)。双 nil 表示未提供周限字段,不归一化。 + let rawWeeklyTotal = row.currentWeeklyTotalCount + let rawWeeklyRemaining = row.currentWeeklyUsageCount + let hasAnyWeeklyField = rawWeeklyTotal != nil || rawWeeklyRemaining != nil + let noWeeklyCap = hasAnyWeeklyField + && (rawWeeklyTotal ?? 0) == 0 + && (rawWeeklyRemaining ?? 0) == 0 + let weeklyTotal: Int? = noWeeklyCap ? nil : rawWeeklyTotal + let weeklyRemaining: Int? = noWeeklyCap ? nil : rawWeeklyRemaining let weeklyUsed: Int? = if let weeklyTotal, let weeklyRemaining { max(0, weeklyTotal - weeklyRemaining) } else { @@ -607,7 +614,11 @@ enum MiniMaxUsageParser { } let weeklyUsedPercent = self.usedPercent(total: weeklyTotal, remaining: weeklyRemaining) let weeklyEndDate = self.dateFromEpoch(row.weeklyEndTime) - let weeklyResetsAt = self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + let weeklyResetsAt: Date? = if noWeeklyCap { + nil + } else { + self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + } let identifier = self.modelIdentifier(row: row, index: index) let displayName = self.modelDisplayName(row: row, identifier: identifier) diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift index 28a5cbde6..59faa0e02 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -137,7 +137,99 @@ struct MiniMaxMenuCardTests { let sections = try #require(model.minimaxSections) #expect(sections.count == 1) let row = try #require(sections.first?.rows.first) - #expect(row.secondaryLine?.contains("Weekly") == true) - #expect(row.secondaryLine?.contains("70646") == true) + let weekly = try #require(row.secondaryLine) + #expect(weekly.contains("Weekly")) + #expect(weekly.contains("91.7")) + // Remaining uses UsageFormatter.tokenCountString (e.g. 70646 → "71K"), not raw digits. + #expect(weekly.contains("remaining")) + } + + @Test + func `minimax hides weekly secondary line when weekly quota is zero zero`() throws { + let now = Date() + let models: [MiniMaxModelUsage] = [ + MiniMaxModelUsage( + identifier: "coding", + displayName: "Coding", + availablePrompts: 1000, + currentPrompts: 100, + remainingPrompts: 900, + windowMinutes: 300, + usedPercent: 10, + resetsAt: nil, + weeklyTotal: 0, + weeklyUsed: 0, + weeklyRemaining: 0, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour), + ] + let minimax = MiniMaxUsageSnapshot( + planName: "Plan", + availablePrompts: 1000, + currentPrompts: 100, + remainingPrompts: 900, + windowMinutes: 300, + usedPercent: 10, + resetsAt: nil, + updatedAt: now, + models: models) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let sections = try #require(model.minimaxSections) + let row = try #require(sections.first?.rows.first) + #expect(row.secondaryLine == nil) + } + + @Test @MainActor + func `collapse store defaults collapsed when row count at least five`() { + let store = MiniMaxSectionCollapseStore.shared + store.resetOverridesForTesting() + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: 10)) + #expect(!store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold - 1)) + } + + @Test @MainActor + func `collapse store toggle persists until reset`() { + let store = MiniMaxSectionCollapseStore.shared + store.resetOverridesForTesting() + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + store.toggle(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold) + #expect(!store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + store.toggle(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold) + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + store.resetOverridesForTesting() + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + } + + @Test @MainActor + func `collapse store user override beats default for small sections`() { + let store = MiniMaxSectionCollapseStore.shared + store.resetOverridesForTesting() + #expect(!store.isCollapsed(sectionTitle: "Other windows", rowCount: 2)) + store.toggle(sectionTitle: "Other windows", rowCount: 2) + #expect(store.isCollapsed(sectionTitle: "Other windows", rowCount: 2)) } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 5cb6e31e0..96e189bdd 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -216,6 +216,71 @@ struct MiniMaxUsageParserTests { #expect(speech?.weeklyUsed == 6354) } + @Test + func `parses weekly zero zero as no weekly cap`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Unlimited Weekly", + "model_remains": [ + { + "model_name": "coding-model", + "current_interval_total_count": 1000, + "current_interval_usage_count": 500, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_end_time": \(start + 7 * 24 * 60 * 60 * 1000), + "weekly_remains_time": 3600000, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let row = try #require(snapshot.models.first { $0.identifier == "coding-model" }) + #expect(row.weeklyTotal == nil) + #expect(row.weeklyRemaining == nil) + #expect(row.weeklyUsed == nil) + #expect(row.weeklyUsedPercent == nil) + #expect(row.weeklyResetsAt == nil) + } + + @Test + func `parses weekly total zero with missing remaining as no weekly cap`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Plan", + "model_remains": [ + { + "model_name": "m1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 500, + "current_weekly_total_count": 0, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let row = try #require(snapshot.models.first) + #expect(row.weeklyTotal == nil) + #expect(row.weeklyRemaining == nil) + #expect(row.weeklyResetsAt == nil) + } + @Test func `parses coding plan from next data`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift b/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift new file mode 100644 index 000000000..d9ad47fa3 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import CodexBar + +struct MiniMaxUILayoutMetricsTests { + @Test + func `preferred menu usage height uses content height when under cap`() { + let height = MiniMaxUILayoutMetrics.preferredMenuUsageHeight( + contentHeight: 180, + visibleScreenHeight: 1000) + #expect(height == 180) + } + + @Test + func `preferred menu usage height clamps to cap when content is taller`() { + let cap = MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: 900) + let height = MiniMaxUILayoutMetrics.preferredMenuUsageHeight( + contentHeight: cap + 240, + visibleScreenHeight: 900) + #expect(height == cap) + } + + @Test + func `menu usage height falls back when screen height unavailable`() { + #expect( + MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: nil) == + MiniMaxUILayoutMetrics.menuScrollFallbackHeight) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 13c31e625..fe2a9c537 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -56,7 +56,12 @@ struct OpenAIDashboardNavigationDelegateTests { box.delegate?.webView(webView, didCommit: nil) #expect(result == nil) - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + // postCommitTask 在 sleep 结束后仍要在 MainActor 上执行 completeOnce;全量并行测试时 + // MainActor 积压会导致固定短 sleep 竞态失败,故轮询直到结果就绪或超时。 + let deadline = Date().addingTimeInterval(5.0) + while result == nil, Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } box.delegate = nil switch result { diff --git a/docs/minimax.md b/docs/minimax.md index 58d62309c..0fd8f6cf3 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -1,10 +1,10 @@ --- -summary: "MiniMax provider data sources: API token or browser cookies + coding plan remains API." + +## summary: "MiniMax provider data sources: API token or browser cookies + coding plan remains API." read_when: - Debugging MiniMax usage parsing - Updating MiniMax cookie handling or coding plan scraping - Adjusting MiniMax provider UI/menu behavior ---- # MiniMax provider @@ -13,33 +13,30 @@ either a Bearer API token or a session cookie header. ## Data sources + fallback order -1) **API token** (preferred) - - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`. - - When present, MiniMax uses the API token and ignores cookies entirely. - -2) **Cached cookie header** (automatic, only when no API token) - - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`). - -3) **Browser cookie import** (automatic) - - Cookie order from provider metadata (default: Safari → Chrome → Firefox). - - Merges Chromium profile cookies across the primary + Network stores before attempting a request. - - Tries each browser source until the Coding Plan API accepts the cookies. - - Domain filters: `platform.minimax.io`, `minimax.io`. - -4) **Browser local storage access token** (Chromium-based) - - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API. - - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims. - - Used automatically; no UI field. - - Also extracts `GroupId` when present (appends query param). - -5) **Manual session cookie header** (optional override) - - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). - - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. - - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and - `GroupId=…` for the remains API. - - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. +1. **API token** (preferred) + - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`. + - When present, MiniMax uses the API token and ignores cookies entirely. +2. **Cached cookie header** (automatic, only when no API token) + - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`). +3. **Browser cookie import** (automatic) + - Cookie order from provider metadata (default: Safari → Chrome → Firefox). + - Merges Chromium profile cookies across the primary + Network stores before attempting a request. + - Tries each browser source until the Coding Plan API accepts the cookies. + - Domain filters: `platform.minimax.io`, `minimax.io`. +4. **Browser local storage access token** (Chromium-based) + - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API. + - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims. + - Used automatically; no UI field. + - Also extracts `GroupId` when present (appends query param). +5. **Manual session cookie header** (optional override) + - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). + - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. + - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and + `GroupId=…` for the remains API. + - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. ## Endpoints + - API token endpoint: `https://api.minimax.io/v1/coding_plan/remains` - Requires `Authorization: Bearer `. - Global host (cookies): `https://platform.minimax.io` @@ -57,37 +54,64 @@ either a Bearer API token or a session cookie header. - `MINIMAX_REMAINS_URL=...` (full URL override) ## Cookie capture (optional override) + - Open the Coding Plan page and DevTools → Network. - Select the request to `/v1/api/openplatform/coding_plan/remains`. - Copy the `Cookie` request header (or use “Copy as cURL” and paste the whole line). - Paste into Preferences → Providers → MiniMax only if automatic import fails. ## Notes + - Cookies alone often return status 1004 (“cookie is missing, log in again”); the remains API expects a Bearer token. - MiniMax stores `access_token` in Chromium local storage (LevelDB). Some entries serialize the storage key without a scheme - (ex: `minimax.io`), so origin matching must account for host-only keys. +(ex: `minimax.io`), so origin matching must account for host-only keys. - Raw JWT scan fallback remains as a safety net if Chromium key formats change. - If local storage keys don’t decode (some Chrome builds), the MiniMax-specific text scan avoids a full raw-byte scan. ## Cookie file paths + - Safari: `~/Library/Cookies/Cookies.binarycookies` - Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` - Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` ## Snapshot mapping + - Primary: percent used from `model_remains` (used/total) or HTML "Available usage". - Window: derived from `start_time`/`end_time` or HTML duration text. - Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …". - Plan/tier: best-effort from response fields or HTML title. ### Coding Plan multi-model (`model_remains[]`) + - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. +- When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. +### Providers settings mirror (Preferences → Providers → MiniMax) + +- The Providers detail **Usage** section mirrors the same `model_remains[]` grouping as the menu card (**5-hour**, **Daily**, **Weekly**, **Other**), with the same per-row progress bar, `used/total (remaining)` detail line, reset text, and optional weekly secondary line. +- Preferences does **not** reuse the menu card’s collapsible section headers; if the combined row count is **≥ 6**, the block is wrapped in an embedded `ScrollView` (max height ≈ 360 pt) so the window stays manageable. +- MiniMax row titles in Preferences use a **separate fixed-width title column** instead of the global usage label width. The width is the rendered width of `code-plan-search`; longer model names **wrap onto multiple lines inside that fixed column** instead of tail-truncating, so the progress/detail column keeps a stable width without hiding the full name. + +### Menu-bar card layout (MiniMax-only) + +- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, NSScreen.main.visibleFrame.height − 310)))`. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling. +- Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit. +- Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height. +- This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons. + ## Key files + - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` +- `Sources/CodexBar/MiniMaxUILayoutMetrics.swift` +- `Sources/CodexBar/MiniMaxSectionCollapseStore.swift` +- `Sources/CodexBar/MiniMaxMenuCardViews.swift` (分组折叠 + 行视图) +- `Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift` (`model_remains[]` → 菜单模型) +- `Sources/CodexBar/MenuCardView.swift`(MiniMax 卡片区滚动) +- `Sources/CodexBar/PreferencesProviderDetailView.swift` (Providers → MiniMax usage mirror) + From 26d70442138eaa47085e1383070a702c41cf06e5 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 17:29:46 +0800 Subject: [PATCH 04/10] Fix post-merge test isolation and MenuCardView file length - AppGroupSupportTests: override snapshot URLs with temp paths so migration tests do not touch real Group Containers.\n- Move ClickToCopy overlay types to MenuCardClickToCopy.swift to satisfy SwiftLint file_length. Made-with: Cursor --- Sources/CodexBar/MenuCardClickToCopy.swift | 42 +++++++++++++++++++ Sources/CodexBar/MenuCardView.swift | 40 ------------------ .../CodexBarTests/AppGroupSupportTests.swift | 13 +++++- 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 Sources/CodexBar/MenuCardClickToCopy.swift diff --git a/Sources/CodexBar/MenuCardClickToCopy.swift b/Sources/CodexBar/MenuCardClickToCopy.swift new file mode 100644 index 000000000..73451d12e --- /dev/null +++ b/Sources/CodexBar/MenuCardClickToCopy.swift @@ -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) + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 122d4c12b..89b1e8692 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1599,43 +1599,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) - } -} diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift index ba55d2acd..de5b1e8d6 100644 --- a/Tests/CodexBarTests/AppGroupSupportTests.swift +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -91,6 +91,15 @@ struct AppGroupSupportTests { @Test func `legacy migration preserves existing target shared defaults`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false) + let legacySnapshotURL = root.appendingPathComponent("legacy/widget-snapshot.json", isDirectory: false) + // 隔离快照路径,避免在未覆盖时使用本机 Group Container 中的真实文件而误判为已迁移。 + let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)" let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)" let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)" @@ -111,7 +120,9 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, - legacyDefaultsOverride: legacyDefaults) + legacyDefaultsOverride: legacyDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) #expect(result.status == .noChangesNeeded) #expect(result.copiedDefaults == 0) From f952a432125d9e7cb40b8aa6ea8a406396b98697 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 18:07:09 +0800 Subject: [PATCH 05/10] Fix MiniMax detail line when interval usage count is omitted Avoid treating missing remaining as zero (full usage). Show em dash over total when neither current nor remaining is available. Adds XCTest coverage. Addresses Codex review on PR #742. Made-with: Cursor --- .../UsageMenuCardViewModel+MiniMax.swift | 21 +++++++-- .../CodexBarTests/MiniMaxMenuCardTests.swift | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift index 3b87c284a..0927e02f0 100644 --- a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -74,14 +74,29 @@ extension UsageMenuCardView.Model { static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { guard let total = model.availablePrompts else { return nil } - let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) + + // 与 MiniMaxUsageFetcher 一致:仅当同时有 total+remaining(或解析出的 current)时才推导已用量; + // remaining 缺省时不得假定为 0,否则会显示成「用尽」且与省略的 current_interval_usage_count 矛盾。 + let used: Int? = if let current = model.currentPrompts { + current + } else if let remaining = model.remainingPrompts { + max(0, total - remaining) + } else { + nil + } + let remaining = model.remainingPrompts // 区间额度占位 0/0 时不展示误导性用量(与周限 0/0 同类问题)。 - if total == 0, used == 0, remaining == nil || remaining == 0 { + if total == 0, (used ?? 0) == 0, remaining == nil || remaining == 0 { return nil } - let usedStr = UsageFormatter.tokenCountString(used) + let totalStr = UsageFormatter.tokenCountString(total) + guard let used else { + return "—/\(totalStr)" + } + + let usedStr = UsageFormatter.tokenCountString(used) if let remaining { let remStr = UsageFormatter.tokenCountString(remaining) return "\(usedStr)/\(totalStr) (\(remStr) remaining)" diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift index 59faa0e02..dd7d2e7fb 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -232,4 +232,50 @@ struct MiniMaxMenuCardTests { store.toggle(sectionTitle: "Other windows", rowCount: 2) #expect(store.isCollapsed(sectionTitle: "Other windows", rowCount: 2)) } + + @Test + func `minimax detail line does not infer full usage when interval usage count missing`() { + let row = MiniMaxModelUsage( + identifier: "m", + displayName: "M", + availablePrompts: 1000, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: 300, + usedPercent: 12.0, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour) + let line = UsageMenuCardView.Model.miniMaxDetailLine(model: row) + let totalStr = UsageFormatter.tokenCountString(1000) + #expect(line == "—/\(totalStr)") + } + + @Test + func `minimax detail line derives used from remaining when current omitted`() { + let row = MiniMaxModelUsage( + identifier: "m", + displayName: "M", + availablePrompts: 1000, + currentPrompts: nil, + remainingPrompts: 250, + windowMinutes: 300, + usedPercent: 75.0, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour) + let line = UsageMenuCardView.Model.miniMaxDetailLine(model: row) + let usedStr = UsageFormatter.tokenCountString(750) + let totalStr = UsageFormatter.tokenCountString(1000) + let remStr = UsageFormatter.tokenCountString(250) + #expect(line == "\(usedStr)/\(totalStr) (\(remStr) remaining)") + } } From 32fc946c9d309a65af092f181c2d86aeedef20a1 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 18:43:42 +0800 Subject: [PATCH 06/10] Stop tracking Cursor hook state and SpecStory local files Remove .cursor/hooks/state and .specstory from the repo tree; add gitignore rules so IDE-local data is not committed again. Made-with: Cursor --- .cursor/hooks/state/continual-learning.json | 8 - .gitignore | 4 + .specstory/.project.json | 7 - .specstory/cli/config.toml | 79 ----- ...19_04-53-42Z-cursor-branch-code-testing.md | 324 ------------------ .specstory/statistics.json | 13 - 6 files changed, 4 insertions(+), 431 deletions(-) delete mode 100644 .cursor/hooks/state/continual-learning.json delete mode 100644 .specstory/.project.json delete mode 100644 .specstory/cli/config.toml delete mode 100644 .specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md delete mode 100644 .specstory/statistics.json diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json deleted file mode 100644 index 8a91eee07..000000000 --- a/.cursor/hooks/state/continual-learning.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": 1, - "lastRunAtMs": 0, - "turnsSinceLastRun": 2, - "lastTranscriptMtimeMs": null, - "lastProcessedGenerationId": "d1ca6c4d-0a87-4bff-b994-8f4b64215c5c", - "trialStartedAtMs": null -} diff --git a/.gitignore b/.gitignore index d44f986f6..0de0767b5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ debug_*.swift # Misc .DS_Store .vscode/ + +# Cursor / SpecStory local state (never commit) +.cursor/hooks/state/ +.specstory/ .codex/environments/ .swiftpm-cache/ diff --git a/.specstory/.project.json b/.specstory/.project.json deleted file mode 100644 index 503fb60d0..000000000 --- a/.specstory/.project.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "workspace_id": "a1bf-ca39-9a6f-0f1e", - "workspace_id_at": "2026-04-19T06:04:20Z", - "git_id": "23d9-7492-e24f-7e50", - "git_id_at": "2026-04-19T06:04:20Z", - "project_name": "CodexBar" -} \ No newline at end of file diff --git a/.specstory/cli/config.toml b/.specstory/cli/config.toml deleted file mode 100644 index 9336aae89..000000000 --- a/.specstory/cli/config.toml +++ /dev/null @@ -1,79 +0,0 @@ -# SpecStory CLI Configuration -# -# This is the project-level config file for SpecStory CLI. -# All settings here apply to this project unless overridden by CLI flags. -# -# Uncomment (remove the #) the line and edit any setting below to change the default behavior. -# For more information, see: https://docs.specstory.com/integrations/terminal-coding-agents/usage - -[local_sync] -# Write markdown files locally. (default: true) -# enabled = false # equivalent to --only-cloud-sync - -# Custom output directory for markdown files. -# Default: ./.specstory/history (relative to the project directory) -# output_dir = "~/.specstory/history" # equivalent to --output-dir "~/.specstory/history" - -# Use local timezone for file name and content timestamps (default: false, UTC) -# local_time_zone = true # equivalent to --local-time-zone - -[cloud_sync] -# Sync session data to SpecStory Cloud. (default: true, when logged in to SpecStory Cloud) -# enabled = false # equivalent to --no-cloud-sync - -[logging] -# Write logs to .specstory/debug/debug.log (default: false) -# log = true # equivalent to --log - -# Debug-level output, requires console or log (default: false) -# debug = true # equivalent to --debug - -# Custom output directory for debug data. -# Default: ./.specstory/debug (relative to the project directory) -# debug_dir = "~/.specstory/debug" # equivalent to --debug-dir "~/.specstory/debug" - -# Error/warn/info output to stdout (default: false) -# console = true # equivalent to --console - -# Suppress all non-error output (default: false) -# silent = true # equivalent to --silent - -[version_check] -# Check for new versions of the CLI on startup. -# Default: true -# enabled = false # equivalent to --no-version-check - -[analytics] -# Send anonymous product usage analytics to help improve SpecStory. -# Default: true -# enabled = false # equivalent to --no-usage-analytics - -[telemetry] -# OTLP gRPC collector endpoint (e.g., "localhost:4317" or "http://localhost:4317") -# endpoint = "localhost:4317" - -# Override the default service name (default: "specstory-cli") -# service_name = "my-service-name" - -# Include user prompt text in telemetry spans (default: true) -# prompts = false - -[providers] -# Agent execution commands by provider (used by specstory run) -# Pass custom flags (e.g. claude_cmd = "claude --allow-dangerously-skip-permissions") -# Use of these is equivalent to -c "custom command" - -# Claude Code command -# claude_cmd = "claude" - -# Codex CLI command -# codex_cmd = "codex" - -# Cursor CLI command -# cursor_cmd = "cursor-agent" - -# Droid CLI command -# droid_cmd = "droid" - -# Gemini CLI command -# gemini_cmd = "gemini" diff --git a/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md b/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md deleted file mode 100644 index 021426232..000000000 --- a/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md +++ /dev/null @@ -1,324 +0,0 @@ - - -# 2026-04-19 04:53:42Z - - - -_**User**_ - -测试cursor分支的代码,应该如何做 - ---- - -_**Agent (default)**_ - - -正在查看项目结构与测试配置,以便说明如何测试 `cursor` 分支的代码。 - - - -_**Agent (default)**_ - - -
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/AGENTS.md - -
-
- -
-Tool use: **read_file_v2** - -
- -_**Agent (default)**_ - - -
-Tool use: **glob_file_search** • Searched codebase "" • **1** result in **1** directory - -Directory: **/Users/gc/Dev/Opensource/CodexBar** (1 file) - -| File | -|------| -| `package.json` | - -
-
- -
-Tool use: **glob_file_search** - -
- -_**Agent (default)**_ - - -
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 - -```bash -cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 -``` -
-
- -
-Tool use: **run_terminal_command_v2** - -
- -_**Agent (default)**_ - -
Thought Process -The git command failed with exit code 69, indicating git may not be installed or the current directory is not a Git repository.
- -_**Agent (default)**_ - - - - - - -_**Agent (default)**_ - - -
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/package.json - -
-
- -
-Tool use: **read_file_v2** - -
- -_**Agent (default)**_ - - -### 🧠 分析与说明 - -当前工作区可能**不是 Git 仓库**(本机 `git` 命令未返回分支信息)。若你要测的是**名为 `cursor` 的分支**,通用做法是:先切到该分支,再按本仓库约定跑构建与测试。 - -CodexBar 是 **SwiftPM + XCTest** 项目,`AGENTS.md` 和 `package.json` 里已经写清了推荐流程。 - ---- - -### 1. 切到 `cursor` 分支(在克隆的仓库里) - -在项目根目录执行: - -```bash -git fetch origin -git checkout cursor -# 若本地已有同名分支且需对齐远端: -# git checkout -b cursor origin/cursor -``` - -确认当前分支: - -```bash -git branch --show-current -git log -1 --oneline -``` - ---- - -### 2. 跑自动化测试(推荐顺序) - -| 目的 | 命令 | -|------|------| -| 只跑单元测试 | `pnpm test` 或 `swift test` | -| TTY 相关集成测试 | `pnpm run test:tty` | -| 需真实账号的 live 测试 | `pnpm run test:live`(会设 `LIVE_TEST=1`) | -| 格式/静态检查(提交前) | `pnpm check`(`AGENTS.md` 要求改代码后跑) | - ---- - -### 3. 完整开发闭环(构建 + 测试 + 打包 + 启动 App) - -与文档一致的首选: - -```bash -./Scripts/compile_and_run.sh -``` - -或: - -```bash -pnpm start -``` - -脚本会结束旧进程、`swift build`、`swift test`、打包并重新启动 `CodexBar.app`,适合验证**菜单栏应用**是否按预期工作。 - ---- - -### 4. 若本目录没有 Git / 没有 `cursor` 分支 - -需要先**克隆**带 `cursor` 分支的远程仓库,或让维护者提供该分支的提交/补丁,再在那一套代码上执行上面的 `swift test` / `compile_and_run.sh`。 - ---- - -**小结**:在 `cursor` 分支上,先 `git checkout cursor`,再 **`swift test`(或 `pnpm test`)** 做自动化验证;需要连 UI/真机行为时用 **`./Scripts/compile_and_run.sh`**;提交前跑 **`pnpm check`**。若你希望我根据你本机实际远程名(`origin` 或其它)写一条「从当前状态拉到 `cursor`」的准确命令,可以说一下仓库是 fork 还是主仓、以及 `git remote -v` 的输出。 - ---- - -_**User**_ - -我需要把cursor/minimax-multi-model-usage-ef73分支拉下来应该怎么做 - ---- - -_**Agent (default)**_ - -
Thought Process -To fetch the specified branch, use the command `git fetch origin cursor/minimax-multi-model-usage-ef73`. -If you need to switch to this branch, run `git checkout cursor/minimax-multi-model-usage-ef73`.
- -_**Agent (default)**_ - - -正在检查仓库的远程与分支情况,以便给出准确命令。 - - - -_**Agent (default)**_ - - -
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 - -```bash -cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 -``` - -``` -true -origin https://github.com/cskeleton/CodexBar.git (fetch) -origin https://github.com/cskeleton/CodexBar.git (push) -upstream https://github.com/steipete/CodexBar.git (fetch) -upstream https://github.com/steipete/CodexBar.git (push) -* main - remotes/origin/HEAD -> origin/main - remotes/origin/cursor/minimax-multi-model-usage-ef73 - remotes/origin/main - remotes/upstream/HEAD -> upstream/main - remotes/upstream/antigravity - remotes/upstream/ci/pin-lint-tools-and-format-baseline - remotes/upstream/claude-keychain-refresh-fix - remotes/upstream/co-pilot - remotes/upstream/codex/auto-keychain-prompts-user-action - remotes/upstream/codex/claude-auto-cli-before-web - remotes/upstream/codex/claude-keychain-policy-hard-enforcement - remotes/upstream/codex/claude-keychain-policy-ux - remotes/upstream/codex/claude-oauth-keychain-policy - remotes/upstream/codex/claude-oauth-security-cli-experimental - remotes/upstream/codex/fix-opencode-null-subscription-273 - remotes/upstream/codex/main-debug-reconcile-split - remotes/upstream/codex/menu-open-no-settings-write - remotes/upstream/codex/pr-275-scripts-only - remotes/upstream/codex/pr-286-audit - remotes/upstream/codex/pr-294-warp-provider - remotes/upstream/codex/pr-298-audit - remotes/upstream/codex/pr-310-audit - remotes/upstream/codex/pr-393-audit - remotes/upstream/codex/pr-397-audit - remotes/upstream/codex/pr-409-audit - remotes/upstream/codex/pr-438-audit - remotes/upstream/codex/pr-449-audit - remotes/upstream/codex/pr-453-audit - remotes/upstream/codex/pr-529-audit-20260409-152104 - remotes/upstream/codex/pr-565-audit - remotes/upstream/codex/pr-632-audit - remotes/upstream/codex/pr-673-audit - remotes/upstream/codex/provider-account-identity - remotes/upstream/codex/rat-185-multi-account-foundation - remotes/upstream/codex/rat-186 - remotes/upstream/codex/rat-187 - remotes/upstream/codex/rat-189-codex-parity-lock - remotes/upstream/cost/pr33-ccusage - remotes/upstream/cursor-fixes - -``` -
-
- -
-Tool use: **run_terminal_command_v2** - -
- -_**Agent (default)**_ - -
Thought Process -The remote branch exists as `remotes/origin/cursor/minimax-multi-model-usage-ef73`.
- -_**Agent (default)**_ - - -远程已有该分支:`origin/cursor/minimax-multi-model-usage-ef73`。正在拉取并检出: - - - -_**Agent (default)**_ - - -
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 - -```bash -cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 -``` - -``` -From https://github.com/cskeleton/CodexBar - * branch cursor/minimax-multi-model-usage-ef73 -> FETCH_HEAD -Switched to a new branch 'cursor/minimax-multi-model-usage-ef73' -branch 'cursor/minimax-multi-model-usage-ef73' set up to track 'origin/cursor/minimax-multi-model-usage-ef73'. - -``` -
-
- -
-Tool use: **run_terminal_command_v2** - -
- -_**Agent (default)**_ - - -### 已在你的仓库里完成操作 - -当前目录已 **`fetch` 并 `checkout`** 到分支 `cursor/minimax-multi-model-usage-ef73`,且已设置跟踪 `origin/cursor/minimax-multi-model-usage-ef73`。 - ---- - -### 以后在别的机器 / 新克隆里可以自己执行 - -**已有克隆、只拉这条分支并切换:** - -```bash -cd /path/to/CodexBar -git fetch origin cursor/minimax-multi-model-usage-ef73 -git checkout cursor/minimax-multi-model-usage-ef73 -``` - -**若本地还没有这个分支,用跟踪分支显式创建(等价写法):** - -```bash -git fetch origin -git checkout -b cursor/minimax-multi-model-usage-ef73 origin/cursor/minimax-multi-model-usage-ef73 -``` - -**全新克隆并直接落在该分支:** - -```bash -git clone -b cursor/minimax-multi-model-usage-ef73 https://github.com/cskeleton/CodexBar.git -``` - ---- - -### 更新分支(之后别人又推了新提交) - -```bash -git pull origin cursor/minimax-multi-model-usage-ef73 -``` - -需要的话可以继续在该分支上跑 `swift test` 或 `./Scripts/compile_and_run.sh` 做验证。 diff --git a/.specstory/statistics.json b/.specstory/statistics.json deleted file mode 100644 index c91f97a4c..000000000 --- a/.specstory/statistics.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "sessions": { - "9b5175d8-f354-417b-b218-5c6d9ac572fc": { - "user_message_count": 2, - "agent_message_count": 15, - "start_timestamp": "2026-04-19T12:53:42+08:00", - "end_timestamp": "2026-04-19T12:53:42+08:00", - "markdown_size_bytes": 10037, - "provider": "cursoride", - "last_updated": "2026-04-19T06:06:33Z" - } - } -} From 3f5c1200964635b586a02278ed5af0302992d1b4 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 17:15:15 +0800 Subject: [PATCH 07/10] Fix menu height shrink after MiniMax section collapse Resolve the post-merge sizing regression by using instance-based remeasurement during menu refresh, so long MiniMax usage content can collapse correctly and the menu window shrinks as expected. Made-with: Cursor --- Sources/CodexBar/StatusItemController+Menu.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index a27e52b72..0ddf73fbc 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -989,7 +989,8 @@ extension StatusItemController { let isOverviewRow = represented?.hasPrefix(Self.overviewRowIdentifierPrefix) == true let isMenuCard = represented?.hasPrefix("menuCard") == true guard (isOverviewRow || isMenuCard), let view = item.view else { continue } - let height = self.menuCardHeight(for: view, width: width) + // Use instance-based remeasure here so dynamic collapse/expand state can shrink as well as grow. + let height = self.remeasuredMenuCardHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } From f4e08c63e64155b9b8135611297bf351df00d3d3 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 18:26:00 +0800 Subject: [PATCH 08/10] Handle unknown MiniMax usage percent and prevent overview tap bleed. Keep MiniMax progress unknown when interval usage counts are missing, and suppress overview row selection when users tap in-card controls like section collapse buttons. Made-with: Cursor --- Sources/CodexBar/MenuCardView.swift | 9 ++--- Sources/CodexBar/MiniMaxMenuCardViews.swift | 31 +++++++++++----- .../PreferencesProviderDetailView.swift | 36 ++++++++++++------- .../CodexBar/StatusItemController+Menu.swift | 2 +- ...tatusItemController+MenuPresentation.swift | 17 +++++++++ .../UsageMenuCardViewModel+MiniMax.swift | 8 ++--- docs/minimax.md | 2 ++ 7 files changed, 73 insertions(+), 32 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 612b531ff..c896a6c39 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -97,7 +97,7 @@ struct UsageMenuCardView: View { struct MiniMaxRow: Identifiable, Equatable { let id: String let title: String - let percent: Double + let percent: Double? let percentStyle: PercentStyle let resetText: String? let detailText: String? @@ -619,16 +619,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 { diff --git a/Sources/CodexBar/MiniMaxMenuCardViews.swift b/Sources/CodexBar/MiniMaxMenuCardViews.swift index ec4327e7a..e9c3a9375 100644 --- a/Sources/CodexBar/MiniMaxMenuCardViews.swift +++ b/Sources/CodexBar/MiniMaxMenuCardViews.swift @@ -93,15 +93,28 @@ struct MiniMaxTokenPlanRowView: View { .font(.caption2) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } - UsageProgressBar( - percent: self.row.percent, - tint: self.progressColor, - accessibilityLabel: self.row.percentStyle.accessibilityLabel) - HStack(alignment: .firstTextBaseline) { - Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) - .font(.caption2) - Spacer() - if let reset = self.row.resetText { + if let percent = self.row.percent { + UsageProgressBar( + percent: percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.0f%% %@", percent, self.row.percentStyle.labelSuffix)) + .font(.caption2) + Spacer() + if let reset = self.row.resetText { + Text(reset) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + } + } + } else if let reset = self.row.resetText { + HStack(alignment: .firstTextBaseline) { + Text("Usage unavailable") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Spacer() Text(reset) .font(.caption2) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index d37be61a0..65610ccac 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -644,19 +644,31 @@ private struct ProviderMiniMaxRowInlineView: View { .font(.footnote) .foregroundStyle(.secondary) } - UsageProgressBar( - percent: self.row.percent, - tint: self.progressColor, - accessibilityLabel: self.row.percentStyle.accessibilityLabel) - .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) + if let percent = self.row.percent { + UsageProgressBar( + percent: percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - Spacer(minLength: 8) - if let resetText = self.row.resetText, !resetText.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(String(format: "%.0f%% %@", percent, self.row.percentStyle.labelSuffix)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer(minLength: 8) + if let resetText = self.row.resetText, !resetText.isEmpty { + Text(resetText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } else if let resetText = self.row.resetText, !resetText.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Usage unavailable") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer(minLength: 8) Text(resetText) .font(.footnote) .foregroundStyle(.secondary) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0ddf73fbc..9e72bbe5c 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -988,7 +988,7 @@ extension StatusItemController { let represented = item.representedObject as? String let isOverviewRow = represented?.hasPrefix(Self.overviewRowIdentifierPrefix) == true let isMenuCard = represented?.hasPrefix("menuCard") == true - guard (isOverviewRow || isMenuCard), let view = item.view else { continue } + guard isOverviewRow || isMenuCard, let view = item.view else { continue } // Use instance-based remeasure here so dynamic collapse/expand state can shrink as well as grow. let height = self.remeasuredMenuCardHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 418d4e9d2..83238535a 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -123,9 +123,26 @@ final class MenuCardItemHostingView: NSHostingView, Menu @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) { guard recognizer.state == .ended else { return } + let location = recognizer.location(in: self) + if let hitView = self.hitTest(location), + self.shouldSuppressRowSelection(for: hitView) + { + return + } self.onClick?() } + private func shouldSuppressRowSelection(for hitView: NSView) -> Bool { + var current: NSView? = hitView + while let view = current, view !== self { + if view is NSButton || view is NSControl { + return true + } + current = view.superview + } + return false + } + func measuredHeight(width: CGFloat) -> CGFloat { let controller = NSHostingController(rootView: self.rootView) let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift index 0927e02f0..264953f9a 100644 --- a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -46,12 +46,12 @@ extension UsageMenuCardView.Model { static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left - let used = model.usedPercent ?? 0 - let barPercent = percentStyle == .used ? used : (100 - used) + let used = model.usedPercent + let barPercent = used.map { percentStyle == .used ? $0 : (100 - $0) } let resetText: String? = if let at = model.resetsAt { UsageFormatter.resetLine( for: RateWindow( - usedPercent: used, + usedPercent: used ?? 0, windowMinutes: model.windowMinutes, resetsAt: at, resetDescription: nil), @@ -65,7 +65,7 @@ extension UsageMenuCardView.Model { return MiniMaxRow( id: model.identifier, title: model.displayName, - percent: Self.clamped(barPercent), + percent: barPercent.map { Self.clamped($0) }, percentStyle: percentStyle, resetText: resetText, detailText: detailText, diff --git a/docs/minimax.md b/docs/minimax.md index 0ba441f5d..194eafa46 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -85,6 +85,7 @@ either a Bearer API token or a session cookie header. - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). +- If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known. - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. - When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. @@ -101,6 +102,7 @@ either a Bearer API token or a session cookie header. - Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit. - Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height. - This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons. +- In merged **Overview** mode, MiniMax section header taps (collapse/expand) are handled as in-card interactions and must not trigger the row-level provider-selection action. ## Key files From b2ad13269afdfca44fdf6d9b1782d64ba3270c6f Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 19:10:50 +0800 Subject: [PATCH 09/10] Harden MiniMax row identity and menu screen height sizing. Make model-remains row IDs collision-safe for SwiftUI diffing and compute MiniMax menu scroll caps from the menu/status-item display screen instead of global NSScreen.main. Made-with: Cursor --- Sources/CodexBar/MenuCardView.swift | 41 +++++-------------- .../CodexBar/StatusItemController+Menu.swift | 26 +++++++++--- .../StatusItemController+MenuTypes.swift | 12 +++++- .../MiniMax/MiniMaxUsageFetcher.swift | 8 ++-- docs/minimax.md | 3 +- 5 files changed, 48 insertions(+), 42 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c896a6c39..81e24a181 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -127,23 +127,11 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? @Environment(\.menuItemHighlighted) private var isHighlighted - init(model: Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { - self.model = model - self.width = width - self.onMiniMaxLayoutChange = onMiniMaxLayoutChange - } - static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { - if provider == .openrouter, metric.id == "primary" { - return "API key limit" - } - return metric.title - } - - static func miniMaxUsageScrollMaxHeight() -> CGFloat { - MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: NSScreen.main?.visibleFrame.height) + provider == .openrouter && metric.id == "primary" ? "API key limit" : metric.title } var body: some View { @@ -173,7 +161,10 @@ struct UsageMenuCardView: View { if hasUsage { Group { if hasMiniMaxSections { - MiniMaxCappedScrollView(maxHeight: Self.miniMaxUsageScrollMaxHeight()) { + MiniMaxCappedScrollView( + maxHeight: MiniMaxUILayoutMetrics + .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight)) + { VStack(alignment: .leading, spacing: 12) { ForEach(self.model.metrics, id: \.id) { metric in MetricRow( @@ -516,27 +507,17 @@ struct UsageMenuCardUsageSectionView: View { let bottomPadding: CGFloat let width: CGFloat let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? @Environment(\.menuItemHighlighted) private var isHighlighted - init( - model: UsageMenuCardView.Model, - showBottomDivider: Bool, - bottomPadding: CGFloat, - width: CGFloat, - onMiniMaxLayoutChange: (() -> Void)? = nil) - { - self.model = model - self.showBottomDivider = showBottomDivider - self.bottomPadding = bottomPadding - self.width = width - self.onMiniMaxLayoutChange = onMiniMaxLayoutChange - } - var body: some View { let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false VStack(alignment: .leading, spacing: 12) { if hasMiniMaxSections { - MiniMaxCappedScrollView(maxHeight: UsageMenuCardView.miniMaxUsageScrollMaxHeight()) { + MiniMaxCappedScrollView( + maxHeight: MiniMaxUILayoutMetrics + .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight)) + { self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } } else { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9e72bbe5c..32f560f61 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -50,6 +50,16 @@ extension StatusItemController { return max(measuredWidth, Self.menuCardBaseWidth) } + private func menuVisibleScreenHeight(for menu: NSMenu) -> CGFloat? { + if let viewHeight = menu.items.lazy + .compactMap({ $0.view?.window?.screen?.visibleFrame.height }) + .first + { + return viewHeight + } + return self.statusItem.button?.window?.screen?.visibleFrame.height + } + func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -418,7 +428,8 @@ extension StatusItemController { OverviewMenuCardRowView( model: row.model, width: menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: identifier, width: menuWidth, onClick: { [weak self, weak menu] in @@ -467,7 +478,8 @@ extension StatusItemController { UsageMenuCardView( model: model, width: context.menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard", width: context.menuWidth)) menu.addItem(.separator()) @@ -477,7 +489,8 @@ extension StatusItemController { UsageMenuCardView( model: model, width: context.menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard-\(index)", width: context.menuWidth)) if index < cards.count - 1 { @@ -511,7 +524,8 @@ extension StatusItemController { UsageMenuCardView( model: model, width: context.menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard", width: context.menuWidth)) if context.openAIContext.canShowBuyCredits { @@ -1086,7 +1100,9 @@ extension StatusItemController { model: model, showBottomDivider: false, bottomPadding: usageBottomPadding, - width: width) + width: width, + onMiniMaxLayoutChange: nil, + miniMaxVisibleScreenHeight: nil) let usageSubmenu = self.makeUsageSubmenu( provider: provider, snapshot: self.store.snapshot(for: provider), diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index e56199801..caf57de1a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -17,11 +17,18 @@ struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model let width: CGFloat let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? - init(model: UsageMenuCardView.Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { + init( + model: UsageMenuCardView.Model, + width: CGFloat, + onMiniMaxLayoutChange: (() -> Void)? = nil, + miniMaxVisibleScreenHeight: CGFloat? = nil) + { self.model = model self.width = width self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + self.miniMaxVisibleScreenHeight = miniMaxVisibleScreenHeight } var body: some View { @@ -36,7 +43,8 @@ struct OverviewMenuCardRowView: View { showBottomDivider: false, bottomPadding: 6, width: self.width, - onMiniMaxLayoutChange: self.onMiniMaxLayoutChange) + onMiniMaxLayoutChange: self.onMiniMaxLayoutChange, + miniMaxVisibleScreenHeight: self.miniMaxVisibleScreenHeight) } } .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index b7fe788d8..eda24a2b1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -647,16 +647,16 @@ enum MiniMaxUsageParser { } private static func modelIdentifier(row: MiniMaxModelRemains, index: Int) -> String { - let candidates = [ + let primary = [ + row.modelId, row.modelName, row.modelType, - row.modelId, row.modelTitle, row.displayName, ] - for candidate in candidates { + for candidate in primary { let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return trimmed } + if !trimmed.isEmpty { return "\(trimmed)#\(index)" } } return "model-\(index + 1)" } diff --git a/docs/minimax.md b/docs/minimax.md index 194eafa46..a0126e43b 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -86,6 +86,7 @@ either a Bearer API token or a session cookie header. - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). - If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known. +- `MiniMaxModelUsage.identifier` is collision-safe for UI diffing: rows with duplicated/missing `model_name`/`model_type` still get distinct stable IDs within a payload (used by SwiftUI `ForEach`). - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. - When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. @@ -98,7 +99,7 @@ either a Bearer API token or a session cookie header. ### Menu-bar card layout (MiniMax-only) -- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, NSScreen.main.visibleFrame.height − 310)))`. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling. +- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, menuScreenVisibleHeight − 310)))`. `menuScreenVisibleHeight` is resolved from the status-item/menu display screen (with fallback only when unavailable), so multi-monitor setups use the correct cap. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling. - Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit. - Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height. - This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons. From 4fa0330dbb2d768c83b18f42c374cc899e058004 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 19:28:12 +0800 Subject: [PATCH 10/10] Keep MiniMax identifiers stable and only suffix duplicates. Preserve unsuffixed model identifiers for lookup compatibility while appending #n only when duplicate IDs appear within the same payload. Made-with: Cursor --- .../MiniMax/MiniMaxUsageFetcher.swift | 26 +++++++++++++++---- docs/minimax.md | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index eda24a2b1..95662158b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -567,8 +567,21 @@ enum MiniMaxUsageParser { nil } + let baseIdentifiers = rows.enumerated().map { index, row in + self.modelIdentifierBase(row: row, index: index) + } + var seenIdentifierCounts: [String: Int] = [:] + let identifiers = baseIdentifiers.map { baseIdentifier in + let seen = seenIdentifierCounts[baseIdentifier, default: 0] + seenIdentifierCounts[baseIdentifier] = seen + 1 + return seen == 0 ? baseIdentifier : "\(baseIdentifier)#\(seen)" + } + let models = rows.enumerated().map { index, row in - self.buildModelUsage(row: row, index: index, now: now) + self.buildModelUsage( + row: row, + identifier: identifiers[index], + now: now) } return MiniMaxUsageSnapshot( @@ -583,7 +596,11 @@ enum MiniMaxUsageParser { models: models) } - private static func buildModelUsage(row: MiniMaxModelRemains, index: Int, now: Date) -> MiniMaxModelUsage { + private static func buildModelUsage( + row: MiniMaxModelRemains, + identifier: String, + now: Date) -> MiniMaxModelUsage + { let total = row.currentIntervalTotalCount let remaining = row.currentIntervalUsageCount let usedPercent = self.usedPercent(total: total, remaining: remaining) @@ -620,7 +637,6 @@ enum MiniMaxUsageParser { self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) } - let identifier = self.modelIdentifier(row: row, index: index) let displayName = self.modelDisplayName(row: row, identifier: identifier) let windowKind = self.classifyWindowKind( windowMinutes: windowMinutes, @@ -646,7 +662,7 @@ enum MiniMaxUsageParser { window: windowKind) } - private static func modelIdentifier(row: MiniMaxModelRemains, index: Int) -> String { + private static func modelIdentifierBase(row: MiniMaxModelRemains, index: Int) -> String { let primary = [ row.modelId, row.modelName, @@ -656,7 +672,7 @@ enum MiniMaxUsageParser { ] for candidate in primary { let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return "\(trimmed)#\(index)" } + if !trimmed.isEmpty { return trimmed } } return "model-\(index + 1)" } diff --git a/docs/minimax.md b/docs/minimax.md index a0126e43b..2de11af52 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -86,7 +86,7 @@ either a Bearer API token or a session cookie header. - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). - If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known. -- `MiniMaxModelUsage.identifier` is collision-safe for UI diffing: rows with duplicated/missing `model_name`/`model_type` still get distinct stable IDs within a payload (used by SwiftUI `ForEach`). +- `MiniMaxModelUsage.identifier` preserves unsuffixed model keys for normal rows (for stable lookups), and only appends `#n` when duplicate identifiers occur within the same payload so SwiftUI `ForEach` still has unique row IDs. - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. - When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**.