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/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/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 c97aed64b..81e24a181 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? @@ -108,13 +126,12 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? @Environment(\.menuItemHighlighted) private var isHighlighted static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { - if provider == .openrouter, metric.id == "primary" { - return "API key limit" - } - return metric.title + provider == .openrouter && metric.id == "primary" ? "API key limit" : metric.title } var body: some View { @@ -125,7 +142,8 @@ struct UsageMenuCardView: View { Divider() } - if self.model.metrics.isEmpty { + let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false + if self.model.metrics.isEmpty, !hasMiniMaxSections { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { @@ -134,22 +152,53 @@ struct UsageMenuCardView: View { .font(.subheadline) } } else { - let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty + let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || hasMiniMaxSections let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost VStack(alignment: .leading, spacing: 12) { if hasUsage { - VStack(alignment: .leading, spacing: 12) { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) - } - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) + Group { + if hasMiniMaxSections { + MiniMaxCappedScrollView( + maxHeight: MiniMaxUILayoutMetrics + .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight)) + { + VStack(alignment: .leading, spacing: 12) { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: Self.popupMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor, + onLayoutChange: self.onMiniMaxLayoutChange) + } + } + } + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: Self.popupMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + } } } } @@ -216,7 +265,8 @@ struct UsageMenuCardView: View { private var hasDetails: Bool { !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || self.model.tokenUsage != nil || - self.model.providerCost != nil + self.model.providerCost != nil || + (self.model.minimaxSections?.isEmpty == false) } } @@ -456,28 +506,22 @@ struct UsageMenuCardUsageSectionView: View { let showBottomDivider: Bool let bottomPadding: CGFloat let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { + let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false VStack(alignment: .leading, spacing: 12) { - if self.model.metrics.isEmpty { - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } else if let placeholder = self.model.placeholder { - Text(placeholder) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .font(.subheadline) + if hasMiniMaxSections { + MiniMaxCappedScrollView( + maxHeight: MiniMaxUILayoutMetrics + .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight)) + { + self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } } else { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) - } - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } + self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } if self.showBottomDivider { Divider() @@ -488,6 +532,35 @@ struct UsageMenuCardUsageSectionView: View { .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } + + @ViewBuilder + private func usageContent(hasMiniMaxSections: Bool) -> some View { + if self.model.metrics.isEmpty, !hasMiniMaxSections { + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) + } + } else { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor, + onLayoutChange: self.onMiniMaxLayoutChange) + } + } } struct UsageMenuCardCreditsSectionView: View { @@ -527,16 +600,13 @@ private struct CreditsBarContent: View { let hintCopyText: String? let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted - private var percentLeft: Double? { guard let creditsRemaining else { return nil } - let percent = (creditsRemaining / Self.fullScaleTokens) * 100 - return min(100, max(0, percent)) + return min(100, max(0, (creditsRemaining / Self.fullScaleTokens) * 100)) } private var scaleText: String { - let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + "\(UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))) tokens" } var body: some View { @@ -753,6 +823,7 @@ extension UsageMenuCardView.Model { lastError: input.lastError) let redacted = Self.redactedText(input: input, subtitle: subtitle) let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil + let minimaxSections = Self.miniMaxSections(input: input) return UsageMenuCardView.Model( provider: input.provider, @@ -762,6 +833,7 @@ extension UsageMenuCardView.Model { subtitleStyle: subtitle.style, planText: planText, metrics: metrics, + minimaxSections: minimaxSections, usageNotes: usageNotes, creditsText: creditsText, creditsRemaining: input.credits?.remaining, @@ -1509,7 +1581,7 @@ extension UsageMenuCardView.Model { spendLine: "\(periodLabel): \(used) / \(limit)") } - private static func clamped(_ value: Double) -> Double { + static func clamped(_ value: Double) -> Double { min(100, max(0, value)) } @@ -1526,43 +1598,3 @@ extension UsageMenuCardView.Model { UsageFormatter.resetLine(for: window, style: style, now: now) } } - -// MARK: - Copy-on-click overlay - -private struct ClickToCopyOverlay: NSViewRepresentable { - let copyText: String - - func makeNSView(context: Context) -> ClickToCopyView { - ClickToCopyView(copyText: self.copyText) - } - - func updateNSView(_ nsView: ClickToCopyView, context: Context) { - nsView.copyText = self.copyText - } -} - -private final class ClickToCopyView: NSView { - var copyText: String - - init(copyText: String) { - self.copyText = copyText - super.init(frame: .zero) - self.wantsLayer = false - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - _ = event - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) - } -} diff --git a/Sources/CodexBar/MiniMaxMenuCardViews.swift b/Sources/CodexBar/MiniMaxMenuCardViews.swift new file mode 100644 index 000000000..e9c3a9375 --- /dev/null +++ b/Sources/CodexBar/MiniMaxMenuCardViews.swift @@ -0,0 +1,133 @@ +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)) + } + 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)) + .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..65610ccac 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,119 @@ 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) + } + 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%% %@", 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) + } + } + + 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 48a295e5e..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) @@ -95,9 +105,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) @@ -415,7 +425,11 @@ 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), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: identifier, width: menuWidth, onClick: { [weak self, weak menu] in @@ -461,14 +475,22 @@ 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), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(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), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard-\(index)", width: context.menuWidth)) if index < cards.count - 1 { @@ -499,7 +521,11 @@ extension StatusItemController { } menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard", width: context.menuWidth)) if context.openAIContext.canShowBuyCredits { @@ -868,7 +894,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) } } } @@ -907,6 +933,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) @@ -969,18 +996,16 @@ 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.renderedMenuWidth(for: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) + // Re-measure card/overview rows right before display to avoid stale sizing when wrapped text changes. + let width = self.renderedMenuWidth(for: menu) + for item in menu.items { + 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 } + // 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)) } } @@ -1075,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), @@ -1346,6 +1373,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+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/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 3e8c6c3cc..caf57de1a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -16,6 +16,20 @@ extension ProviderSwitcherSelection { 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, + miniMaxVisibleScreenHeight: CGFloat? = nil) + { + self.model = model + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + self.miniMaxVisibleScreenHeight = miniMaxVisibleScreenHeight + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -28,14 +42,17 @@ struct OverviewMenuCardRowView: View { model: self.model, showBottomDivider: false, bottomPadding: 6, - width: self.width) + width: self.width, + onMiniMaxLayoutChange: self.onMiniMaxLayoutChange, + miniMaxVisibleScreenHeight: self.miniMaxVisibleScreenHeight) } } .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..264953f9a --- /dev/null +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -0,0 +1,143 @@ +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 + let barPercent = used.map { percentStyle == .used ? $0 : (100 - $0) } + let resetText: String? = if let at = model.resetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: used ?? 0, + 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: barPercent.map { Self.clamped($0) }, + percentStyle: percentStyle, + resetText: resetText, + detailText: detailText, + secondaryLine: secondaryLine) + } + + static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { + guard let total = model.availablePrompts else { return nil } + + // 与 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) == 0, remaining == nil || remaining == 0 { + return nil + } + + 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)" + } + 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/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..95662158b 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 } 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,23 @@ 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, + identifier: identifiers[index], + now: now) + } + return MiniMaxUsageSnapshot( planName: planName, availablePrompts: total, @@ -541,7 +592,129 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + models: models) + } + + 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) + 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 + } + + // 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 { + nil + } + let weeklyUsedPercent = self.usedPercent(total: weeklyTotal, remaining: weeklyRemaining) + let weeklyEndDate = self.dateFromEpoch(row.weeklyEndTime) + let weeklyResetsAt: Date? = if noWeeklyCap { + nil + } else { + self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + } + + 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 modelIdentifierBase(row: MiniMaxModelRemains, index: Int) -> String { + let primary = [ + row.modelId, + row.modelName, + row.modelType, + row.modelTitle, + row.displayName, + ] + for candidate in primary { + 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/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) diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift new file mode 100644 index 000000000..dd7d2e7fb --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -0,0 +1,281 @@ +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) + 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)) + } + + @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)") + } +} diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 7cf0524bc..96e189bdd 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,134 @@ 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 + 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 @@ -193,6 +323,7 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(snapshot.usedPercent == 75) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) } @Test 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/docs/minimax.md b/docs/minimax.md index 6b7b7d647..2de11af52 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -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,30 +54,67 @@ 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). +- 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` 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**. + +### 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, 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. +- 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 + - `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) +