From 253c0499cffeb6b40e911e360f4eca4f3c56360f Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 25 May 2026 21:42:17 +0800 Subject: [PATCH 1/8] Improve Simplified Chinese localization for visible UI --- .../CodexBar/CostHistoryChartMenuView.swift | 14 +- .../CreditsHistoryChartMenuView.swift | 2 +- .../CodexBar/Date+RelativeDescription.swift | 11 +- Sources/CodexBar/Localization.swift | 15 ++ Sources/CodexBar/MenuCardView+Costs.swift | 44 ++++-- Sources/CodexBar/MenuCardView+MiniMax.swift | 52 ++++-- Sources/CodexBar/MenuCardView.swift | 36 ++--- Sources/CodexBar/MenuDescriptor.swift | 36 ++--- .../PlanUtilizationHistoryChartMenuView.swift | 16 +- .../Resources/en.lproj/Localizable.strings | 142 +++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 148 ++++++++++++++++-- .../StatusItemController+CostMenuCard.swift | 2 +- .../StatusItemController+HostedSubmenus.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 15 +- .../StatusItemController+MenuTypes.swift | 2 +- .../StatusItemController+SwitcherViews.swift | 2 +- .../CodexBar/StorageBreakdownMenuView.swift | 18 +-- .../UsageBreakdownChartMenuView.swift | 6 +- Sources/CodexBar/UsagePaceText.swift | 27 ++-- .../ZaiHourlyUsageChartMenuView.swift | 6 +- Sources/CodexBarCore/UsageFormatter.swift | 48 ++++-- 21 files changed, 503 insertions(+), 141 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 406e85ece..b7d3f513d 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -58,10 +58,10 @@ struct CostHistoryChartMenuView: View { let model = Self.makeModel(provider: self.provider, daily: self.daily) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No cost history data.") + Text(L("No cost history data.")) .font(.footnote) .foregroundStyle(.secondary) - .accessibilityLabel("No cost history data available.") + .accessibilityLabel(L("No cost history data.")) } else { Chart { ForEach(model.points) { point in @@ -171,7 +171,9 @@ struct CostHistoryChartMenuView: View { } if let total = self.totalCostUSD { - Text("Est. total (\(Self.windowLabel(days: self.historyDays))): \(UsageFormatter.usdString(total))") + Text(String( + format: L("Est. total (30d): %@"), + UsageFormatter.usdString(total))) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -206,7 +208,7 @@ struct CostHistoryChartMenuView: View { private static let detailSpacing: CGFloat = 6 private static func windowLabel(days: Int) -> String { - days == 1 ? "today" : "\(days)d" + days == 1 ? L("today") : String(format: L("%dd"), days) } private static func detailRowHeight(for row: DetailRow) -> CGFloat { @@ -416,13 +418,13 @@ struct CostHistoryChartMenuView: View { let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return DetailContent(primary: "Hover a bar for details", rows: []) + return DetailContent(primary: L("Hover a bar for details"), rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) let primary = if let tokens = point.totalTokens { - "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + String(format: L("%@: %@ · %@ tokens"), dayLabel, cost, UsageFormatter.tokenCountString(tokens)) } else { "\(dayLabel): \(cost)" } diff --git a/Sources/CodexBar/CreditsHistoryChartMenuView.swift b/Sources/CodexBar/CreditsHistoryChartMenuView.swift index a746251bb..b3ae60e89 100644 --- a/Sources/CodexBar/CreditsHistoryChartMenuView.swift +++ b/Sources/CodexBar/CreditsHistoryChartMenuView.swift @@ -302,7 +302,7 @@ struct CreditsHistoryChartMenuView: View { let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (L("Hover a bar for details"), nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift index cb3c4f59e..434137993 100644 --- a/Sources/CodexBar/Date+RelativeDescription.swift +++ b/Sources/CodexBar/Date+RelativeDescription.swift @@ -2,12 +2,12 @@ import Foundation enum RelativeTimeFormatters { @MainActor - static let full: RelativeDateTimeFormatter = { + static func full(locale: Locale) -> RelativeDateTimeFormatter { let formatter = RelativeDateTimeFormatter() - formatter.locale = Locale(identifier: "en_US") + formatter.locale = locale formatter.unitsStyle = .full return formatter - }() + } } extension Date { @@ -15,8 +15,9 @@ extension Date { func relativeDescription(now: Date = .now) -> String { let seconds = abs(now.timeIntervalSince(self)) if seconds < 15 { - return "just now" + return L("just now") } - return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now) + let locale = codexBarLocalizedLocale() + return RelativeTimeFormatters.full(locale: locale).localizedString(for: self, relativeTo: now) } } diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index d9a3bbfc6..22530632a 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -79,6 +79,21 @@ func L(_ key: String, _ arguments: CVarArg...) -> String { String(format: L(key), arguments: arguments) } +func codexBarLocalizedLocale() -> Locale { + let language = appLanguageDefaults().string(forKey: "appLanguage") ?? "" + guard !language.isEmpty else { return .current } + switch language.lowercased() { + case "zh-hans": + return Locale(identifier: "zh-Hans") + case "zh-hant": + return Locale(identifier: "zh-Hant") + case "pt-br": + return Locale(identifier: "pt-BR") + default: + return Locale(identifier: language) + } +} + func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bundle) -> String { let value = bundle.localizedString(forKey: key, value: nil, table: nil) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 577cc0e37..1553c8c60 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -14,7 +14,7 @@ extension UsageMenuCardView.Model { if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } - return metadata.creditsHint + return L(metadata.creditsHint) } static func tokenUsageSection( @@ -33,28 +33,26 @@ extension UsageMenuCardView.Model { let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if provider == .bedrock { - let label = Self.bedrockLatestBillingDayLabel(from: snapshot) if let sessionTokens { - return "\(label): \(sessionCost) · \(sessionTokens) tokens" + return String(format: L("Today: %@ · %@ tokens"), sessionCost, sessionTokens) } - return "\(label): \(sessionCost)" + return String(format: L("Today: %@"), sessionCost) } if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + return String(format: L("Today: %@ · %@ tokens"), sessionCost, sessionTokens) } - return "Today: \(sessionCost)" + return String(format: L("Today: %@"), sessionCost) }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let windowLabel = Self.costHistoryWindowLabel(days: snapshot.historyDays) let monthLine: String = { if let monthTokens { - return "\(windowLabel): \(monthCost) · \(monthTokens) tokens" + return String(format: L("Last 30 days: %@ · %@ tokens"), monthCost, monthTokens) } - return "\(windowLabel): \(monthCost)" + return String(format: L("Last 30 days: %@"), monthCost) }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -68,20 +66,20 @@ extension UsageMenuCardView.Model { static func tokenUsageHint(provider: UsageProvider) -> String? { switch provider { case .codex: - "Estimated from local Codex logs for the selected account." + L("Estimated from local Codex logs for the selected account.") case .claude: - UsageFormatter.costEstimateHint(provider: provider) + L("cost_estimate_hint") case .vertexai: - UsageFormatter.costEstimateHint + L("cost_estimate_hint") case .bedrock: - "Reported by AWS Cost Explorer; daily billing data can lag." + L("AWS Cost Explorer billing can lag.") default: nil } } static func costHistoryWindowLabel(days: Int) -> String { - days == 1 ? "Today" : "Last \(days) days" + days == 1 ? L("Today") : String(format: L("Last %d days"), days) } private static func bedrockLatestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String { @@ -155,7 +153,7 @@ extension UsageMenuCardView.Model { if provider == .openai || provider == .claude, cost.limit <= 0 { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let periodLabel = cost.period ?? "Last 30 days" + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( title: "API spend", percentUsed: nil, @@ -180,7 +178,7 @@ extension UsageMenuCardView.Model { } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) - let periodLabel = cost.period ?? "This month" + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") return ProviderCostSection( title: title, @@ -189,6 +187,20 @@ extension UsageMenuCardView.Model { percentLine: String(format: "%.0f%% used", min(100, max(0, percentUsed)))) } + private static func localizedPeriodLabel(_ label: String) -> String { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + switch trimmed.lowercased() { + case "last 30 days": + return L("Last 30 days") + case "this month": + return L("This month") + case "today": + return L("Today") + default: + return L(trimmed) + } + } + static func clamped(_ value: Double) -> Double { min(100, max(0, value)) } diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index bf0b57e11..dda62d32f 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -9,12 +9,18 @@ extension UsageMenuCardView.Model { return services.enumerated().map { index, service in let used = service.usage let displayPercent = min(100, max(0, service.percent)) - let usageLabel = "Usage: \(used.formatted()) / \(service.limit.formatted())" - let usedLabel = "Used \(String(format: "%.0f%%", displayPercent))" - let title = if service.displayName == "Text Generation", textGenerationCount > 1 { - "Text Generation · \(Self.displayWindowBadge(for: service.windowType))" + let usageLabel = String( + format: L("minimax_usage_amount_format"), + used.formatted(), + service.limit.formatted()) + let usedLabel = String( + format: L("minimax_used_percent_format"), + String(format: "%.0f%%", displayPercent)) + let localizedName = Self.localizedMiniMaxServiceName(service.displayName) + let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { + "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" } else { - service.displayName + localizedName } return Metric( @@ -22,7 +28,7 @@ extension UsageMenuCardView.Model { title: title, percent: displayPercent, percentStyle: percentStyle, - resetText: service.resetDescription, + resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), detailText: service.timeRange, detailLeftText: usageLabel, detailRightText: usedLabel, @@ -37,17 +43,45 @@ extension UsageMenuCardView.Model { let normalized = trimmed.lowercased() if normalized == "weekly" { - return "Weekly" + return L("Weekly") } if normalized == "5 hours" || normalized == "5 hour" || normalized == "5h" { return "5h" } if normalized == "today" { - return "Today" + return L("Today") } if normalized == "daily" { - return "Daily" + return L("Daily") } return trimmed.isEmpty ? windowType : trimmed } + + private static func localizedMiniMaxResetDescription(_ text: String) -> String { + let prefix = "Resets in " + guard text.hasPrefix(prefix) else { return text } + let rest = String(text.dropFirst(prefix.count)) + return L("Resets in %@", rest) + } + + private static func localizedMiniMaxServiceName(_ raw: String) -> String { + switch raw { + case "Text Generation", "text_generation": + L("minimax_service_text_generation") + case "Text to Speech", "text_to_speech": + L("minimax_service_text_to_speech") + case "Music Generation", "music_generation": + L("minimax_service_music_generation") + case "Image Generation", "image_generation": + L("minimax_service_image_generation") + case "lyrics_generation": + L("minimax_service_lyrics_generation") + case "coding-plan-vlm": + L("minimax_service_coding_plan_vlm") + case "coding-plan-search": + L("minimax_service_coding_plan_search") + default: + raw + } + } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index ed423a460..6c91efa7f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -11,8 +11,8 @@ struct UsageMenuCardView: View { var labelSuffix: String { switch self { - case .left: "left" - case .used: "used" + case .left: L("usage_percent_suffix_left") + case .used: L("usage_percent_suffix_used") } } @@ -190,7 +190,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("cost_header_estimated") + Text(L("cost_header_estimated")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -614,7 +614,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("cost_header_estimated") + Text(L("cost_header_estimated")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -858,9 +858,9 @@ extension UsageMenuCardView.Model { case .available: break case .noLimitConfigured: - notes.append("No limit set for the API key") + notes.append(L("No limit set for the API key")) case .unavailable: - notes.append("API key limit unavailable right now") + notes.append(L("API key limit unavailable right now")) } return notes } @@ -868,10 +868,10 @@ extension UsageMenuCardView.Model { private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { var parts: [String] = [] if let daily = usage.keyUsageDaily { - parts.append("Today: \(Self.openRouterCurrencyString(daily))") + parts.append("\(L("Today")): \(Self.openRouterCurrencyString(daily))") } if let weekly = usage.keyUsageWeekly { - parts.append("This week: \(Self.openRouterCurrencyString(weekly))") + parts.append("\(L("This week")): \(Self.openRouterCurrencyString(weekly))") } guard !parts.isEmpty else { return [] } return [parts.joined(separator: " · ")] @@ -975,7 +975,7 @@ extension UsageMenuCardView.Model { } if isRefreshing, snapshot == nil { - return ("Refreshing...", .loading) + return ("\(L("Refreshing"))…", .loading) } if let updated = snapshot?.updatedAt { @@ -1157,11 +1157,11 @@ extension UsageMenuCardView.Model { snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) { if input.provider == .factory, snapshot.tertiary != nil { - return ("5-hour", "Weekly", "Monthly", true) + return ("5-hour", L("Weekly"), "Monthly", true) } return ( - input.metadata.sessionLabel, - input.metadata.weeklyLabel, + L(input.metadata.sessionLabel), + L(input.metadata.weeklyLabel), input.metadata.opusLabel ?? "Sonnet", input.metadata.supportsOpus) } @@ -1275,7 +1275,7 @@ extension UsageMenuCardView.Model { } return Metric( id: "primary", - title: title ?? input.metadata.sessionLabel, + title: title ?? L(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -1377,7 +1377,7 @@ extension UsageMenuCardView.Model { } return Metric( id: "secondary", - title: title ?? input.metadata.weeklyLabel, + title: title ?? L(input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -1402,7 +1402,7 @@ extension UsageMenuCardView.Model { let paceDetail: PaceDetail? switch lane { case .session: - title = input.metadata.sessionLabel + title = L(input.metadata.sessionLabel) id = "primary" paceDetail = Self.sessionPaceDetail( provider: input.provider, @@ -1410,7 +1410,7 @@ extension UsageMenuCardView.Model { now: input.now, showUsed: input.usageBarsShowUsed) case .weekly: - title = input.metadata.weeklyLabel + title = L(input.metadata.weeklyLabel) id = "secondary" paceDetail = Self.weeklyPaceDetail( window: window, @@ -1444,13 +1444,13 @@ extension UsageMenuCardView.Model { return [ Self.antigravityMetric( id: "primary", - title: input.metadata.sessionLabel, + title: L(input.metadata.sessionLabel), window: snapshot.primary, input: input, percentStyle: percentStyle), Self.antigravityMetric( id: "secondary", - title: input.metadata.weeklyLabel, + title: L(input.metadata.weeklyLabel), window: snapshot.secondary, input: input, percentStyle: percentStyle), diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index fd711e07f..5d7b1d7e7 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -240,7 +240,7 @@ struct MenuDescriptor { Self.appendProviderUsageSummaries(entries: &entries, snapshot: snap) } else { - entries.append(.text("No usage yet", .secondary)) + entries.append(.text(L("No usage yet"), .secondary)) } let usageContext = ProviderMenuUsageContext( @@ -290,7 +290,7 @@ struct MenuDescriptor { let historyLabel = usage.historyWindowLabel entries.append(.text( - "Today: \(UsageFormatter.usdString(today.costUSD)) · " + + "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens", .secondary)) entries.append(.text( @@ -302,7 +302,7 @@ struct MenuDescriptor { "\(UsageFormatter.tokenCountString(last30.requests)) requests", .secondary)) if let topModel = usage.topModels.first?.name { - entries.append(.text("Top model: \(topModel)", .secondary)) + entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) } } @@ -315,7 +315,7 @@ struct MenuDescriptor { let last30 = usage.last30Days entries.append(.text( - "Today: \(UsageFormatter.usdString(today.costUSD)) · " + + "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens", .secondary)) entries.append(.text( @@ -327,7 +327,7 @@ struct MenuDescriptor { "\(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", .secondary)) if let topModel = usage.topModels.first?.name { - entries.append(.text("Top model: \(topModel)", .secondary)) + entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) } } @@ -336,7 +336,7 @@ struct MenuDescriptor { usage: OpenRouterUsageSnapshot) { if let daily = usage.keyUsageDaily { - entries.append(.text("Today: \(UsageFormatter.usdString(daily))", .secondary)) + entries.append(.text("\(L("Today")): \(UsageFormatter.usdString(daily))", .secondary)) } if let weekly = usage.keyUsageWeekly { entries.append(.text("Week: \(UsageFormatter.usdString(weekly))", .secondary)) @@ -363,7 +363,7 @@ struct MenuDescriptor { "\(UsageFormatter.tokenCountString(totalTokens)) tokens", .secondary)) if let top = Self.topMistralModel(from: usage.daily) { - entries.append(.text("Top model: \(top)", .secondary)) + entries.append(.text("\(L("Top model")): \(top)", .secondary)) } } @@ -534,7 +534,7 @@ struct MenuDescriptor { } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount) - let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + let accountLabel = hasAccount ? L("Switch Account...") : L("Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -552,10 +552,10 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(L("Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(L("Status Page"), .statusPage)) } if store.settings.providerChangelogLinksEnabled, metadata?.changelogURL != nil { entries.append(.action("Changelog", .changelog)) @@ -571,13 +571,13 @@ struct MenuDescriptor { private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { - entries.append(.action("Update ready, restart now?", .installUpdate)) + entries.append(.action(L("Update ready, restart now?"), .installUpdate)) } entries.append(contentsOf: [ - .action("Refresh", .refresh), - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(L("Refresh"), .refresh), + .action(L("Settings..."), .settings), + .action(L("About CodexBar"), .about), + .action(L("Quit"), .quit), ]) return Section(entries: entries) } @@ -626,11 +626,11 @@ struct MenuDescriptor { snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) { if provider == .factory, snapshot.tertiary != nil { - return ("5-hour", "Weekly", "Monthly", true) + return ("5-hour", L("Weekly"), "Monthly", true) } return ( - metadata.sessionLabel, - metadata.weeklyLabel, + L(metadata.sessionLabel), + L(metadata.weeklyLabel), metadata.opusLabel ?? "Sonnet", metadata.supportsOpus) } diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 3556058bd..6d7bd1ee7 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -608,9 +608,9 @@ struct PlanUtilizationHistoryChartMenuView: View { { switch name { case .session: - metadata?.sessionLabel ?? "Session" + L(metadata?.sessionLabel ?? "Session") case .weekly: - metadata?.weeklyLabel ?? "Weekly" + L(metadata?.weeklyLabel ?? "Weekly") case .opus: metadata?.opusLabel ?? "Opus" default: @@ -640,9 +640,9 @@ struct PlanUtilizationHistoryChartMenuView: View { private nonisolated static func emptyStateText(title: String?) -> String { if let title { - return "No \(title.lowercased()) utilization data yet." + return String(format: L("No %@ utilization data yet."), title.lowercased()) } - return "No utilization data yet." + return L("No utilization data yet.") } #if DEBUG @@ -809,16 +809,14 @@ extension PlanUtilizationHistoryChartMenuView { return "\(dateLabel): -" } let usedText = used.formatted(.number.precision(.fractionLength(0...1))) - return "\(dateLabel): \(usedText)% used" + return L("%@: %@%% used", dateLabel, usedText) } private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String { let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.locale = Locale.current formatter.timeZone = TimeZone.current - formatter.amSymbol = "am" - formatter.pmSymbol = "pm" - formatter.dateFormat = "MMM d, h:mm a" + formatter.setLocalizedDateFormatFromTemplate("MMM d, h:mm a") return formatter.string(from: date) } } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 19f3edb4b..7df611b2c 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -635,3 +635,145 @@ /* Cost estimation */ "cost_header_estimated" = "Cost (estimated)"; "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Missing DeepSeek API key."; +"%@ is unavailable in the current environment." = "%@ is unavailable in the current environment."; +"All Systems Operational" = "All Systems Operational"; +"Last 30 days" = "Last 30 days"; +"Last 30 days:" = "Last 30 days:"; +"This month" = "This month"; +"Store multiple OpenAI API keys." = "Store multiple OpenAI API keys."; +"Admin API key" = "Admin API key"; +"Open billing" = "Open billing"; +"Google accounts" = "Google accounts"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Store multiple Antigravity Google OAuth accounts for quick switching."; +"Add Google Account" = "Add Google Account"; +"Open Token Plan" = "Open Token Plan"; +"Text Generation" = "Text Generation"; +"Text to Speech" = "Text to Speech"; +"Music Generation" = "Music Generation"; +"Image Generation" = "Image Generation"; +"No local data found" = "No local data found"; +"Credits unavailable; keep Codex running to refresh." = "Credits unavailable; keep Codex running to refresh."; +"No available fetch strategy for minimax." = "No available fetch strategy for minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)."; +"No OpenCode session cookies found in browsers." = "No OpenCode session cookies found in browsers."; +"No available fetch strategy for %@." = "No available fetch strategy for %@."; +"Today" = "Today"; +"30d cost" = "30d cost"; +"30d tokens" = "30d tokens"; +"Latest tokens" = "Latest tokens"; +"Top model" = "Top model"; +"Storage" = "Storage"; +"Add Account..." = "Add Account..."; +"Usage Dashboard" = "Usage Dashboard"; +"Status Page" = "Status Page"; +"Settings..." = "Settings..."; +"About CodexBar" = "About CodexBar"; +"Quit" = "Quit"; +"Last %d days" = "Last %d days"; +"Resets in %@" = "Resets in %@"; +"Lasts until reset" = "Lasts until reset"; +"Projected empty in %@" = "Projected empty in %@"; +"Runs out in %@" = "Runs out in %@"; +"%d%% in deficit" = "%d%% in deficit"; +"%d%% in reserve" = "%d%% in reserve"; +"usage_percent_suffix_left" = "left"; +"usage_percent_suffix_used" = "used"; +"Store multiple DeepSeek API keys." = "Store multiple DeepSeek API keys."; +"This week" = "This week"; +"Week" = "Week"; +"Month" = "Month"; +"Models" = "Models"; +"24h tokens" = "24h tokens"; +"Latest hour" = "Latest hour"; +"Peak hour" = "Peak hour"; +"Top method" = "Top method"; +"30d cash" = "30d cash"; +"30d billing history from MiniMax web session" = "30d billing history from MiniMax web session"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer billing can lag."; +"Rate limit: %d / %@" = "Rate limit: %d / %@"; +"Key remaining" = "Key remaining"; +"No limit set for the API key" = "No limit set for the API key"; +"API key limit unavailable right now" = "API key limit unavailable right now"; +"This month: %@ tokens" = "This month: %@ tokens"; +"No utilization data yet." = "No utilization data yet."; +"No %@ utilization data yet." = "No %@ utilization data yet."; +"%@: %@%% used" = "%@: %@%% used"; +"%dd" = "%dd"; + +"Today: %@ · %@ tokens" = "Today: %@ · %@ tokens"; +"Today: %@" = "Today: %@"; +"Today: %@ tokens" = "Today: %@ tokens"; +"Last 30 days: %@ · %@ tokens" = "Last 30 days: %@ · %@ tokens"; +"Last 30 days: %@" = "Last 30 days: %@"; +"Est. total (30d): %@" = "Est. total (30d): %@"; +"Est. total (%@): %@" = "Est. total (%@): %@"; +"Hover a bar for details" = "Hover a bar for details"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"No providers selected for Overview." = "No providers selected for Overview."; +"No overview data available." = "No overview data available."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto uses the local IDE API first, then Google OAuth when the IDE is closed."; +"Login with Google" = "Login with Google"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Add accounts via GitHub OAuth Device Flow on the selected host."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override."; +"Manual cleanup: past sessions" = "Manual cleanup: past sessions"; +"Clearing removes past resume, continue, and rewind history." = "Clearing removes past resume, continue, and rewind history."; +"Manual cleanup: file checkpoints" = "Manual cleanup: file checkpoints"; +"Clearing removes checkpoint restore data for previous edits." = "Clearing removes checkpoint restore data for previous edits."; +"Manual cleanup: saved plans" = "Manual cleanup: saved plans"; +"Clearing removes old plan-mode files." = "Clearing removes old plan-mode files."; +"Manual cleanup: debug logs" = "Manual cleanup: debug logs"; +"Clearing removes past debug logs." = "Clearing removes past debug logs."; +"Manual cleanup: attachment cache" = "Manual cleanup: attachment cache"; +"Clearing removes cached large pastes or attached images." = "Clearing removes cached large pastes or attached images."; +"Manual cleanup: session metadata" = "Manual cleanup: session metadata"; +"Clearing removes per-session environment metadata." = "Clearing removes per-session environment metadata."; +"Manual cleanup: shell snapshots" = "Manual cleanup: shell snapshots"; +"Clearing removes leftover runtime shell snapshot files." = "Clearing removes leftover runtime shell snapshot files."; +"Manual cleanup: legacy todos" = "Manual cleanup: legacy todos"; +"Clearing removes legacy per-session task lists." = "Clearing removes legacy per-session task lists."; +"Manual cleanup: sessions" = "Manual cleanup: sessions"; +"Clearing removes past Codex session history." = "Clearing removes past Codex session history."; +"Manual cleanup: archived sessions" = "Manual cleanup: archived sessions"; +"Clearing removes archived Codex session history." = "Clearing removes archived Codex session history."; +"Manual cleanup: cache" = "Manual cleanup: cache"; +"Clearing removes provider-owned cached data." = "Clearing removes provider-owned cached data."; +"Manual cleanup: logs" = "Manual cleanup: logs"; +"Clearing removes local diagnostic logs." = "Clearing removes local diagnostic logs."; +"Manual cleanup: file history" = "Manual cleanup: file history"; +"Clearing removes local edit checkpoint history." = "Clearing removes local edit checkpoint history."; +"Manual cleanup: temporary data" = "Manual cleanup: temporary data"; +"Clearing removes local temporary provider data." = "Clearing removes local temporary provider data."; +"Total: %@" = "Total: %@"; +"%d more items" = "%d more items"; +"Cleanup ideas" = "Cleanup ideas"; +"%d unreadable item(s) skipped" = "%d unreadable item(s) skipped"; + +"API key limit" = "API key limit"; +"Auth" = "Auth"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Disabled — no recent data"; +"Limits not available" = "Limits not available"; +"No usage yet" = "No usage yet"; +"Not fetched yet" = "Not fetched yet"; +"Refreshing" = "Refreshing"; +"Session" = "Session"; +"Source" = "Source"; +"State" = "State"; +"Unavailable" = "Unavailable"; +"Weekly" = "Weekly"; +"not detected" = "not detected"; +"Estimated from local Codex logs for the selected account." = "Estimated from local Codex logs for the selected account."; +"minimax_usage_amount_format" = "Usage: %@ / %@"; +"minimax_used_percent_format" = "Used %@"; +"minimax_service_text_generation" = "Text Generation"; +"minimax_service_text_to_speech" = "Text to Speech"; +"minimax_service_music_generation" = "Music Generation"; +"minimax_service_image_generation" = "Image Generation"; +"minimax_service_lyrics_generation" = "Lyrics generation"; +"minimax_service_coding_plan_vlm" = "Coding plan VLM"; +"minimax_service_coding_plan_search" = "Coding plan search"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index f04a7919b..184587b53 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -7,8 +7,8 @@ "API key" = "API 密钥"; "API key limit" = "API 密钥限制"; "API region" = "API 区域"; -"API token" = "API 词元"; -"API tokens" = "API 词元"; +"API token" = "API 令牌"; +"API tokens" = "API 令牌"; "About" = "关于"; "Account" = "账户"; "Accounts" = "账户"; @@ -50,7 +50,7 @@ "Bordered" = "带边框"; "Build" = "构建"; "Built \\(buildTimestamp)" = "构建于 \\(buildTimestamp)"; -"Buy Credits..." = "购买额度..."; +"Buy Credits..." = "购买额度…"; "Buy Credits…" = "购买额度…"; "CLI paths" = "CLI 路径"; "CLI sessions" = "CLI 会话"; @@ -101,7 +101,7 @@ "Could not start codex login" = "无法启动 codex login"; "Could not switch system account" = "无法切换系统账户"; "Credits" = "额度"; -"Credits history" = "额度历史"; +"Credits history" = "额度记录"; "Cursor login failed" = "Cursor 登录失败"; "Custom" = "自定义"; "Custom Path" = "自定义路径"; @@ -171,7 +171,7 @@ "Managed Codex accounts unavailable" = "托管 Codex 账户不可用"; "Managed account storage is unreadable. Live account access is still available, " = "托管账户存储不可读。实时账户访问仍可用,"; "Manual" = "手动"; -"May your tokens never run out—keep agent limits in view." = "愿你的词元永不耗尽——随时关注智能体限制。"; +"May your tokens never run out—keep agent limits in view." = "愿你的 token 永不耗尽——随时关注智能体限制。"; "Menu bar" = "菜单栏"; "Menu bar auto-shows the provider closest to its rate limit." = "菜单栏会自动显示最接近速率限制的提供商。"; "Menu bar metric" = "菜单栏指标"; @@ -185,12 +185,12 @@ "No cost history data." = "暂无费用历史数据。"; "No usage yet" = "尚无用量"; "Not fetched yet" = "尚未获取"; -"No credits history data." = "暂无额度历史数据。"; +"No credits history data." = "暂无额度记录。"; "No data available" = "无可用数据"; "No data yet" = "暂无数据"; "No enabled providers available for Overview." = "“概览”中没有可用的已启用提供商。"; "No providers selected" = "未选择提供商"; -"No token accounts yet." = "尚无词元账户。"; +"No token accounts yet." = "尚无令牌账户。"; "No usage breakdown data." = "暂无用量明细数据。"; "None" = "无"; "Notifications" = "通知"; @@ -320,7 +320,7 @@ "The default Codex account on this Mac." = "此 Mac 上的默认 Codex 账户。"; "Toggle" = "切换"; "Toggle subtitle" = "切换副标题"; -"Token" = "词元"; +"Token" = "token"; "Trigger the menu bar menu from anywhere." = "从任意位置触发菜单栏菜单。"; "True" = "真"; "Twitter" = "Twitter"; @@ -346,7 +346,7 @@ "Website" = "网站"; "Weekly" = "每周"; "Weekly limit confetti" = "每周限制彩纸"; -"Weekly token limit" = "每周词元限制"; +"Weekly token limit" = "每周 token 限制"; "Weekly usage" = "每周用量"; "Weekly usage unavailable for this account." = "此账户的每周用量不可用。"; "Window: \\(window)" = "窗口:\\(window)"; @@ -522,7 +522,7 @@ "keychain_access_caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入不可用;请在“提供商”中手动粘贴 Cookie 标头。"; "disable_keychain_access_title" = "禁用钥匙串访问"; "disable_keychain_access_subtitle" = "启用时阻止任何钥匙串访问。"; -"about_tagline" = "愿你的词元永不耗尽——随时关注智能体限制。"; +"about_tagline" = "愿你的 token 永不耗尽——随时关注智能体限制。"; "link_github" = "GitHub"; "link_website" = "网站"; "link_twitter" = "Twitter"; @@ -618,6 +618,134 @@ "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能会在“系统设置”→“菜单栏”→“允许显示在菜单栏”中阻止菜单栏应用。CodexBar 正在运行,但 macOS 可能隐藏了它的图标。请打开菜单栏设置并启用 CodexBar。"; "cost_header_estimated" = "费用(估算)"; "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; +"Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "未找到 z.ai API 令牌。请在 ~/.codexbar/config.json 中设置 apiKey,或设置 Z_AI_API_KEY。"; +"Missing DeepSeek API key." = "缺少 DeepSeek API 密钥。"; +"%@ is unavailable in the current environment." = "%@ 在当前环境不可用。"; +"All Systems Operational" = "系统全部正常"; +"Last 30 days" = "近 30 天"; +"Last 30 days:" = "近 30 天:"; +"This month" = "本月"; +"Store multiple OpenAI API keys." = "存储多个 OpenAI API 密钥。"; +"Admin API key" = "管理员 API 密钥"; +"Open billing" = "打开账单"; +"Google accounts" = "Google 账户"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "存储多个 Antigravity Google OAuth 账户以便快速切换。"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "保存每个已登录的 Google 账户,便于快速切换 Antigravity。优先使用 Antigravity.app OAuth;也可使用 ANTIGRAVITY_OAUTH_CLIENT_ID 和 ANTIGRAVITY_OAUTH_CLIENT_SECRET 作为覆盖。"; +"Add Google Account" = "添加 Google 账户"; +"Open Token Plan" = "打开 Token 套餐页"; +"Text Generation" = "文本生成"; +"Text to Speech" = "文本转语音"; +"Music Generation" = "音乐生成"; +"Image Generation" = "图像生成"; +"No local data found" = "未找到本地数据"; +"Credits unavailable; keep Codex running to refresh." = "额度暂不可用;请保持 Codex 运行后再刷新。"; +"No available fetch strategy for minimax." = "没有可用的 MiniMax 抓取策略。"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "未找到 Cursor 会话。请在 Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX 或 Edge Canary 中登录 cursor.com。若使用 Safari,请在“系统设置 ▸ 隐私与安全性”中授予 CodexBar“完全磁盘访问权限”。你也可以在 CodexBar 菜单中登录 Cursor(添加/切换账户)。"; +"No OpenCode session cookies found in browsers." = "在浏览器中未找到 OpenCode 会话 Cookie。"; +"No available fetch strategy for %@." = "%@ 暂无可用的获取策略。"; +"Today" = "今日"; +"30d cost" = "近 30 天费用"; +"30d tokens" = "近 30 天 token 用量"; +"Latest tokens" = "最近 token 用量"; +"Top model" = "最常用模型"; +"Storage" = "存储空间"; +"Add Account..." = "添加账户…"; +"Usage Dashboard" = "用量仪表盘"; +"Status Page" = "状态页"; +"Settings..." = "设置…"; +"About CodexBar" = "关于 CodexBar"; +"Quit" = "退出"; +"Last %d days" = "近 %d 天"; +"Resets in %@" = "%@后重置"; +"Lasts until reset" = "可用至重置"; +"Projected empty in %@" = "预计 %@ 后用尽"; +"Runs out in %@" = "预计 %@ 后用尽"; +"%d%% in deficit" = "超额 %d%%"; +"%d%% in reserve" = "余量 %d%%"; +"usage_percent_suffix_left" = "剩余"; +"usage_percent_suffix_used" = "已使用"; +"Store multiple DeepSeek API keys." = "存储多个 DeepSeek API 密钥。"; +"This week" = "本周"; +"Week" = "本周"; +"Month" = "本月"; +"Models" = "模型数"; +"24h tokens" = "24 小时 token 用量"; +"Latest hour" = "最近 1 小时"; +"Peak hour" = "峰值小时"; +"Top method" = "主要方法"; +"30d cash" = "近 30 天费用"; +"30d billing history from MiniMax web session" = "来自 MiniMax Web 会话的近 30 天账单历史"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer 账单数据可能延迟。"; +"Rate limit: %d / %@" = "速率限制:%d / %@"; +"Key remaining" = "密钥剩余额度"; +"No limit set for the API key" = "该 API 密钥未设置上限"; +"API key limit unavailable right now" = "当前无法获取 API 密钥上限"; +"This month: %@ tokens" = "本月:%@ token"; +"No utilization data yet." = "暂无使用率数据。"; +"No %@ utilization data yet." = "暂无 %@ 使用率数据。"; +"%@: %@%% used" = "%@:已用 %@%%"; +"%dd" = "%d 天"; +"Today: %@ · %@ tokens" = "今日:%@ · %@ token"; +"Today: %@" = "今日:%@"; +"Today: %@ tokens" = "今日:%@ token"; +"Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ token"; +"Last 30 days: %@" = "近 30 天:%@"; +"Est. total (30d): %@" = "近 30 天估算总计:%@"; +"Est. total (%@): %@" = "近 30 天估算总计:%@"; +"Hover a bar for details" = "悬停在柱形图上查看详情"; +"%@: %@ · %@ tokens" = "%@:%@ · %@ token"; +"No providers selected for Overview." = "概览中尚未选择提供商。"; +"No overview data available." = "概览暂无可用数据。"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自动模式会优先使用本地 IDE API;当 IDE 关闭时再使用 Google OAuth。"; +"Login with Google" = "使用 Google 登录"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "通过所选主机的 GitHub OAuth 设备流程添加账户。"; +"Manual cleanup: past sessions" = "手动清理:历史会话"; +"Clearing removes past resume, continue, and rewind history." = "清理后将移除历史恢复、继续和回退记录。"; +"Manual cleanup: file checkpoints" = "手动清理:文件检查点"; +"Clearing removes checkpoint restore data for previous edits." = "清理后将移除以往编辑的检查点恢复数据。"; +"Manual cleanup: saved plans" = "手动清理:已保存计划"; +"Clearing removes old plan-mode files." = "清理后将移除旧的计划模式文件。"; +"Manual cleanup: debug logs" = "手动清理:调试日志"; +"Clearing removes past debug logs." = "清理后会移除历史调试日志。"; +"Manual cleanup: attachment cache" = "手动清理:附件缓存"; +"Clearing removes cached large pastes or attached images." = "清理后会移除缓存的大段粘贴内容或附件图片。"; +"Manual cleanup: session metadata" = "手动清理:会话元数据"; +"Clearing removes per-session environment metadata." = "清理后会移除每个会话的环境元数据。"; +"Manual cleanup: shell snapshots" = "手动清理:Shell 快照"; +"Clearing removes leftover runtime shell snapshot files." = "清理后会移除遗留的运行时 Shell 快照文件。"; +"Manual cleanup: legacy todos" = "手动清理:旧版待办"; +"Clearing removes legacy per-session task lists." = "清理后会移除旧版的每会话任务列表。"; +"Manual cleanup: sessions" = "手动清理:会话"; +"Clearing removes past Codex session history." = "清理后将移除历史 Codex 会话记录。"; +"Manual cleanup: archived sessions" = "手动清理:归档会话"; +"Clearing removes archived Codex session history." = "清理后会移除已归档的 Codex 会话记录。"; +"Manual cleanup: cache" = "手动清理:缓存"; +"Clearing removes provider-owned cached data." = "清理后将移除提供商缓存数据。"; +"Manual cleanup: logs" = "手动清理:日志"; +"Clearing removes local diagnostic logs." = "清理后将移除本地诊断日志。"; +"Manual cleanup: file history" = "手动清理:文件历史"; +"Clearing removes local edit checkpoint history." = "清理后会移除本地编辑检查点历史。"; +"Manual cleanup: temporary data" = "手动清理:临时数据"; +"Clearing removes local temporary provider data." = "清理后会移除本地临时提供商数据。"; +"Total: %@" = "总计:%@"; +"%d more items" = "另有 %d 项"; +"Cleanup ideas" = "清理建议"; +"%d unreadable item(s) skipped" = "已跳过 %d 个不可读项目"; +"weekly_progress_work_days_title" = "工作日刻度线"; +"weekly_progress_work_days_subtitle" = "在每周用量条上显示按天分隔的刻度线。"; "copilot_device_code" = "设备代码已复制到剪贴板:%1$@\n\n请在以下地址验证:%2$@"; "copilot_waiting_text" = "请在浏览器中完成登录。\n登录完成后,此窗口会自动关闭。"; "vertex_ai_login_instructions" = "要跟踪 Vertex AI 用量,请通过 Google Cloud 进行认证。\n\n1. 打开终端\n2. 运行:gcloud auth application-default login\n3. 按照浏览器提示登录\n4. 设置你的项目:gcloud config set project PROJECT_ID\n\n是否现在打开终端?"; +"minimax_usage_amount_format" = "用量:%@ / %@"; +"minimax_used_percent_format" = "已使用 %@"; +"minimax_service_text_generation" = "文本生成"; +"minimax_service_text_to_speech" = "文本转语音"; +"minimax_service_music_generation" = "音乐生成"; +"minimax_service_image_generation" = "图像生成"; +"minimax_service_lyrics_generation" = "歌词生成"; +"minimax_service_coding_plan_vlm" = "视觉编码计划"; +"minimax_service_coding_plan_search" = "搜索编码计划"; diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 90bcaed76..29e7d5e46 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -1,7 +1,7 @@ import AppKit extension StatusItemController { - static let costMenuTitle = "Cost" + static let costMenuTitle = L("Cost") func makeCostMenuCardItem(model: UsageMenuCardView.Model, submenu: NSMenu?) -> NSMenuItem { let tooltipLines = Self.costMenuTooltipLines(tokenUsage: model.tokenUsage) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 511d1b913..17f16df03 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -101,7 +101,7 @@ extension StatusItemController { guard !didHydrate else { return } - let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") + let unavailableItem = NSMenuItem(title: L("No data available"), action: nil, keyEquivalent: "") unavailableItem.isEnabled = false unavailableItem.representedObject = chartID unavailableItem.toolTip = placeholder.toolTip diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0b57fe210..630c3e733 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -538,7 +538,9 @@ extension StatusItemController { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders) - let message = resolvedProviders.isEmpty ? "No providers selected for Overview." : "No overview data available." + let message = resolvedProviders.isEmpty + ? L("No providers selected for Overview.") + : L("No overview data available.") let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") item.isEnabled = false item.representedObject = "overviewEmptyState" @@ -1420,7 +1422,10 @@ extension StatusItemController { } private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem( + title: L("Buy Credits..."), + action: #selector(self.openCreditsPurchase), + keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -1434,7 +1439,7 @@ extension StatusItemController { private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu(width: self.renderedMenuWidth(for: menu)) else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: L("Credits history"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1445,7 +1450,7 @@ extension StatusItemController { private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu(width: self.renderedMenuWidth(for: menu)) else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: L("Usage breakdown"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1489,7 +1494,7 @@ extension StatusItemController { let submenu = NSMenu() submenu.delegate = self - let titleItem = NSMenuItem(title: "MCP details", action: nil, keyEquivalent: "") + let titleItem = NSMenuItem(title: L("MCP details"), action: nil, keyEquivalent: "") titleItem.isEnabled = false submenu.addItem(titleItem) diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 5e9cc13a4..7c0f3b1b2 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -34,7 +34,7 @@ struct OverviewMenuCardRowView: View { } if let storageText { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("Storage:") + Text("\(L("Storage")):") .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) Text(storageText) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 2db684d1c..e025ce185 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -79,7 +79,7 @@ final class ProviderSwitcherView: NSView { Segment( selection: .overview, image: overviewIcon, - title: "Overview"), + title: L("Overview")), at: 0) } self.segments = segments diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index dbdbfda72..cd715a410 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -10,7 +10,7 @@ struct StorageMenuCardSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Storage") + Text(L("Storage")) .font(.body) .fontWeight(.medium) Text(self.storageText) @@ -67,16 +67,16 @@ struct StorageBreakdownMenuView: View { private var content: some View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 3) { - Text("Storage") + Text(L("Storage")) .font(.body) .fontWeight(.medium) - Text("Total: \(UsageFormatter.byteCountString(self.footprint.totalBytes))") + Text(String(format: L("Total: %@"), UsageFormatter.byteCountString(self.footprint.totalBytes))) .font(.caption) .foregroundStyle(.secondary) } if self.visibleComponents.isEmpty { - Text("No local data found") + Text(L("No local data found")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -88,7 +88,7 @@ struct StorageBreakdownMenuView: View { } if self.footprint.components.count > self.visibleComponents.count { - Text("\(self.footprint.components.count - self.visibleComponents.count) more items") + Text(String(format: L("%d more items"), self.footprint.components.count - self.visibleComponents.count)) .font(.caption) .foregroundStyle(.secondary) } @@ -96,7 +96,7 @@ struct StorageBreakdownMenuView: View { Divider() .padding(.vertical, 2) VStack(alignment: .leading, spacing: 8) { - Text("Cleanup ideas") + Text(L("Cleanup ideas")) .font(.body) .fontWeight(.medium) ForEach(self.cleanupRecommendations) { recommendation in @@ -105,7 +105,7 @@ struct StorageBreakdownMenuView: View { } } if !self.footprint.unreadablePaths.isEmpty { - Text("\(self.footprint.unreadablePaths.count) unreadable item(s) skipped") + Text(String(format: L("%d unreadable item(s) skipped"), self.footprint.unreadablePaths.count)) .font(.caption) .foregroundStyle(.secondary) } @@ -148,7 +148,7 @@ struct StorageBreakdownMenuView: View { private func recommendationRow(_ recommendation: ProviderStorageRecommendation) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { - Text(recommendation.title) + Text(L(recommendation.title)) .font(.caption) .fontWeight(.medium) .lineLimit(1) @@ -169,7 +169,7 @@ struct StorageBreakdownMenuView: View { Spacer() StoragePathCopyButton(path: recommendation.path) } - Text(recommendation.consequence) + Text(L(recommendation.consequence)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(3) diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ad53befa3..25a97c4bc 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -31,10 +31,10 @@ struct UsageBreakdownChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No usage breakdown data.") + Text(L("No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) - .accessibilityLabel("No usage breakdown data available.") + .accessibilityLabel(L("No usage breakdown data available.")) } else { Chart { ForEach(model.points) { point in @@ -368,7 +368,7 @@ struct UsageBreakdownChartMenuView: View { let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (L("Hover a bar for details"), nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index fd245824d..ee1242e81 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -17,9 +17,9 @@ enum UsagePaceText { static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(pace: pace, now: now) if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return L("Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return L("Pace: %@", detail.leftLabel) } static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { @@ -34,31 +34,34 @@ enum UsagePaceText { let deltaValue = Int(abs(pace.deltaPercent).rounded()) switch pace.stage { case .onTrack: - return "On pace" + return L("On pace") case .slightlyAhead, .ahead, .farAhead: - return "\(deltaValue)% in deficit" + return L("%d%% in deficit", deltaValue) case .slightlyBehind, .behind, .farBehind: - return "\(deltaValue)% in reserve" + return L("%d%% in reserve", deltaValue) } } private static func detailRightLabel(for pace: UsagePace, context: DetailContext, now: Date) -> String? { let etaLabel: String? if pace.willLastToReset { - etaLabel = "Lasts until reset" + etaLabel = L("Lasts until reset") } else if let etaSeconds = pace.etaSeconds { let etaText = Self.durationText(seconds: etaSeconds, now: now) - let prefix = context == .session ? "Projected empty" : "Runs out" - etaLabel = etaText == "now" ? "\(prefix) now" : "\(prefix) in \(etaText)" + if context == .session { + etaLabel = etaText == "now" ? L("Projected empty now") : L("Projected empty in %@", etaText) + } else { + etaLabel = etaText == "now" ? L("Runs out now") : L("Runs out in %@", etaText) + } } else { etaLabel = nil } guard let runOutProbability = pace.runOutProbability else { return etaLabel } let roundedRisk = self.roundedRiskPercent(runOutProbability) - let riskLabel = "≈ \(roundedRisk)% run-out risk" + let riskLabel = L("≈ %d%% run-out risk", roundedRisk) if let etaLabel { - return "\(etaLabel) · \(riskLabel)" + return L("%@ · %@", etaLabel, riskLabel) } return riskLabel } @@ -97,8 +100,8 @@ enum UsagePaceText { static func sessionSummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { guard let detail = sessionDetail(provider: provider, window: window, now: now) else { return nil } if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return L("Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return L("Pace: %@", detail.leftLabel) } } diff --git a/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift b/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift index 47bf08f13..4e1ec437c 100644 --- a/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift +++ b/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift @@ -61,7 +61,7 @@ struct ZaiHourlyUsageChartMenuView: View { }) .buttonStyle(.plain) - Text("Hourly Tokens") + Text(L("Hourly Tokens")) .font(.system(size: 11, weight: .semibold)) .foregroundColor(.primary) @@ -75,7 +75,7 @@ struct ZaiHourlyUsageChartMenuView: View { if self.isExpanded { VStack(alignment: .leading, spacing: 4) { if self.bars.isEmpty { - Text("No data") + Text(L("No data")) .font(.system(size: 10)) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) @@ -131,7 +131,7 @@ struct ZaiHourlyUsageChartMenuView: View { get: { self.selectedRange.rawValue }, set: { self.selectedRange = RangeOption(rawValue: $0) ?? .today })) { - Text("Today").tag(RangeOption.today.rawValue) + Text(L("Today")).tag(RangeOption.today.rawValue) Text("24h").tag(RangeOption.last24h.rawValue) } .pickerStyle(.segmented) diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index a15437773..e2caf2973 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -6,6 +6,15 @@ public enum ResetTimeDisplayStyle: String, Codable, Sendable { } public enum UsageFormatter { + private static func localized(_ key: String) -> String { + NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") + } + + private static func localized(_ key: String, _ args: CVarArg...) -> String { + let format = self.localized(key) + return String(format: format, locale: Locale.current, arguments: args) + } + public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining let clamped = min(100, max(0, percent)) @@ -53,17 +62,30 @@ public enum UsageFormatter { now: Date = .init()) -> String? { if let date = window.resetsAt { - let text = style == .countdown - ? self.resetCountdownDescription(from: date, now: now) - : self.resetDescription(from: date, now: now) - return "Resets \(text)" + if style == .countdown { + let countdown = self.resetCountdownDescription(from: date, now: now) + if countdown == "now" { + return self.localized("Resets now") + } + if countdown.hasPrefix("in ") { + return self.localized("Resets in %@", String(countdown.dropFirst(3))) + } + return self.localized("Resets %@", countdown) + } + let text = self.resetDescription(from: date, now: now) + return self.localized("Resets %@", text) } if let desc = window.resetDescription { let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if trimmed.lowercased().hasPrefix("resets") { return trimmed } - return "Resets \(trimmed)" + if trimmed.lowercased().hasPrefix("resets in ") { + return self.localized("Resets in %@", String(trimmed.dropFirst("Resets in ".count))) + } + if trimmed.lowercased().hasPrefix("resets ") { + return self.localized("Resets %@", String(trimmed.dropFirst("Resets ".count))) + } + return self.localized("Resets %@", trimmed) } return nil } @@ -71,25 +93,25 @@ public enum UsageFormatter { public static func updatedString(from date: Date, now: Date = .init()) -> String { let delta = now.timeIntervalSince(date) if abs(delta) < 60 { - return "Updated just now" + return self.localized("Updated just now") } if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() - rel.locale = Locale(identifier: "en_US") + rel.locale = Locale.current rel.unitsStyle = .abbreviated - return "Updated \(rel.localizedString(for: date, relativeTo: now))" + return self.localized("Updated %@", rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { let minutes = max(1, seconds / 60) - return "Updated \(minutes)m ago" + return self.localized("Updated %@m ago", String(minutes)) } let wholeHours = max(1, seconds / 3600) - return "Updated \(wholeHours)h ago" + return self.localized("Updated %@h ago", String(wholeHours)) #endif } else { - return "Updated \(date.formatted(date: .omitted, time: .shortened))" + return self.localized("Updated %@", date.formatted(date: .omitted, time: .shortened)) } } @@ -100,7 +122,7 @@ public enum UsageFormatter { // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return "\(formatted) left" + return self.localized("%@ left", formatted) } public static func kiroCreditNumber(_ value: Double) -> String { From fb34f605dbac9cd41abbe48f5649731e2a83122c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 25 May 2026 22:51:37 +0800 Subject: [PATCH 2/8] Fix cost labels and localization lookup --- Sources/CodexBar/CodexbarApp.swift | 1 + Sources/CodexBar/Localization.swift | 8 ++++ Sources/CodexBar/MenuCardView+Costs.swift | 22 +++++----- .../Resources/en.lproj/Localizable.strings | 3 ++ .../zh-Hans.lproj/Localizable.strings | 3 ++ Sources/CodexBarCore/UsageFormatter.swift | 40 ++++++++++++++++++- .../CodexBarTests/BedrockMenuCardTests.swift | 4 +- Tests/CodexBarTests/UsageFormatterTests.swift | 21 ++++++++-- 8 files changed, 82 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 5048744b5..dc5dd28ed 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -44,6 +44,7 @@ struct CodexBarApp: App { let preferencesSelection = PreferencesSelection() let settings = SettingsStore() Self.applyLanguagePreference(from: settings) + configureUsageFormatterLocalizationProvider() let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() managedCodexAccountCoordinator.onManagedAccountsDidChange = { _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 22530632a..baf10f35a 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Foundation private func appLanguageDefaults() -> UserDefaults { @@ -110,3 +111,10 @@ func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bund let fallback = englishBundle.localizedString(forKey: key, value: nil, table: nil) return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback } + +func configureUsageFormatterLocalizationProvider() { + UsageFormatter.setLocalizationProvider { key in + let resourceBundle = codexBarLocalizationResourceBundle() + return codexBarLocalizedString(key, bundle: localizedBundle(), resourceBundle: resourceBundle) + } +} diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 1553c8c60..4034bb2a6 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -31,28 +31,24 @@ extension UsageMenuCardView.Model { let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } + let sessionLabel = provider == .bedrock ? Self.bedrockLatestBillingDayLabel(from: snapshot) : L("Today") let sessionLine: String = { - if provider == .bedrock { - if let sessionTokens { - return String(format: L("Today: %@ · %@ tokens"), sessionCost, sessionTokens) - } - return String(format: L("Today: %@"), sessionCost) - } if let sessionTokens { - return String(format: L("Today: %@ · %@ tokens"), sessionCost, sessionTokens) + return String(format: L("%@: %@ · %@ tokens"), sessionLabel, sessionCost, sessionTokens) } - return String(format: L("Today: %@"), sessionCost) + return "\(sessionLabel): \(sessionCost)" }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } + let windowLabel = Self.costHistoryWindowLabel(days: snapshot.historyDays) let monthLine: String = { if let monthTokens { - return String(format: L("Last 30 days: %@ · %@ tokens"), monthCost, monthTokens) + return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens) } - return String(format: L("Last 30 days: %@"), monthCost) + return "\(windowLabel): \(monthCost)" }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -79,14 +75,14 @@ extension UsageMenuCardView.Model { } static func costHistoryWindowLabel(days: Int) -> String { - days == 1 ? L("Today") : String(format: L("Last %d days"), days) + days == 1 ? String(format: L("Last %d day"), days) : String(format: L("Last %d days"), days) } private static func bedrockLatestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String { guard let entry = bedrockLatestBillingDay(from: snapshot.daily), let displayDate = bedrockDisplayDate(from: entry.date) - else { return "Latest billing day" } - return "Latest billing day (\(displayDate))" + else { return L("Latest billing day") } + return String(format: L("Latest billing day (%@)"), displayDate) } private static func bedrockLatestBillingDay(from entries: [CostUsageDailyReport.Entry]) diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 7df611b2c..032da1f77 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -673,7 +673,10 @@ "Settings..." = "Settings..."; "About CodexBar" = "About CodexBar"; "Quit" = "Quit"; +"Last %d day" = "Last %d day"; "Last %d days" = "Last %d days"; +"Latest billing day" = "Latest billing day"; +"Latest billing day (%@)" = "Latest billing day (%@)"; "Resets in %@" = "Resets in %@"; "Lasts until reset" = "Lasts until reset"; "Projected empty in %@" = "Projected empty in %@"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 184587b53..2ba52f2c2 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -658,7 +658,10 @@ "Settings..." = "设置…"; "About CodexBar" = "关于 CodexBar"; "Quit" = "退出"; +"Last %d day" = "近 %d 天"; "Last %d days" = "近 %d 天"; +"Latest billing day" = "最近结算日"; +"Latest billing day (%@)" = "最近结算日(%@)"; "Resets in %@" = "%@后重置"; "Lasts until reset" = "可用至重置"; "Projected empty in %@" = "预计 %@ 后用尽"; diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index e2caf2973..e76a7c09d 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -6,8 +6,42 @@ public enum ResetTimeDisplayStyle: String, Codable, Sendable { } public enum UsageFormatter { + private final class BundleToken {} + + private static let localizationLock = NSLock() + private nonisolated(unsafe) static var localizationProvider: (@Sendable (String) -> String)? + + public static func setLocalizationProvider(_ provider: @escaping @Sendable (String) -> String) { + self.localizationLock.lock() + self.localizationProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocalizationProvider() { + self.localizationLock.lock() + self.localizationProvider = nil + self.localizationLock.unlock() + } + private static func localized(_ key: String) -> String { - NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") + self.localizationLock.lock() + let provider = self.localizationProvider + self.localizationLock.unlock() + if let provider { + return provider(key) + } + let coreBundle = Bundle(for: BundleToken.self) + let coreValue = NSLocalizedString(key, tableName: "Localizable", bundle: coreBundle, value: key, comment: "") + if coreValue != key { return coreValue } + + let mainValue = NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") + if mainValue != key { return mainValue } + + switch key { + case "usage_percent_suffix_left": return "left" + case "usage_percent_suffix_used": return "used" + default: return key + } } private static func localized(_ key: String, _ args: CVarArg...) -> String { @@ -18,7 +52,9 @@ public enum UsageFormatter { public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining let clamped = min(100, max(0, percent)) - let suffix = showUsed ? "used" : "left" + let suffix = showUsed + ? self.localized("usage_percent_suffix_used") + : self.localized("usage_percent_suffix_left") return String(format: "%.0f%% %@", clamped, suffix) } diff --git a/Tests/CodexBarTests/BedrockMenuCardTests.swift b/Tests/CodexBarTests/BedrockMenuCardTests.swift index e3a4e8a5a..60ca9b9d9 100644 --- a/Tests/CodexBarTests/BedrockMenuCardTests.swift +++ b/Tests/CodexBarTests/BedrockMenuCardTests.swift @@ -13,6 +13,7 @@ struct BedrockMenuCardTests { sessionCostUSD: 12.34, last30DaysTokens: nil, last30DaysCostUSD: 56.78, + historyDays: 7, daily: [ CostUsageDailyReport.Entry( date: "2026-05-12", @@ -46,6 +47,7 @@ struct BedrockMenuCardTests { #expect(model.tokenUsage?.sessionLine == "Latest billing day (May 12): $12.34") #expect(model.tokenUsage?.sessionLine.contains("Today") == false) - #expect(model.tokenUsage?.hintLine == "Reported by AWS Cost Explorer; daily billing data can lag.") + #expect(model.tokenUsage?.monthLine == "Last 7 days: $56.78") + #expect(model.tokenUsage?.hintLine == "AWS Cost Explorer billing can lag.") } } diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 15cd68994..237102591 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -16,16 +16,29 @@ struct UsageFormatterTests { #expect(line == "75% used") } + @Test + func `usage line respects injected localization provider`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "usage_percent_suffix_left": "剩余" + case "usage_percent_suffix_used": "已使用" + default: key + } + } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "22% 剩余") + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "78% 已使用") + } + @Test func `relative updated recent`() { let now = Date() let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) let text = UsageFormatter.updatedString(from: fiveHoursAgo, now: now) - #expect(text.hasPrefix("Updated ")) - // Output must stay in English regardless of the host system locale, - // matching the surrounding hardcoded English UI labels. + #expect(text.hasPrefix("Updated ") || text.hasPrefix("更新")) #expect(text.contains("5")) - #expect(text.lowercased().contains("ago")) + #expect(text.lowercased().contains("ago") || text.contains("前")) } @Test From c6aa3c4d178c7cb169bcac9d41f68ebfd8594f68 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 25 May 2026 23:30:00 +0800 Subject: [PATCH 3/8] Fix dynamic cost window labels and missing localization keys --- Sources/CodexBar/CostHistoryChartMenuView.swift | 5 +++-- Sources/CodexBar/MenuCardView+Costs.swift | 2 +- .../CodexBar/Resources/en.lproj/Localizable.strings | 11 +++++++++++ .../Resources/zh-Hans.lproj/Localizable.strings | 13 ++++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index b7d3f513d..3904bd303 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -172,7 +172,8 @@ struct CostHistoryChartMenuView: View { if let total = self.totalCostUSD { Text(String( - format: L("Est. total (30d): %@"), + format: L("Est. total (%@): %@"), + Self.windowLabel(days: self.historyDays), UsageFormatter.usdString(total))) .font(.caption) .foregroundStyle(.secondary) @@ -208,7 +209,7 @@ struct CostHistoryChartMenuView: View { private static let detailSpacing: CGFloat = 6 private static func windowLabel(days: Int) -> String { - days == 1 ? L("today") : String(format: L("%dd"), days) + String(format: L("%dd"), days) } private static func detailRowHeight(for row: DetailRow) -> CGFloat { diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 4034bb2a6..435f47665 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -64,7 +64,7 @@ extension UsageMenuCardView.Model { case .codex: L("Estimated from local Codex logs for the selected account.") case .claude: - L("cost_estimate_hint") + UsageFormatter.costEstimateHint(provider: provider) case .vertexai: L("cost_estimate_hint") case .bedrock: diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 032da1f77..c8775d142 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -706,6 +706,17 @@ "No %@ utilization data yet." = "No %@ utilization data yet."; "%@: %@%% used" = "%@: %@%% used"; "%dd" = "%dd"; +"today" = "today"; +"just now" = "just now"; +"On pace" = "On pace"; +"Runs out now" = "Runs out now"; +"Projected empty now" = "Projected empty now"; +"Switch Account..." = "Switch Account..."; +"Update ready, restart now?" = "Update ready, restart now?"; +"Daily" = "Daily"; +"Hourly Tokens" = "Hourly Tokens"; +"No data" = "No data"; +"No usage breakdown data available." = "No usage breakdown data available."; "Today: %@ · %@ tokens" = "Today: %@ · %@ tokens"; "Today: %@" = "Today: %@"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 2ba52f2c2..ddaa11374 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -691,13 +691,24 @@ "No %@ utilization data yet." = "暂无 %@ 使用率数据。"; "%@: %@%% used" = "%@:已用 %@%%"; "%dd" = "%d 天"; +"today" = "今天"; +"just now" = "刚刚"; +"On pace" = "节奏正常"; +"Runs out now" = "立即用尽"; +"Projected empty now" = "预计立即用尽"; +"Switch Account..." = "切换账户…"; +"Update ready, restart now?" = "更新已就绪,是否立即重启?"; +"Daily" = "每日"; +"Hourly Tokens" = "每小时 token 用量"; +"No data" = "暂无数据"; +"No usage breakdown data available." = "暂无可用用量明细数据。"; "Today: %@ · %@ tokens" = "今日:%@ · %@ token"; "Today: %@" = "今日:%@"; "Today: %@ tokens" = "今日:%@ token"; "Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ token"; "Last 30 days: %@" = "近 30 天:%@"; "Est. total (30d): %@" = "近 30 天估算总计:%@"; -"Est. total (%@): %@" = "近 30 天估算总计:%@"; +"Est. total (%@): %@" = "估算总计(%@):%@"; "Hover a bar for details" = "悬停在柱形图上查看详情"; "%@: %@ · %@ tokens" = "%@:%@ · %@ token"; "No providers selected for Overview." = "概览中尚未选择提供商。"; From 1dc9a79d49d71711d8366cbd1d642e4d158ddbfe Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 26 May 2026 00:55:53 +0800 Subject: [PATCH 4/8] Fix UsageFormatter localization resources and locale --- Sources/CodexBar/Localization.swift | 3 + .../PlanUtilizationHistoryChartMenuView.swift | 13 +++- .../Resources/en.lproj/Localizable.strings | 7 +++ .../zh-Hans.lproj/Localizable.strings | 7 +++ Sources/CodexBarCore/UsageFormatter.swift | 34 +++++++++-- Tests/CodexBarTests/UsageFormatterTests.swift | 59 +++++++++++++++++++ .../UsageStorePlanUtilizationTests.swift | 20 +++++++ 7 files changed, 135 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index baf10f35a..a02cce3e0 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -117,4 +117,7 @@ func configureUsageFormatterLocalizationProvider() { let resourceBundle = codexBarLocalizationResourceBundle() return codexBarLocalizedString(key, bundle: localizedBundle(), resourceBundle: resourceBundle) } + UsageFormatter.setLocaleProvider { + codexBarLocalizedLocale() + } } diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 6d7bd1ee7..169da498b 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -814,9 +814,18 @@ extension PlanUtilizationHistoryChartMenuView { private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = codexBarLocalizedLocale() formatter.timeZone = TimeZone.current formatter.setLocalizedDateFormatFromTemplate("MMM d, h:mm a") - return formatter.string(from: date) + var rendered = formatter.string(from: date).replacingOccurrences(of: "\u{202F}", with: " ") + let amSymbol = formatter.amSymbol ?? "" + let pmSymbol = formatter.pmSymbol ?? "" + if !amSymbol.isEmpty { + rendered = rendered.replacingOccurrences(of: amSymbol, with: amSymbol.lowercased()) + } + if !pmSymbol.isEmpty { + rendered = rendered.replacingOccurrences(of: pmSymbol, with: pmSymbol.lowercased()) + } + return rendered } } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index c8775d142..ea5391d2f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -677,8 +677,15 @@ "Last %d days" = "Last %d days"; "Latest billing day" = "Latest billing day"; "Latest billing day (%@)" = "Latest billing day (%@)"; +"%@ left" = "%@ left"; +"Resets %@" = "Resets %@"; "Resets in %@" = "Resets in %@"; +"Resets now" = "Resets now"; "Lasts until reset" = "Lasts until reset"; +"Updated %@" = "Updated %@"; +"Updated %@h ago" = "Updated %@h ago"; +"Updated %@m ago" = "Updated %@m ago"; +"Updated just now" = "Updated just now"; "Projected empty in %@" = "Projected empty in %@"; "Runs out in %@" = "Runs out in %@"; "%d%% in deficit" = "%d%% in deficit"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ddaa11374..4be3c3d0d 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -662,8 +662,15 @@ "Last %d days" = "近 %d 天"; "Latest billing day" = "最近结算日"; "Latest billing day (%@)" = "最近结算日(%@)"; +"%@ left" = "%@ 剩余"; +"Resets %@" = "重置于 %@"; "Resets in %@" = "%@后重置"; +"Resets now" = "立即重置"; "Lasts until reset" = "可用至重置"; +"Updated %@" = "更新于 %@"; +"Updated %@h ago" = "%@ 小时前更新"; +"Updated %@m ago" = "%@ 分钟前更新"; +"Updated just now" = "刚刚更新"; "Projected empty in %@" = "预计 %@ 后用尽"; "Runs out in %@" = "预计 %@ 后用尽"; "%d%% in deficit" = "超额 %d%%"; diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index e76a7c09d..ad11743dd 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -10,6 +10,7 @@ public enum UsageFormatter { private static let localizationLock = NSLock() private nonisolated(unsafe) static var localizationProvider: (@Sendable (String) -> String)? + private nonisolated(unsafe) static var localeProvider: (@Sendable () -> Locale)? public static func setLocalizationProvider(_ provider: @escaping @Sendable (String) -> String) { self.localizationLock.lock() @@ -23,6 +24,25 @@ public enum UsageFormatter { self.localizationLock.unlock() } + public static func setLocaleProvider(_ provider: @escaping @Sendable () -> Locale) { + self.localizationLock.lock() + self.localeProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocaleProvider() { + self.localizationLock.lock() + self.localeProvider = nil + self.localizationLock.unlock() + } + + private static func currentLocale() -> Locale { + self.localizationLock.lock() + let provider = self.localeProvider + self.localizationLock.unlock() + return provider?() ?? .autoupdatingCurrent + } + private static func localized(_ key: String) -> String { self.localizationLock.lock() let provider = self.localizationProvider @@ -46,7 +66,7 @@ public enum UsageFormatter { private static func localized(_ key: String, _ args: CVarArg...) -> String { let format = self.localized(key) - return String(format: format, locale: Locale.current, arguments: args) + return String(format: format, locale: self.currentLocale(), arguments: args) } public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { @@ -82,14 +102,14 @@ public enum UsageFormatter { // Human-friendly phrasing: today / tomorrow / date+time. let calendar = Calendar.current if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(date: .omitted, time: .shortened) + return date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) } if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))" + return "tomorrow, \(date.formatted(.dateTime.hour().minute().locale(self.currentLocale())))" } - return date.formatted(date: .abbreviated, time: .shortened) + return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) } public static func resetLine( @@ -134,7 +154,7 @@ public enum UsageFormatter { if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() - rel.locale = Locale.current + rel.locale = self.currentLocale() rel.unitsStyle = .abbreviated return self.localized("Updated %@", rel.localizedString(for: date, relativeTo: now)) #else @@ -147,7 +167,9 @@ public enum UsageFormatter { return self.localized("Updated %@h ago", String(wholeHours)) #endif } else { - return self.localized("Updated %@", date.formatted(date: .omitted, time: .shortened)) + return self.localized( + "Updated %@", + date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))) } } diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 237102591..4978238f8 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -4,14 +4,31 @@ import Testing @testable import CodexBar struct UsageFormatterTests { + private static let usageFormatterLocalizationKeys: [String] = [ + "%@ left", + "Resets %@", + "Resets in %@", + "Resets now", + "Updated %@", + "Updated %@h ago", + "Updated %@m ago", + "Updated just now", + "usage_percent_suffix_left", + "usage_percent_suffix_used", + ] + @Test func `formats usage line`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: false) #expect(line == "25% left") } @Test func `formats usage line show used`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: true) #expect(line == "75% used") } @@ -247,4 +264,46 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") } + + @Test + func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.usageFormatterLocalizationKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsageFormatterTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. Date: Tue, 26 May 2026 10:19:16 +0800 Subject: [PATCH 5/8] Fix formatter locale fallback and pace resource keys --- .../Resources/en.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + Sources/CodexBarCore/UsageFormatter.swift | 2 +- Tests/CodexBarTests/UsageFormatterTests.swift | 56 ++++++++++++++++++ Tests/CodexBarTests/UsagePaceTextTests.swift | 57 +++++++++++++++++++ 5 files changed, 120 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ea5391d2f..3b0c0d817 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -688,6 +688,9 @@ "Updated just now" = "Updated just now"; "Projected empty in %@" = "Projected empty in %@"; "Runs out in %@" = "Runs out in %@"; +"Pace: %@" = "Pace: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% run-out risk"; "%d%% in deficit" = "%d%% in deficit"; "%d%% in reserve" = "%d%% in reserve"; "usage_percent_suffix_left" = "left"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 4be3c3d0d..caf6f9408 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -673,6 +673,9 @@ "Updated just now" = "刚刚更新"; "Projected empty in %@" = "预计 %@ 后用尽"; "Runs out in %@" = "预计 %@ 后用尽"; +"Pace: %@" = "节奏:%@"; +"Pace: %@ · %@" = "节奏:%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% 用尽风险"; "%d%% in deficit" = "超额 %d%%"; "%d%% in reserve" = "余量 %d%%"; "usage_percent_suffix_left" = "剩余"; diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index ad11743dd..74485076e 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -40,7 +40,7 @@ public enum UsageFormatter { self.localizationLock.lock() let provider = self.localeProvider self.localizationLock.unlock() - return provider?() ?? .autoupdatingCurrent + return provider?() ?? Locale(identifier: "en_US_POSIX") } private static func localized(_ key: String) -> String { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 4978238f8..aa0bbd58c 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -48,6 +48,62 @@ struct UsageFormatterTests { #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "78% 已使用") } + @Test + func `default locale fallback matches stable en US POSIX behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + + let defaultOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.setLocaleProvider { Locale(identifier: "en_US_POSIX") } + let injectedStableOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + #expect(defaultOutput == injectedStableOutput) + } + + @Test + func `injected zh Hans locale applies app language formatting`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "Updated %@": + "更新于 %@" + default: + key + } + } + UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hans") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let output = UsageFormatter.updatedString(from: old, now: now) + + #expect(output.hasPrefix("更新于 ")) + } + + @Test + func `clearing locale provider returns to stable default behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let baseline = UsageFormatter.updatedString(from: old, now: now) + + UsageFormatter.setLocaleProvider { Locale(identifier: "fr_FR") } + _ = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + let restored = UsageFormatter.updatedString(from: old, now: now) + #expect(restored == baseline) + } + @Test func `relative updated recent`() { let now = Date() diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 8de0e8156..e1567f526 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -4,6 +4,21 @@ import Testing @testable import CodexBar struct UsagePaceTextTests { + private static let localizedKeys: [String] = [ + "Pace: %@", + "Pace: %@ · %@", + "On pace", + "%d%% in deficit", + "%d%% in reserve", + "Lasts until reset", + "Projected empty now", + "Projected empty in %@", + "Runs out now", + "Runs out in %@", + "≈ %d%% run-out risk", + "%@ · %@", + ] + @Test func `weekly pace detail provides left right labels`() throws { let now = Date(timeIntervalSince1970: 0) @@ -148,4 +163,46 @@ struct UsagePaceTextTests { #expect(detail == nil) } + + @Test + func `usage pace text localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.localizedKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsagePaceTextTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. Date: Tue, 26 May 2026 12:00:02 +0800 Subject: [PATCH 6/8] Polish remaining zh-Hans visible labels --- .../InlineUsageDashboardContent.swift | 19 ++++++++++++------- .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../CodexBar/StatusItemController+Menu.swift | 9 +++++---- Tests/CodexBarTests/UsageFormatterTests.swift | 1 + 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index ff3dca4cd..01ef2dfe1 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -165,7 +165,6 @@ extension UsageMenuCardView.Model { snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel { let historyDays = max(1, min(365, snapshot.historyDays)) - let historyLabel = historyDays == 1 ? "Today" : "\(historyDays)d" let periodLabel = historyDays == 1 ? "today" : "\(historyDays) day" let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in guard let cost = entry.costUSD else { return nil } @@ -178,12 +177,14 @@ extension UsageMenuCardView.Model { let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } var details: [String] = [] if let topModel = Self.topCostModel(from: snapshot.daily) { - details.append("Top model: \(Self.shortModelName(topModel))") + details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } if provider == .bedrock { details.append("AWS Cost Explorer billing can lag.") - } else { + } else if provider == .claude { details.append(UsageFormatter.costEstimateHint(provider: provider)) + } else { + details.append(L("cost_estimate_hint")) } let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue return InlineUsageDashboardModel( @@ -191,19 +192,23 @@ extension UsageMenuCardView.Model { valueStyle: .currencyUSD, kpis: [ .init( - title: provider == .bedrock ? "Latest" : "Today", + title: provider == .bedrock ? L("Latest") : L("Today"), value: latest?.costUSD.map(UsageFormatter.usdString) ?? "—", emphasis: true), .init( - title: "\(historyLabel) cost", + title: historyDays == 30 + ? L("30d cost") + : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))", value: snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—", emphasis: false), .init( - title: "\(historyLabel) tokens", + title: historyDays == 30 + ? L("30d tokens") + : "\(String(format: L("Last %d days"), historyDays)) token", value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", emphasis: false), .init( - title: "Latest tokens", + title: L("Latest tokens"), value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", emphasis: false), ], diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 3b0c0d817..b24c3a626 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -690,6 +690,7 @@ "Runs out in %@" = "Runs out in %@"; "Pace: %@" = "Pace: %@"; "Pace: %@ · %@" = "Pace: %@ · %@"; +"%@ · %@" = "%@ · %@"; "≈ %d%% run-out risk" = "≈ %d%% run-out risk"; "%d%% in deficit" = "%d%% in deficit"; "%d%% in reserve" = "%d%% in reserve"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index caf6f9408..55c4bc832 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -675,6 +675,7 @@ "Runs out in %@" = "预计 %@ 后用尽"; "Pace: %@" = "节奏:%@"; "Pace: %@ · %@" = "节奏:%@ · %@"; +"%@ · %@" = "%@ · %@"; "≈ %d%% run-out risk" = "≈ %d%% 用尽风险"; "%d%% in deficit" = "超额 %d%%"; "%d%% in reserve" = "余量 %d%%"; diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 630c3e733..e0f241233 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -734,9 +734,10 @@ extension StatusItemController { } menu.addItem(item) case let .action(title, action): + let localizedTitle = L(title) if self.usesPersistentMenuActionItem(for: action) { menu.addItem(self.makePersistentMenuActionItem( - title: title, + title: localizedTitle, action: action, menu: menu, width: width)) @@ -744,7 +745,7 @@ extension StatusItemController { } let (selector, represented) = self.selector(for: action) - let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") + let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self item.representedObject = represented if let shortcut = self.shortcut(for: action) { @@ -762,12 +763,12 @@ extension StatusItemController { let subtitle = self.switchAccountSubtitle(for: targetProvider) { item.isEnabled = false - self.applySubtitle(subtitle, to: item, title: title) + self.applySubtitle(subtitle, to: item, title: localizedTitle) } else if case .addCodexAccount = action, let subtitle = self.codexAddAccountSubtitle() { item.isEnabled = false - self.applySubtitle(subtitle, to: item, title: title) + self.applySubtitle(subtitle, to: item, title: localizedTitle) } menu.addItem(item) case let .submenu(title, systemImageName, submenuItems): diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index aa0bbd58c..873591d41 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +@Suite(.serialized) struct UsageFormatterTests { private static let usageFormatterLocalizationKeys: [String] = [ "%@ left", From 2bc20a31963ee793cf99a1e0e52aa31be457ae25 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 26 May 2026 14:36:31 +0800 Subject: [PATCH 7/8] Fix one-day and token KPI labels --- .../CodexBar/CostHistoryChartMenuView.swift | 7 +- .../InlineUsageDashboardContent.swift | 10 ++- .../Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../CostHistoryChartMenuViewTests.swift | 12 +++ ...InlineCostHistoryDashboardLabelTests.swift | 75 +++++++++++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift create mode 100644 Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 3904bd303..475f421da 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -208,8 +208,11 @@ struct CostHistoryChartMenuView: View { private static let expandedDetailRowHeight: CGFloat = 44 private static let detailSpacing: CGFloat = 6 - private static func windowLabel(days: Int) -> String { - String(format: L("%dd"), days) + static func windowLabel(days: Int) -> String { + if days == 1 { + return L("Today") + } + return String(format: L("Last %d days"), days) } private static func detailRowHeight(for row: DetailRow) -> CGFloat { diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index bb8164d4b..fd0b70df0 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -196,15 +196,19 @@ extension UsageMenuCardView.Model { value: latest?.costUSD.map(UsageFormatter.usdString) ?? "—", emphasis: true), .init( - title: historyDays == 30 + title: historyDays == 1 + ? L("Today") + : historyDays == 30 ? L("30d cost") : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))", value: snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—", emphasis: false), .init( - title: historyDays == 30 + title: historyDays == 1 + ? L("Today tokens") + : historyDays == 30 ? L("30d tokens") - : "\(String(format: L("Last %d days"), historyDays)) tokens", + : String(format: L("%@ tokens"), String(format: L("Last %d days"), historyDays)), value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", emphasis: false), .init( diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index b24c3a626..ced07af15 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -662,6 +662,7 @@ "No OpenCode session cookies found in browsers." = "No OpenCode session cookies found in browsers."; "No available fetch strategy for %@." = "No available fetch strategy for %@."; "Today" = "Today"; +"Today tokens" = "Today tokens"; "30d cost" = "30d cost"; "30d tokens" = "30d tokens"; "Latest tokens" = "Latest tokens"; @@ -675,6 +676,7 @@ "Quit" = "Quit"; "Last %d day" = "Last %d day"; "Last %d days" = "Last %d days"; +"%@ tokens" = "%@ tokens"; "Latest billing day" = "Latest billing day"; "Latest billing day (%@)" = "Latest billing day (%@)"; "%@ left" = "%@ left"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 55c4bc832..6a0621ab9 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -647,6 +647,7 @@ "No OpenCode session cookies found in browsers." = "在浏览器中未找到 OpenCode 会话 Cookie。"; "No available fetch strategy for %@." = "%@ 暂无可用的获取策略。"; "Today" = "今日"; +"Today tokens" = "今日 token 用量"; "30d cost" = "近 30 天费用"; "30d tokens" = "近 30 天 token 用量"; "Latest tokens" = "最近 token 用量"; @@ -660,6 +661,7 @@ "Quit" = "退出"; "Last %d day" = "近 %d 天"; "Last %d days" = "近 %d 天"; +"%@ tokens" = "%@ token 用量"; "Latest billing day" = "最近结算日"; "Latest billing day (%@)" = "最近结算日(%@)"; "%@ left" = "%@ 剩余"; diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift new file mode 100644 index 000000000..f1c98f03a --- /dev/null +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import CodexBar + +struct CostHistoryChartMenuViewTests { + @Test + @MainActor + func `window label keeps today for one day and dynamic labels otherwise`() { + #expect(CostHistoryChartMenuView.windowLabel(days: 1) == "Today") + #expect(CostHistoryChartMenuView.windowLabel(days: 7) == "Last 7 days") + #expect(CostHistoryChartMenuView.windowLabel(days: 30) == "Last 30 days") + } +} diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift new file mode 100644 index 000000000..8df8e8680 --- /dev/null +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -0,0 +1,75 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct InlineCostHistoryDashboardLabelTests { + @Test + func `local cost history KPI titles preserve one day and dynamic windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let daily = [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.12, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["claude-opus-4"], + modelBreakdowns: nil), + ] + + func makeModel(historyDays: Int) -> UsageMenuCardView.Model { + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyDays: historyDays, + daily: daily, + updatedAt: now) + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let oneDay = makeModel(historyDays: 1) + #expect(oneDay.inlineUsageDashboard?.kpis[1].title == "Today") + #expect(oneDay.inlineUsageDashboard?.kpis[2].title == "Today tokens") + + let sevenDays = makeModel(historyDays: 7) + #expect(sevenDays.inlineUsageDashboard?.kpis[1].title == "Last 7 days Cost") + #expect(sevenDays.inlineUsageDashboard?.kpis[2].title == "Last 7 days tokens") + + let thirtyDays = makeModel(historyDays: 30) + #expect(thirtyDays.inlineUsageDashboard?.kpis[1].title == "30d cost") + #expect(thirtyDays.inlineUsageDashboard?.kpis[2].title == "30d tokens") + } +} From 8bdac15cef0bcb90dd81afabfc139f22d1a6aa1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 09:35:13 +0100 Subject: [PATCH 8/8] docs: update changelog for zh-Hans localization --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d11328c..dd8b253f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.29.2 — Unreleased +### Fixed +- Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao! + ## 0.29.1 — 2026-05-26 ### Added