753 lines
31 KiB
Swift
753 lines
31 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
|
|
@MainActor
|
|
struct MenuDescriptor {
|
|
struct SubmenuItem: Equatable {
|
|
let title: String
|
|
let action: MenuAction?
|
|
let isEnabled: Bool
|
|
let isChecked: Bool
|
|
|
|
init(title: String, action: MenuAction?, isEnabled: Bool = true, isChecked: Bool = false) {
|
|
self.title = title
|
|
self.action = action
|
|
self.isEnabled = isEnabled
|
|
self.isChecked = isChecked
|
|
}
|
|
}
|
|
|
|
struct Section {
|
|
var entries: [Entry]
|
|
}
|
|
|
|
enum Entry {
|
|
case text(String, TextStyle)
|
|
case action(String, MenuAction)
|
|
case unavailable(String, String?)
|
|
case submenu(String, String?, [SubmenuItem])
|
|
case divider
|
|
|
|
var isActionable: Bool {
|
|
switch self {
|
|
case .action, .submenu, .unavailable: true
|
|
case .text, .divider: false
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MenuActionSystemImage: String {
|
|
case installUpdate = "arrow.down.circle"
|
|
case refresh = "arrow.clockwise"
|
|
case dashboard = "chart.xyaxis.line"
|
|
case statusPage = "waveform.path.ecg"
|
|
case changelog = "list.bullet.rectangle"
|
|
case addAccount = "plus"
|
|
case systemAccount = "person.crop.circle"
|
|
case switchAccount = "key"
|
|
case openTerminal = "terminal"
|
|
case loginToProvider = "arrow.right.square"
|
|
case settings = "gearshape"
|
|
case about = "info.circle"
|
|
case quit = "xmark.rectangle"
|
|
case copyError = "doc.on.doc"
|
|
}
|
|
|
|
enum TextStyle {
|
|
case headline
|
|
case primary
|
|
case secondary
|
|
}
|
|
|
|
enum MenuAction: Equatable {
|
|
case installUpdate
|
|
case refresh
|
|
case refreshAugmentSession
|
|
case dashboard
|
|
case statusPage
|
|
case changelog
|
|
case addCodexAccount
|
|
case requestCodexSystemPromotion(UUID)
|
|
case addProviderAccount(UsageProvider)
|
|
case switchAccount(UsageProvider)
|
|
case openTerminal(command: String)
|
|
case loginToProvider(url: String)
|
|
case settings
|
|
case about
|
|
case quit
|
|
case copyError(String)
|
|
case focusAgentSession(AgentSession, remoteHost: String?)
|
|
}
|
|
|
|
var sections: [Section]
|
|
|
|
static func build(
|
|
provider: UsageProvider?,
|
|
store: UsageStore,
|
|
settings: SettingsStore,
|
|
account: AccountInfo,
|
|
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? = nil,
|
|
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil,
|
|
updateReady: Bool,
|
|
includeContextualActions: Bool = true,
|
|
agentSessionsEnabled: Bool = false,
|
|
localAgentSessions: [AgentSession] = [],
|
|
remoteAgentHosts: [RemoteSessionHostResult] = [],
|
|
now: Date = Date()) -> MenuDescriptor
|
|
{
|
|
var sections: [Section] = []
|
|
|
|
if let provider {
|
|
let fallbackAccount = store.accountInfo(for: provider)
|
|
sections.append(Self.usageSection(for: provider, store: store, settings: settings))
|
|
if let accountSection = Self.accountSection(
|
|
for: provider,
|
|
store: store,
|
|
settings: settings,
|
|
account: fallbackAccount)
|
|
{
|
|
sections.append(accountSection)
|
|
}
|
|
} else {
|
|
var addedUsage = false
|
|
|
|
for enabledProvider in store.enabledProviders() {
|
|
sections.append(Self.usageSection(for: enabledProvider, store: store, settings: settings))
|
|
addedUsage = true
|
|
}
|
|
if addedUsage {
|
|
if let accountProvider = Self.accountProviderForCombined(store: store),
|
|
let fallbackAccount = Optional(store.accountInfo(for: accountProvider)),
|
|
let accountSection = Self.accountSection(
|
|
for: accountProvider,
|
|
store: store,
|
|
settings: settings,
|
|
account: fallbackAccount)
|
|
{
|
|
sections.append(accountSection)
|
|
}
|
|
} else {
|
|
sections.append(Section(entries: [.text(L("No usage configured."), .secondary)]))
|
|
}
|
|
}
|
|
|
|
if includeContextualActions {
|
|
let actions = Self.actionsSection(
|
|
for: provider,
|
|
store: store,
|
|
account: account,
|
|
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
|
|
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)
|
|
if !actions.entries.isEmpty {
|
|
sections.append(actions)
|
|
}
|
|
}
|
|
if agentSessionsEnabled {
|
|
sections.append(Self.agentSessionsSection(
|
|
localSessions: localAgentSessions,
|
|
remoteHosts: remoteAgentHosts,
|
|
now: now))
|
|
}
|
|
sections.append(Self.metaSection(updateReady: updateReady))
|
|
|
|
return MenuDescriptor(sections: sections)
|
|
}
|
|
|
|
static func agentSessionsSection(
|
|
localSessions: [AgentSession],
|
|
remoteHosts: [RemoteSessionHostResult],
|
|
now: Date = Date()) -> Section
|
|
{
|
|
let totalCount = localSessions.count + remoteHosts.reduce(0) { $0 + $1.sessions.count }
|
|
var entries: [Entry] = [.text("Agent Sessions (\(totalCount))", .headline)]
|
|
|
|
for session in localSessions {
|
|
entries.append(.action(
|
|
self.agentSessionRowTitle(session, now: now),
|
|
.focusAgentSession(session, remoteHost: nil)))
|
|
}
|
|
for remoteHost in remoteHosts {
|
|
if let error = remoteHost.error {
|
|
entries.append(.unavailable("\(remoteHost.host) — unreachable", error))
|
|
continue
|
|
}
|
|
entries.append(.text("\(remoteHost.host) — \(remoteHost.sessions.count)", .secondary))
|
|
for session in remoteHost.sessions {
|
|
entries.append(.action(
|
|
self.agentSessionRowTitle(session, now: now),
|
|
.focusAgentSession(session, remoteHost: remoteHost.host)))
|
|
}
|
|
}
|
|
if totalCount == 0 {
|
|
entries.append(.unavailable("No agent sessions found", nil))
|
|
}
|
|
return Section(entries: entries)
|
|
}
|
|
|
|
private static func agentSessionRowTitle(_ session: AgentSession, now: Date) -> String {
|
|
let state = session.state == .active ? "●" : "○"
|
|
let providerGlyph = session.provider == .codex ? "⌘" : "✦"
|
|
let project = session.projectName ?? "Unknown project"
|
|
return "\(state) \(providerGlyph) \(project) — \(session.provider.rawValue) · " +
|
|
"\(session.source.rawValue) · \(self.agentSessionAge(session, now: now))"
|
|
}
|
|
|
|
private static func agentSessionAge(_ session: AgentSession, now: Date) -> String {
|
|
guard let activity = session.lastActivityAt ?? session.startedAt else { return "now" }
|
|
let seconds = max(0, Int(now.timeIntervalSince(activity)))
|
|
if seconds < 60 {
|
|
return "\(seconds)s"
|
|
}
|
|
if seconds < 3600 {
|
|
return "\(seconds / 60)m"
|
|
}
|
|
if seconds < 86400 {
|
|
return "\(seconds / 3600)h"
|
|
}
|
|
return "\(seconds / 86400)d"
|
|
}
|
|
|
|
private static func usageSection(
|
|
for provider: UsageProvider,
|
|
store: UsageStore,
|
|
settings: SettingsStore) -> Section
|
|
{
|
|
let meta = store.metadata(for: provider)
|
|
var entries: [Entry] = []
|
|
let headlineText: String = {
|
|
if let ver = Self.versionNumber(for: provider, store: store) {
|
|
return "\(meta.displayName) \(ver)"
|
|
}
|
|
return meta.displayName
|
|
}()
|
|
entries.append(.text(headlineText, .headline))
|
|
|
|
if let snap = store.snapshot(for: provider) {
|
|
let resetStyle = settings.resetTimeDisplayStyle
|
|
let labels = Self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snap)
|
|
if let primary = snap.primary {
|
|
let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus ||
|
|
provider == .deepseek || provider == .azureopenai || provider == .mimo || provider == .qoder ||
|
|
provider == .sub2api
|
|
let primaryWindow = if primaryDescriptionIsDetail {
|
|
// Some providers use resetDescription for non-reset detail
|
|
// (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line.
|
|
RateWindow(
|
|
usedPercent: primary.usedPercent,
|
|
windowMinutes: primary.windowMinutes,
|
|
resetsAt: primary.resetsAt,
|
|
resetDescription: nil)
|
|
} else {
|
|
primary
|
|
}
|
|
Self.appendRateWindow(
|
|
entries: &entries,
|
|
title: labels.primary,
|
|
window: primaryWindow,
|
|
resetStyle: resetStyle,
|
|
showUsed: settings.usageBarsShowUsed)
|
|
if primaryDescriptionIsDetail,
|
|
let primaryDetail,
|
|
!primaryDetail.isEmpty
|
|
{
|
|
entries.append(.text(primaryDetail, .secondary))
|
|
}
|
|
if provider == .crof,
|
|
primary.resetsAt != nil,
|
|
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!detail.isEmpty
|
|
{
|
|
entries.append(.text(detail, .secondary))
|
|
}
|
|
if provider == .abacus,
|
|
let pace = store.weeklyPace(provider: provider, window: primary)
|
|
{
|
|
let paceSummary = UsagePaceText.weeklySummary(provider: provider, pace: pace)
|
|
entries.append(.text(paceSummary, .secondary))
|
|
}
|
|
if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) {
|
|
entries.append(.text(paceSummary, .secondary))
|
|
}
|
|
}
|
|
if let weekly = snap.secondary {
|
|
let weeklyResetOverride: String? = {
|
|
guard provider == .warp || provider == .kilo || provider == .perplexity || provider == .crof ||
|
|
provider == .sub2api
|
|
else { return nil }
|
|
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let detail, !detail.isEmpty else { return nil }
|
|
if provider == .kilo, weekly.resetsAt != nil {
|
|
return nil
|
|
}
|
|
return detail
|
|
}()
|
|
Self.appendRateWindow(
|
|
entries: &entries,
|
|
title: labels.secondary,
|
|
window: weekly,
|
|
resetStyle: resetStyle,
|
|
showUsed: settings.usageBarsShowUsed,
|
|
resetOverride: weeklyResetOverride)
|
|
if provider == .kilo,
|
|
weekly.resetsAt != nil,
|
|
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!detail.isEmpty
|
|
{
|
|
entries.append(.text(detail, .secondary))
|
|
}
|
|
if let pace = store.weeklyPace(provider: provider, window: weekly) {
|
|
let paceSummary = UsagePaceText.weeklySummary(provider: provider, pace: pace)
|
|
entries.append(.text(paceSummary, .secondary))
|
|
}
|
|
}
|
|
if labels.showsTertiary, let opus = snap.tertiary {
|
|
// Perplexity purchased credits don't reset; show the balance as plain text.
|
|
let opusResetOverride: String? = provider == .perplexity || provider == .sub2api
|
|
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
: nil
|
|
Self.appendRateWindow(
|
|
entries: &entries,
|
|
title: labels.tertiary,
|
|
window: opus,
|
|
resetStyle: resetStyle,
|
|
showUsed: settings.usageBarsShowUsed,
|
|
resetOverride: opusResetOverride)
|
|
}
|
|
|
|
Self.appendProviderUsageSummaries(
|
|
entries: &entries,
|
|
snapshot: snap,
|
|
showOptionalUsage: settings.showOptionalCreditsAndExtraUsage)
|
|
if snap.rateLimitsUnavailable(for: provider) {
|
|
entries.append(.text(L("Limits not available"), .secondary))
|
|
}
|
|
} else if !store.isStale(provider: provider),
|
|
store.knownLimitsAvailability(for: provider)?.isUnavailable == true
|
|
{
|
|
entries.append(.text(L("Limits not available"), .secondary))
|
|
} else {
|
|
entries.append(.text(L("No usage yet"), .secondary))
|
|
}
|
|
|
|
let usageContext = ProviderMenuUsageContext(
|
|
provider: provider,
|
|
store: store,
|
|
settings: settings,
|
|
metadata: meta,
|
|
snapshot: store.snapshot(for: provider))
|
|
ProviderCatalog.implementation(for: provider)?
|
|
.appendUsageMenuEntries(context: usageContext, entries: &entries)
|
|
|
|
return Section(entries: entries)
|
|
}
|
|
|
|
private static func appendProviderUsageSummaries(
|
|
entries: inout [Entry],
|
|
snapshot: UsageSnapshot,
|
|
showOptionalUsage: Bool)
|
|
{
|
|
if let cost = snapshot.providerCost {
|
|
if cost.currencyCode == "Quota" {
|
|
let used = String(format: "%.0f", cost.used)
|
|
let limit = String(format: "%.0f", cost.limit)
|
|
entries.append(.text("\(L("Quota")): \(used) / \(limit)", .primary))
|
|
}
|
|
}
|
|
if let openAIAPIUsage = snapshot.openAIAPIUsage {
|
|
Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage)
|
|
}
|
|
if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage {
|
|
Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage)
|
|
}
|
|
if let openRouterUsage = snapshot.openRouterUsage {
|
|
Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage)
|
|
}
|
|
if let clawRouterUsage = snapshot.clawRouterUsage {
|
|
entries.append(.text(
|
|
"\(UsageFormatter.tokenCountString(clawRouterUsage.requestCount)) \(L("requests")) · " +
|
|
"\(UsageFormatter.tokenCountString(clawRouterUsage.totalTokens)) \(L("tokens"))",
|
|
.secondary))
|
|
if !clawRouterUsage.providers.isEmpty {
|
|
let mix = clawRouterUsage.providers.prefix(5)
|
|
.map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" }
|
|
.joined(separator: " · ")
|
|
entries.append(.text("Routed providers: \(mix)", .secondary))
|
|
}
|
|
}
|
|
if let wayfinderUsage = snapshot.wayfinderUsage {
|
|
Self.appendWayfinderUsageSummary(entries: &entries, usage: wayfinderUsage)
|
|
}
|
|
if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty {
|
|
Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage)
|
|
}
|
|
if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty {
|
|
Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage)
|
|
}
|
|
if let mimoUsage = snapshot.mimoUsage {
|
|
entries.append(.text("\(L("Balance")): \(mimoUsage.balanceDetail)", .primary))
|
|
}
|
|
// Sakana pay-as-you-go is optional data gated by "Show optional credits and extra usage".
|
|
// Gate the render on the setting too, not just the fetch: toggling the setting off only
|
|
// rebuilds the menu, it does not immediately refetch, so a previously-populated
|
|
// sakanaPayAsYouGo would otherwise linger in the cached snapshot until the next refresh.
|
|
if showOptionalUsage, let sakanaPayAsYouGo = snapshot.sakanaPayAsYouGo {
|
|
entries.append(.text("\(L("Balance")): \(sakanaPayAsYouGo.balanceDetail)", .primary))
|
|
if let periodUsageTotal = sakanaPayAsYouGo.periodUsageTotal {
|
|
entries.append(.text(
|
|
"\(L("Usage")): \(UsageFormatter.usdString(periodUsageTotal))",
|
|
.secondary))
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func accountSection(
|
|
for provider: UsageProvider,
|
|
store: UsageStore,
|
|
settings: SettingsStore,
|
|
account: AccountInfo) -> Section?
|
|
{
|
|
let snapshot = store.snapshot(for: provider)
|
|
let metadata = store.metadata(for: provider)
|
|
let entries = Self.accountEntries(
|
|
provider: provider,
|
|
snapshot: snapshot,
|
|
metadata: metadata,
|
|
fallback: account,
|
|
hidePersonalInfo: settings.hidePersonalInfo)
|
|
guard !entries.isEmpty else { return nil }
|
|
return Section(entries: entries)
|
|
}
|
|
|
|
private static func accountEntries(
|
|
provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
metadata: ProviderMetadata,
|
|
fallback: AccountInfo,
|
|
hidePersonalInfo: Bool) -> [Entry]
|
|
{
|
|
var entries: [Entry] = []
|
|
let emailText = snapshot?.accountEmail(for: provider)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let loginMethodText = snapshot?.loginMethod(for: provider)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo)
|
|
|
|
if let emailText, !emailText.isEmpty, !redactedEmail.isEmpty {
|
|
entries.append(.text("\(L("Account")): \(redactedEmail)", .secondary))
|
|
}
|
|
if provider == .kiro {
|
|
if let plan = snapshot?.kiroUsage?.displayPlanName,
|
|
!plan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
entries.append(.text("\(L("Plan")): \(plan)", .secondary))
|
|
}
|
|
if let loginMethodText, !loginMethodText.isEmpty {
|
|
entries.append(.text("\(L("Auth")): \(loginMethodText)", .secondary))
|
|
}
|
|
if let overages = snapshot?.kiroUsage?.overagesStatus,
|
|
!overages.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
entries.append(.text("\(L("Overages")): \(overages)", .secondary))
|
|
}
|
|
} else if provider == .kilo {
|
|
let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText)
|
|
if let pass = kiloLogin.pass {
|
|
entries.append(.text("\(L("Plan")): \(AccountFormatter.plan(pass, provider: provider))", .secondary))
|
|
}
|
|
for detail in kiloLogin.details {
|
|
entries.append(.text("\(L("Activity")): \(detail)", .secondary))
|
|
}
|
|
} else if provider == .crossmodel {
|
|
if let loginMethodText, !loginMethodText.isEmpty {
|
|
entries.append(.text("\(L("Auth")): \(loginMethodText)", .secondary))
|
|
}
|
|
} else if let loginMethodText, !loginMethodText.isEmpty {
|
|
if provider == .openrouter || provider == .mimo || provider == .poe,
|
|
loginMethodText.localizedCaseInsensitiveContains("balance:")
|
|
{
|
|
let balanceValue = loginMethodText
|
|
.replacingOccurrences(
|
|
of: #"(?i)^\s*balance:\s*"#,
|
|
with: "",
|
|
options: [.regularExpression])
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let value = balanceValue.isEmpty ? loginMethodText : balanceValue
|
|
entries.append(
|
|
.text("\(L("Balance")): \(AccountFormatter.plan(value, provider: provider))", .secondary))
|
|
} else {
|
|
entries.append(
|
|
.text(
|
|
"\(L("Plan")): \(AccountFormatter.plan(loginMethodText, provider: provider))",
|
|
.secondary))
|
|
}
|
|
}
|
|
|
|
if metadata.usesAccountFallback {
|
|
if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty {
|
|
let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo)
|
|
if !redacted.isEmpty {
|
|
entries.append(.text("\(L("Account")): \(redacted)", .secondary))
|
|
}
|
|
}
|
|
if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty {
|
|
entries.append(
|
|
.text(
|
|
"\(L("Plan")): \(AccountFormatter.plan(fallbackPlan, provider: provider))",
|
|
.secondary))
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
private static func kiloLoginParts(loginMethod: String?) -> (pass: String?, details: [String]) {
|
|
guard let loginMethod else {
|
|
return (nil, [])
|
|
}
|
|
let parts = loginMethod
|
|
.components(separatedBy: "·")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
guard !parts.isEmpty else {
|
|
return (nil, [])
|
|
}
|
|
let first = parts[0]
|
|
if self.isKiloActivitySegment(first) {
|
|
return (nil, parts)
|
|
}
|
|
return (first, Array(parts.dropFirst()))
|
|
}
|
|
|
|
private static func isKiloActivitySegment(_ text: String) -> Bool {
|
|
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return normalized.hasPrefix("auto top-up:")
|
|
}
|
|
|
|
private static func accountProviderForCombined(store: UsageStore) -> UsageProvider? {
|
|
for provider in store.enabledProviders() {
|
|
let metadata = store.metadata(for: provider)
|
|
if store.snapshot(for: provider)?.identity(for: provider) != nil {
|
|
return provider
|
|
}
|
|
if metadata.usesAccountFallback {
|
|
return provider
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func actionsSection(
|
|
for provider: UsageProvider?,
|
|
store: UsageStore,
|
|
account: AccountInfo,
|
|
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?,
|
|
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?) -> Section
|
|
{
|
|
var entries: [Entry] = []
|
|
let targetProvider = provider ?? store.enabledProviders().first
|
|
let metadata = targetProvider.map { store.metadata(for: $0) }
|
|
let fallbackAccount = targetProvider.map { store.accountInfo(for: $0) } ?? account
|
|
let loginContext = targetProvider.map {
|
|
ProviderMenuLoginContext(
|
|
provider: $0,
|
|
store: store,
|
|
settings: store.settings,
|
|
account: fallbackAccount)
|
|
}
|
|
|
|
// Show "Add Account" if no account, "Switch Account" if logged in
|
|
if let targetProvider,
|
|
let implementation = ProviderCatalog.implementation(for: targetProvider),
|
|
implementation.supportsLoginFlow
|
|
{
|
|
if let loginContext,
|
|
let override = implementation.loginMenuAction(context: loginContext)
|
|
{
|
|
entries.append(.action(override.label, override.action))
|
|
} else {
|
|
let loginAction = self.switchAccountTarget(for: provider, store: store)
|
|
let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount)
|
|
let accountLabel = hasAccount ? L("Switch Account...") : L("Add Account...")
|
|
entries.append(.action(accountLabel, loginAction))
|
|
}
|
|
}
|
|
|
|
if let targetProvider {
|
|
let actionContext = ProviderMenuActionContext(
|
|
provider: targetProvider,
|
|
store: store,
|
|
settings: store.settings,
|
|
account: fallbackAccount,
|
|
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
|
|
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)
|
|
ProviderCatalog.implementation(for: targetProvider)?
|
|
.appendActionMenuEntries(context: actionContext, entries: &entries)
|
|
}
|
|
|
|
if metadata?.dashboardURL != nil {
|
|
entries.append(.action(L("Usage Dashboard"), .dashboard))
|
|
}
|
|
if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil {
|
|
entries.append(.action(L("Status Page"), .statusPage))
|
|
}
|
|
if store.settings.providerChangelogLinksEnabled, metadata?.changelogURL != nil {
|
|
entries.append(.action(L("Changelog"), .changelog))
|
|
}
|
|
|
|
if let statusLine = self.statusLine(for: provider, store: store) {
|
|
entries.append(.text(statusLine, .secondary))
|
|
}
|
|
|
|
return Section(entries: entries)
|
|
}
|
|
|
|
private static func metaSection(updateReady: Bool) -> Section {
|
|
var entries: [Entry] = []
|
|
if updateReady {
|
|
entries.append(.action(L("Update ready, restart now?"), .installUpdate))
|
|
}
|
|
entries.append(contentsOf: [
|
|
.action(L("Refresh"), .refresh),
|
|
.action(L("Settings..."), .settings),
|
|
.action(L("About CodexBar"), .about),
|
|
.action(L("Quit"), .quit),
|
|
])
|
|
return Section(entries: entries)
|
|
}
|
|
|
|
private static func statusLine(for provider: UsageProvider?, store: UsageStore) -> String? {
|
|
let target = provider ?? store.enabledProviders().first
|
|
guard let target,
|
|
let status = store.status(for: target),
|
|
status.indicator != .none else { return nil }
|
|
|
|
let description = status.description?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let label = description?.isEmpty == false ? description! : status.indicator.label
|
|
if let updated = status.updatedAt {
|
|
let freshness = UsageFormatter.updatedString(from: updated)
|
|
return "\(label) — \(freshness)"
|
|
}
|
|
return label
|
|
}
|
|
|
|
private static func switchAccountTarget(for provider: UsageProvider?, store: UsageStore) -> MenuAction {
|
|
if let provider {
|
|
return .switchAccount(provider)
|
|
}
|
|
if let enabled = store.enabledProviders().first {
|
|
return .switchAccount(enabled)
|
|
}
|
|
return .switchAccount(.codex)
|
|
}
|
|
|
|
private static func hasAccount(for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Bool {
|
|
let target = provider ?? store.enabledProviders().first ?? .codex
|
|
if let email = store.snapshot(for: target)?.accountEmail(for: target),
|
|
!email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
return true
|
|
}
|
|
let metadata = store.metadata(for: target)
|
|
if metadata.usesAccountFallback,
|
|
let fallback = account.email?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!fallback.isEmpty
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func rateWindowLabels(
|
|
provider: UsageProvider,
|
|
metadata: ProviderMetadata,
|
|
snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool)
|
|
{
|
|
if provider == .factory, snapshot.tertiary != nil {
|
|
return ("5-hour", L("Weekly"), L("Monthly"), true)
|
|
}
|
|
let primaryLabel = if provider == .grok {
|
|
GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel
|
|
} else if provider == .doubao {
|
|
DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel
|
|
} else if provider == .sub2api {
|
|
Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel
|
|
} else {
|
|
metadata.sessionLabel
|
|
}
|
|
return (
|
|
L(primaryLabel),
|
|
L(metadata.weeklyLabel),
|
|
metadata.opusLabel.map(L) ?? L("Sonnet"),
|
|
metadata.supportsOpus)
|
|
}
|
|
|
|
private static func appendRateWindow(
|
|
entries: inout [Entry],
|
|
title: String,
|
|
window: RateWindow,
|
|
resetStyle: ResetTimeDisplayStyle,
|
|
showUsed: Bool,
|
|
resetOverride: String? = nil)
|
|
{
|
|
let line = UsageFormatter
|
|
.usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed)
|
|
entries.append(.text("\(title): \(line)", .primary))
|
|
if let resetOverride {
|
|
entries.append(.text(resetOverride, .secondary))
|
|
} else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) {
|
|
entries.append(.text(reset, .secondary))
|
|
}
|
|
}
|
|
|
|
private static func versionNumber(for provider: UsageProvider, store: UsageStore) -> String? {
|
|
guard let raw = store.version(for: provider) else { return nil }
|
|
let pattern = #"[0-9]+(?:\.[0-9]+)*"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
let range = NSRange(raw.startIndex..<raw.endIndex, in: raw)
|
|
guard let match = regex.firstMatch(in: raw, options: [], range: range),
|
|
let r = Range(match.range, in: raw) else { return nil }
|
|
return String(raw[r])
|
|
}
|
|
}
|
|
|
|
private enum AccountFormatter {
|
|
static func plan(_ text: String, provider: UsageProvider) -> String {
|
|
let cleaned = if provider == .codex {
|
|
CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text)
|
|
} else {
|
|
UsageFormatter.cleanPlanName(text)
|
|
}
|
|
return cleaned.isEmpty ? text : cleaned
|
|
}
|
|
|
|
static func email(_ text: String) -> String {
|
|
text
|
|
}
|
|
}
|
|
|
|
extension MenuDescriptor.MenuAction {
|
|
var systemImageName: String? {
|
|
switch self {
|
|
case .installUpdate: MenuDescriptor.MenuActionSystemImage.installUpdate.rawValue
|
|
case .settings: MenuDescriptor.MenuActionSystemImage.settings.rawValue
|
|
case .about: MenuDescriptor.MenuActionSystemImage.about.rawValue
|
|
case .quit: MenuDescriptor.MenuActionSystemImage.quit.rawValue
|
|
case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
|
|
case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
|
|
case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue
|
|
case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue
|
|
case .changelog: MenuDescriptor.MenuActionSystemImage.changelog.rawValue
|
|
case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue
|
|
case .requestCodexSystemPromotion:
|
|
nil
|
|
case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue
|
|
case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue
|
|
case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue
|
|
case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue
|
|
case .focusAgentSession:
|
|
nil
|
|
}
|
|
}
|
|
}
|