From ba7e5eb5230b11b5b259e6c3034265f8161dead4 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:33:24 +0300 Subject: [PATCH 1/7] feat(menubar): route every user-facing string through a strings catalog Groundwork for #1219. The menubar app hardcoded ~530 English strings across Views/, the presentation structs, the status-item menu and the update alerts, so there was nothing for a translator to translate. Every user-facing literal now goes through `L(_:)` / `L(_:_:)`, which look the string up in `Localizable.strings` in the SwiftPM target resource bundle. The key *is* the English copy, so English stays the development language and a missing translation degrades to correct English instead of a dotted identifier. `en.lproj` is therefore an identity table; it exists so the bundle advertises `en` and so a translation can be diffed against it. Why the lookups are explicit rather than relying on SwiftUI's implicit `LocalizedStringKey`: SwiftPM emits target resources into a sibling bundle (`CodeBurnMenubar_CodeBurnMenubar.bundle`) that the packaging scripts copy into `Contents/Resources`. `Bundle.main` has no `.lproj` at all, so `Text("literal")` would always miss. Naming `Bundle.module` is the one form that resolves the same way in `swift run`, in `swift test` and in the packaged `.app`. Enum raw values that double as identity (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gain a `displayLabel`, so persistence and cache keys are untouched by translation. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now use `setLocalizedDateFormatFromTemplate`, and the calendar popover's weekday row comes from the locale's own symbols. The `yyyy-MM-dd` formatter stays POSIX: it parses and builds data keys, not display text. Not extracted, deliberately: provider, model and plan names; units; currency codes; shell commands and paths; quota window labels that policy code matches on by English substring (`QuotaSummary.headlineWindow`); and anything the `codeburn` CLI produces. Also copies the SwiftPM resource bundle into the app in build-local.sh, using the named-bundle + `[[ -d ]]` pattern package-app.sh already uses. Without it the assembled app finds no strings table (and already trapped on first icon load, the resource-bundle half of #1262). --- mac/Package.swift | 14 +- mac/Scripts/build-local.sh | 15 + mac/Sources/CodeBurnMenubar/AppStore.swift | 84 ++- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 56 +- .../Data/CapacityDockPreferences.swift | 8 +- .../Data/CopilotQuotaPresentation.swift | 24 +- .../Data/ProviderConnectionCatalog.swift | 32 +- .../Data/ProviderReconnectPresentation.swift | 30 +- .../CodeBurnMenubar/Data/QuotaSummary.swift | 18 +- .../Data/SubscriptionRefreshCadence.swift | 10 +- .../CodeBurnMenubar/Data/UpdateChecker.swift | 48 +- .../Data/UsageRefreshCadence.swift | 10 +- .../CodeBurnMenubar/Localization.swift | 62 ++ .../CodeBurnMenubar/MenubarScope.swift | 9 + .../Resources/en.lproj/Localizable.strings | 603 ++++++++++++++++++ .../Security/PreferredTerminal.swift | 2 +- .../CodeBurnMenubar/SessionCountLabel.swift | 18 +- .../CodeBurnMenubar/Theme/ThemeState.swift | 15 + .../Views/ActivitySection.swift | 8 +- .../CodeBurnMenubar/Views/AgentTabStrip.swift | 18 +- .../Views/CapacityDockView.swift | 48 +- .../Views/FindingsSection.swift | 28 +- .../Views/HeatmapSection.swift | 325 +++++----- .../CodeBurnMenubar/Views/HeroSection.swift | 20 +- .../Views/MenuBarContent.swift | 58 +- .../CodeBurnMenubar/Views/ModelsSection.swift | 20 +- .../Views/PeriodSegmentedControl.swift | 23 +- .../Views/PullRequestsSection.swift | 2 +- .../CodeBurnMenubar/Views/SettingsView.swift | 572 ++++++++--------- .../Views/ToolingSection.swift | 8 +- 30 files changed, 1497 insertions(+), 691 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Localization.swift create mode 100644 mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings diff --git a/mac/Package.swift b/mac/Package.swift index 4b5c0d8f4..b74eb3936 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -3,6 +3,11 @@ import PackageDescription let package = Package( name: "CodeBurnMenubar", + // English is the development language: every key in Localizable.strings *is* + // its English copy, so a key with no translation renders as correct English + // instead of a dotted identifier. Declaring it here is also what lets SwiftPM + // treat Resources/.lproj as localized resources at all. + defaultLocalization: "en", platforms: [ // macOS 14 (Sonoma) is the floor: matches Info.plist LSMinimumSystemVersion, // the CLI install guard (MIN_MACOS_MAJOR=14), and mac/README. The earlier .v15 @@ -18,7 +23,14 @@ let package = Package( name: "CodeBurnMenubar", path: "Sources/CodeBurnMenubar", resources: [ - .process("Resources/ProviderIcons") + .process("Resources/ProviderIcons"), + // Emitted into the target resource bundle as `.lproj/ + // Localizable.strings`, which is the layout NSBundle needs to + // resolve a table per localization. Lookups go through + // `L(_:)` / `Bundle.module`, never `Bundle.main`: the strings + // live in the SwiftPM resource bundle inside Contents/Resources, + // not at the app bundle's resource root. + .process("Resources/en.lproj") ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency") diff --git a/mac/Scripts/build-local.sh b/mac/Scripts/build-local.sh index fbd20f42e..96c928400 100755 --- a/mac/Scripts/build-local.sh +++ b/mac/Scripts/build-local.sh @@ -107,6 +107,21 @@ mkdir -p "${BUNDLE}/Contents/MacOS" "${BUNDLE}/Contents/Resources" cp "${BIN}" "${BUNDLE}/Contents/MacOS/${EXE}" cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png" +# SwiftPM emits target resources as a sibling bundle of the executable, and +# `Bundle.module` resolves it from Contents/Resources. Without it the app traps +# on first icon load and no Localizable.strings table is reachable, so every +# string falls back to its English key. Mirrors package-app.sh:53-58. +# The arm64 bin path is enough: the bundle is arch-independent. +SPM_BIN_PATH="$(cd "${SCRATCH}" && "${SWIFT}" build -c release --arch arm64 --show-bin-path)" +SPM_RESOURCE_BUNDLE="${SPM_BIN_PATH}/${EXE}_${EXE}.bundle" +if [[ -d "${SPM_RESOURCE_BUNDLE}" ]]; then + cp -R "${SPM_RESOURCE_BUNDLE}" "${BUNDLE}/Contents/Resources/" +else + echo "✗ Resource bundle missing at ${SPM_RESOURCE_BUNDLE}" >&2 + echo " Bundle.module would trap at launch; aborting." >&2 + exit 1 +fi + ICONSET="${SCRATCH}/AppIcon.iconset"; mkdir -p "${ICONSET}" for spec in "16:16x16" "32:16x16@2x" "32:32x32" "64:32x32@2x" "128:128x128" \ "256:128x128@2x" "256:256x256" "512:256x256@2x" "512:512x512"; do diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 7187c2204..a7a8e4c13 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2573,27 +2573,29 @@ final class AppStore { enum SupportedCurrency: String, CaseIterable, Identifiable { case USD, GBP, EUR, AUD, CAD, NZD, JPY, CNY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK, RON var id: String { rawValue } + /// "USD · US Dollar" — the code is never translated, the name is. + var pickerLabel: String { "\(rawValue) · \(displayName)" } var displayName: String { switch self { - case .USD: "US Dollar" - case .GBP: "British Pound" - case .EUR: "Euro" - case .AUD: "Australian Dollar" - case .CAD: "Canadian Dollar" - case .NZD: "New Zealand Dollar" - case .JPY: "Japanese Yen" - case .CNY: "Chinese Yuan" - case .CHF: "Swiss Franc" - case .INR: "Indian Rupee" - case .BRL: "Brazilian Real" - case .SEK: "Swedish Krona" - case .SGD: "Singapore Dollar" - case .HKD: "Hong Kong Dollar" - case .KRW: "South Korean Won" - case .MXN: "Mexican Peso" - case .ZAR: "South African Rand" - case .DKK: "Danish Krone" - case .RON: "Romanian Leu" + case .USD: L("US Dollar") + case .GBP: L("British Pound") + case .EUR: L("Euro") + case .AUD: L("Australian Dollar") + case .CAD: L("Canadian Dollar") + case .NZD: L("New Zealand Dollar") + case .JPY: L("Japanese Yen") + case .CNY: L("Chinese Yuan") + case .CHF: L("Swiss Franc") + case .INR: L("Indian Rupee") + case .BRL: L("Brazilian Real") + case .SEK: L("Swedish Krona") + case .SGD: L("Singapore Dollar") + case .HKD: L("Hong Kong Dollar") + case .KRW: L("South Korean Won") + case .MXN: L("Mexican Peso") + case .ZAR: L("South African Rand") + case .DKK: L("Danish Krone") + case .RON: L("Romanian Leu") } } } @@ -2632,6 +2634,12 @@ enum ProviderFilter: String, CaseIterable, Identifiable { var id: String { rawValue } + /// Tab and empty-state label. Provider names are product names and stay + /// verbatim; only the synthetic "All" filter is translated. + var displayLabel: String { + self == .all ? L("All") : rawValue + } + var providerKeys: [String] { switch self { case .cursor: ["cursor"] @@ -2717,6 +2725,19 @@ enum InsightMode: String, CaseIterable, Identifiable { case stats = "Stats" case optimize = "Optimize" var id: String { rawValue } + + /// Tab label. `rawValue` stays the persisted identity. + var displayLabel: String { + switch self { + case .plan: L("Plan") + case .trend: L("Trend") + case .forecast: L("Forecast") + case .calendar: L("Calendar") + case .pulse: L("Pulse") + case .stats: L("Stats") + case .optimize: L("Optimize") + } + } } enum Period: String, CaseIterable, Identifiable { @@ -2732,6 +2753,19 @@ enum Period: String, CaseIterable, Identifiable { var id: String { rawValue } + /// Segment label. `rawValue` stays the stable identity used for `id` and + /// for cache keys. + var displayLabel: String { + switch self { + case .today: L("Today") + case .sevenDays: L("7D") + case .thirtyDays: L("30D") + case .month: L("Month") + case .all: L("6M") + case .lifetime: L("Life") + } + } + /// Maps to the CLI's `--period` argument values. var cliArg: String { switch self { @@ -2748,12 +2782,12 @@ enum Period: String, CaseIterable, Identifiable { var menubarMetricLabel: String { switch self { - case .today: "Today" - case .sevenDays: "Week" - case .thirtyDays: "30 Days" - case .month: "Month" - case .all: "6 Months" - case .lifetime: "Lifetime" + case .today: L("Today") + case .sevenDays: L("Week") + case .thirtyDays: L("30 Days") + case .month: L("Month") + case .all: L("6 Months") + case .lifetime: L("Lifetime") } } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index bfdddba93..d71407b9a 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -1316,7 +1316,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)" } else if store.displayMetric == .credits, let p = menubarPayload?.current { let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded()) - valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)" + valueText = compact ? "\(credits)cr\(suffix)" : " " + L("%@ credits", credits) + suffix } else { let fallback = compact ? "$-" : "$—" valueText = compact @@ -1346,7 +1346,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM button.attributedTitle = composed if let shortfall = store.menubarBadgeDeviceShortfall { - button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting" + button.toolTip = L( + "CodeBurn %@ · %lld of %lld devices reporting", + menubarPeriod.menubarMetricLabel, + shortfall.reachable, + shortfall.total + ) } else { button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" } @@ -1496,29 +1501,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM usageItem.isEnabled = false menu.addItem(usageItem) - let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: "") + let settingsItem = NSMenuItem(title: L("Settings…"), action: #selector(openSettings), keyEquivalent: "") settingsItem.target = self settingsItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") menu.addItem(settingsItem) - let dockSettingsItem = NSMenuItem(title: "Capacity Dock Settings…", action: #selector(openCapacityDockSettings), keyEquivalent: "") + let dockSettingsItem = NSMenuItem(title: L("Capacity Dock Settings…"), action: #selector(openCapacityDockSettings), keyEquivalent: "") dockSettingsItem.target = self dockSettingsItem.image = NSImage(systemSymbolName: "rectangle.trailinghalf.inset.filled.arrow.trailing", accessibilityDescription: "Capacity Dock") menu.addItem(dockSettingsItem) - let refreshNow = NSMenuItem(title: "Refresh Now", action: #selector(refreshNowAction), keyEquivalent: "") + let refreshNow = NSMenuItem(title: L("Refresh Now"), action: #selector(refreshNowAction), keyEquivalent: "") refreshNow.target = self menu.addItem(refreshNow) - let updateItem = NSMenuItem(title: "Check for Updates", action: #selector(checkForUpdates), keyEquivalent: "") + let updateItem = NSMenuItem(title: L("Check for Updates"), action: #selector(checkForUpdates), keyEquivalent: "") updateItem.target = self menu.addItem(updateItem) - let aboutItem = NSMenuItem(title: "About CodeBurn", action: #selector(openAbout), keyEquivalent: "") + let aboutItem = NSMenuItem(title: L("About CodeBurn"), action: #selector(openAbout), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) - let quitItem = NSMenuItem(title: "Quit CodeBurn", action: #selector(quitApp), keyEquivalent: "") + let quitItem = NSMenuItem(title: L("Quit CodeBurn"), action: #selector(quitApp), keyEquivalent: "") quitItem.target = self menu.addItem(quitItem) @@ -1559,9 +1564,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM /// One-line "today" summary for the context menu's usage row. private func contextMenuUsageSummary() -> String { - guard let current = store.todayPayload?.current else { return "Today · no usage yet" } - let calls = current.calls == 1 ? "1 call" : "\(current.calls) calls" - return "Today · \(current.cost.asCurrency()) · \(calls)" + guard let current = store.todayPayload?.current else { return L("Today · no usage yet") } + let calls = current.calls == 1 ? L("1 call") : L("%lld calls", current.calls) + return L("Today · %@ · %@", current.cost.asCurrency(), calls) } private var settingsWindowController: NSWindowController? @@ -1597,7 +1602,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM backing: .buffered, defer: false ) - window.title = "CodeBurn Settings" + window.title = L("CodeBurn Settings") window.contentViewController = hosting window.center() window.isReleasedWhenClosed = false @@ -1636,24 +1641,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM let alert = NSAlert() alert.icon = codeburnAlertIcon() if let error = updateChecker.updateError { - alert.messageText = "Update Check Failed" + alert.messageText = L("Update Check Failed") alert.informativeText = error alert.alertStyle = .warning } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { - alert.messageText = "Update Available" - let header = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion)))." + alert.messageText = L("Update Available") + let header = L( + "%@ is available (you have %@).", + AppVersion.display(latest), + AppVersion.display(updateChecker.currentVersion) + ) if updateChecker.cliTooOldForUpdate { - alert.informativeText = "\(header) Your codeburn CLI is too old to install it. First run:\n\n\(updateChecker.cliUpdateCommand)\n\nthen:\n\ncodeburn menubar --force" + alert.informativeText = L( + "%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force", + header, + updateChecker.cliUpdateCommand + ) } else { - alert.informativeText = "\(header) Run:\n\ncodeburn menubar --force" + alert.informativeText = L("%@ Run:\n\ncodeburn menubar --force", header) } alert.alertStyle = .informational } else { - alert.messageText = "Up to Date" - alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))." + alert.messageText = L("Up to Date") + alert.informativeText = L( + "You're on the latest version (%@).", + AppVersion.display(updateChecker.currentVersion) + ) alert.alertStyle = .informational } - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: L("OK")) alert.runModal() } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift b/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift index 924c20825..074eb95b6 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift @@ -113,8 +113,8 @@ enum CapacityDockTheme: String, CaseIterable, Sendable { var displayName: String { switch self { - case .graphite: "Graphite" - case .liquidGlass: "Liquid Glass" + case .graphite: L("Graphite") + case .liquidGlass: L("Liquid Glass") } } } @@ -125,8 +125,8 @@ enum CapacityDockGaugeShape: String, CaseIterable, Sendable { var displayName: String { switch self { - case .circle: "Circle" - case .squircle: "Squircle" + case .circle: L("Circle") + case .squircle: L("Squircle") } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift index acfd05a02..3557a651f 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift @@ -31,16 +31,20 @@ enum CopilotQuotaPresentation { case usage(idle: Bool) } - static let noCredentialsPlanTitle = "No Copilot credentials found" - static let noCredentialsPlanMessage = - "Sign in via an editor's Copilot plugin first. Then click Try Again." - static let disconnectedPlanTitle = "Copilot quota tracking disconnected" - static let disconnectedPlanMessage = - "Your Copilot credentials are untouched. Click Connect to resume." - static let noCredentialsSettingsDetail = - "Usage tracking still works. For live quota, sign in with the Copilot CLI or gh auth login, or paste a token below, then click Connect." - static let disconnectedSettingsDetail = - "Quota tracking disconnected. Credentials are untouched. Click Connect to resume." + static var noCredentialsPlanTitle: String { L("No Copilot credentials found") } + static var noCredentialsPlanMessage: String { + L("Sign in via an editor's Copilot plugin first. Then click Try Again.") + } + static var disconnectedPlanTitle: String { L("Copilot quota tracking disconnected") } + static var disconnectedPlanMessage: String { + L("Your Copilot credentials are untouched. Click Connect to resume.") + } + static var noCredentialsSettingsDetail: String { + L("Usage tracking still works. For live quota, sign in with the Copilot CLI or gh auth login, or paste a token below, then click Connect.") + } + static var disconnectedSettingsDetail: String { + L("Quota tracking disconnected. Credentials are untouched. Click Connect to resume.") + } static func planContent( loadState: SubscriptionLoadState, diff --git a/mac/Sources/CodeBurnMenubar/Data/ProviderConnectionCatalog.swift b/mac/Sources/CodeBurnMenubar/Data/ProviderConnectionCatalog.swift index 0095c6c11..e610a2013 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ProviderConnectionCatalog.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ProviderConnectionCatalog.swift @@ -24,12 +24,12 @@ enum ProviderAuthMethod: String, Codable, CaseIterable, Hashable, Sendable { var title: String { switch self { - case .localAppOrCLI: "Installed app or CLI" - case .oauth: "OAuth" - case .apiTokenOrCloudCredentials: "API or cloud credentials" - case .cookieOrWebSession: "Browser session" - case .localhost: "Localhost service" - case .none: "No sign-in required" + case .localAppOrCLI: L("Installed app or CLI") + case .oauth: L("OAuth") + case .apiTokenOrCloudCredentials: L("API or cloud credentials") + case .cookieOrWebSession: L("Browser session") + case .localhost: L("Localhost service") + case .none: L("No sign-in required") } } } @@ -164,39 +164,39 @@ enum ProviderConnectionGuidance { static func instruction(for provider: CapacityDockProvider) -> String { let methods = provider.catalogEntry.authMethods if methods == [.apiTokenOrCloudCredentials] { - return "Enter an API key or token below, then press Save & Connect." + return L("Enter an API key or token below, then press Save & Connect.") } if methods == [.cookieOrWebSession] { - return "Sign in to \(provider.displayName) in a supported browser, then click Retry." + return L("Sign in to %@ in a supported browser, then click Retry.", provider.displayName) } if methods.contains(.localAppOrCLI) { - return "Sign in with the \(provider.displayName) app or CLI, then click Retry." + return L("Sign in with the %@ app or CLI, then click Retry.", provider.displayName) } if methods.contains(.oauth) { - return "Complete \(provider.displayName) OAuth, then click Retry." + return L("Complete %@ OAuth, then click Retry.", provider.displayName) } if methods.contains(.localhost) { - return "Start the local \(provider.displayName) service, then click Retry." + return L("Start the local %@ service, then click Retry.", provider.displayName) } if methods.contains(.apiTokenOrCloudCredentials) { - return "Enter the required API or cloud credentials below, then press Save & Connect." + return L("Enter the required API or cloud credentials below, then press Save & Connect.") } if methods.contains(.cookieOrWebSession) { - return "Sign in to \(provider.displayName) in a supported browser, then click Retry." + return L("Sign in to %@ in a supported browser, then click Retry.", provider.displayName) } - return "No sign-in is required. Click Retry to refresh quota." + return L("No sign-in is required. Click Retry to refresh quota.") } static func dockInstruction(for provider: CapacityDockProvider) -> String { let methods = provider.catalogEntry.authMethods if methods == [.apiTokenOrCloudCredentials] { - return "Add an API key or token in Provider Settings." + return L("Add an API key or token in Provider Settings.") } if methods.contains(.apiTokenOrCloudCredentials), !methods.contains(.localAppOrCLI), !methods.contains(.cookieOrWebSession), !methods.contains(.oauth) { - return "Add the required API or cloud credentials in Provider Settings." + return L("Add the required API or cloud credentials in Provider Settings.") } return instruction(for: provider) } diff --git a/mac/Sources/CodeBurnMenubar/Data/ProviderReconnectPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/ProviderReconnectPresentation.swift index b6fa69fd6..a528954d8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ProviderReconnectPresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ProviderReconnectPresentation.swift @@ -6,29 +6,29 @@ struct ProviderReconnectPresentation: Sendable, Equatable { let instruction: String init(provider: ProviderFilter) { - title = "Reconnect \(provider.rawValue)" + title = L("Reconnect %@", provider.displayLabel) switch provider { case .claude: - defaultReason = "Claude Code credentials need to be refreshed." - instruction = "Open Claude Code in your terminal and type `/login`, then click Reconnect." + defaultReason = L("Claude Code credentials need to be refreshed.") + instruction = L("Open Claude Code in your terminal and type `/login`, then click Reconnect.") case .codex: - defaultReason = "Codex credentials need to be refreshed." - instruction = "Run `codex login` in your terminal, then click Reconnect." + defaultReason = L("Codex credentials need to be refreshed.") + instruction = L("Run `codex login` in your terminal, then click Reconnect.") case .kimiCode: - defaultReason = "Kimi Code credentials need to be refreshed." - instruction = "Run the Kimi CLI once to refresh your login, then click Reconnect." + defaultReason = L("Kimi Code credentials need to be refreshed.") + instruction = L("Run the Kimi CLI once to refresh your login, then click Reconnect.") case .gemini: - defaultReason = "Gemini credentials need to be refreshed." - instruction = "Run the Gemini CLI once to refresh your login, then click Reconnect." + defaultReason = L("Gemini credentials need to be refreshed.") + instruction = L("Run the Gemini CLI once to refresh your login, then click Reconnect.") case .copilot: - defaultReason = "Copilot credentials need to be refreshed." - instruction = "Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect." + defaultReason = L("Copilot credentials need to be refreshed.") + instruction = L("Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect.") case .antigravity: - defaultReason = "The local Antigravity service is unavailable." - instruction = "Start the Antigravity app, then click Reconnect." + defaultReason = L("The local Antigravity service is unavailable.") + instruction = L("Start the Antigravity app, then click Reconnect.") default: - defaultReason = "\(provider.rawValue) credentials need to be refreshed." - instruction = "Sign in to \(provider.rawValue) again, then retry." + defaultReason = L("%@ credentials need to be refreshed.", provider.displayLabel) + instruction = L("Sign in to %@ again, then retry.", provider.displayLabel) } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 70a21b359..a4650a967 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -77,11 +77,16 @@ enum CapacityDockConnectionAction: String, Equatable, Sendable { case connect = "Connect" case reconnect = "Reconnect" - var title: String { rawValue } + var title: String { + switch self { + case .connect: L("Connect") + case .reconnect: L("Reconnect") + } + } func title(for provider: CapacityDockProvider) -> String { if provider.catalogEntry.authMethods == [.apiTokenOrCloudCredentials] { - return "Add API Key" + return L("Add API Key") } return title } @@ -101,13 +106,14 @@ extension QuotaSummary.Window { var resetsInLabel: String { guard let resetsAt else { return "" } let seconds = max(0, resetsAt.timeIntervalSinceNow) - if seconds < 60 { return "now" } + if seconds < 60 { return L("now") } let minutes = Int(seconds / 60) let hours = minutes / 60 let days = hours / 24 - if days > 0 { return "\(days)d \(hours % 24)h" } - if hours > 0 { return "\(hours)h \(minutes % 60)m" } - return "\(minutes)m" + // d/h/m are unit abbreviations; zh-Hans uses 天/小时/分. + if days > 0 { return L("%lldd %lldh", days, hours % 24) } + if hours > 0 { return L("%lldh %lldm", hours, minutes % 60) } + return L("%lldm", minutes) } var percentLabel: String { diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift index 5f568e0d4..bda03fb2f 100644 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift @@ -15,11 +15,11 @@ enum SubscriptionRefreshCadence: Int, CaseIterable, Identifiable { var label: String { switch self { - case .manual: return "Manual" - case .oneMinute: return "1 minute" - case .twoMinutes: return "2 minutes" - case .fiveMinutes: return "5 minutes" - case .fifteenMinutes: return "15 minutes" + case .manual: return L("Manual") + case .oneMinute: return L("1 minute") + case .twoMinutes: return L("2 minutes") + case .fiveMinutes: return L("5 minutes") + case .fifteenMinutes: return L("15 minutes") } } diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 0b6a8d3ca..7a8f47bde 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -22,24 +22,24 @@ enum UpdateFailureStage: Equatable { var badgeLabel: String { switch self { - case .check: "Update Check Failed" - case .cliUpdate: "CLI Update Failed" - case .menubarUpdate: "Menubar Update Failed" + case .check: L("Update Check Failed") + case .cliUpdate: L("CLI Update Failed") + case .menubarUpdate: L("Menubar Update Failed") } } var summary: String { switch self { - case .check: "CodeBurn could not check GitHub for updates." - case .cliUpdate: "CodeBurn could not update the CLI." - case .menubarUpdate: "CodeBurn could not update the menubar app." + case .check: L("CodeBurn could not check GitHub for updates.") + case .cliUpdate: L("CodeBurn could not update the CLI.") + case .menubarUpdate: L("CodeBurn could not update the menubar app.") } } var retryHelp: String { switch self { - case .check: "Click to retry the update check." - case .cliUpdate, .menubarUpdate: "Click to retry the update." + case .check: L("Click to retry the update check.") + case .cliUpdate, .menubarUpdate: L("Click to retry the update.") } } } @@ -80,13 +80,13 @@ final class UpdateChecker { var updateFailureStage: UpdateFailureStage? var updateBadgeLabel: String { - if isUpdating { return "Updating..." } - return updateFailureStage?.badgeLabel ?? "Update" + if isUpdating { return L("Updating...") } + return updateFailureStage?.badgeLabel ?? L("Update") } var updateHelpText: String { guard let error = updateError, let stage = updateFailureStage else { - return "Update the CLI and menubar to the latest release" + return L("Update the CLI and menubar to the latest release") } return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)" } @@ -199,11 +199,11 @@ final class UpdateChecker { nonisolated static func updateNotificationCopy(appVersion: String?, cliVersion: String?) -> (title: String, body: String)? { switch (appVersion, cliVersion) { case let (app?, cli?): - return ("CodeBurn \(AppVersion.display(app)) available", "App and CLI \(AppVersion.display(cli)) updates are ready. Click to install.") + return (L("CodeBurn %@ available", AppVersion.display(app)), L("App and CLI %@ updates are ready. Click to install.", AppVersion.display(cli))) case let (app?, nil): - return ("CodeBurn \(AppVersion.display(app)) available", "Click to install the update.") + return (L("CodeBurn %@ available", AppVersion.display(app)), L("Click to install the update.")) case let (nil, cli?): - return ("CodeBurn CLI \(AppVersion.display(cli)) available", "Click to install the update.") + return (L("CodeBurn CLI %@ available", AppVersion.display(cli)), L("Click to install the update.")) case (nil, nil): return nil } @@ -286,7 +286,11 @@ final class UpdateChecker { guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else { isUpdating = false updateFailureStage = .cliUpdate - updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again." + updateError = L( + "Could not find the package manager for %@. Run “%@” manually, then try again.", + cliPath.isEmpty ? L("the CLI") : cliPath, + cliUpdateCommand + ) return } let process = Process() @@ -298,7 +302,7 @@ final class UpdateChecker { if status != 0 { self.isUpdating = false self.updateFailureStage = .cliUpdate - self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr + self.updateError = stderr.isEmpty ? L("CLI update failed (exit %lld)", status) : stderr NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)") return } @@ -353,7 +357,11 @@ final class UpdateChecker { installedCliVersion = Self.queryInstalledCliVersion() if cliTooOldForUpdate { updateFailureStage = .menubarUpdate - updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again." + updateError = L( + "Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again.", + AppVersion.display(installedCliVersion ?? ""), + cliUpdateCommand + ) return } isUpdating = true @@ -389,7 +397,7 @@ final class UpdateChecker { self.isUpdating = false if proc.terminationStatus != 0 { self.updateFailureStage = .menubarUpdate - self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr + self.updateError = stderr.isEmpty ? L("Update failed (exit %lld)", proc.terminationStatus) : stderr NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)") } else { self.latestVersion = nil @@ -429,8 +437,8 @@ enum UpdateCheckError: LocalizedError { var errorDescription: String? { switch self { - case let .http(status): "GitHub returned HTTP \(status)." - case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found." + case let .http(status): L("GitHub returned HTTP %lld.", status) + case .missingMenubarAsset: L("No mac-v release with a menubar zip and checksum was found.") } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift index ddc47ab82..7f92fdfc8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift @@ -18,11 +18,11 @@ enum UsageRefreshCadence: Int, CaseIterable, Identifiable { var label: String { switch self { - case .auto: return "Auto (2m, less on battery)" - case .manual: return "Manual" - case .oneMinute: return "1 minute" - case .fiveMinutes: return "5 minutes" - case .fifteenMinutes: return "15 minutes" + case .auto: return L("Auto (2m, less on battery)") + case .manual: return L("Manual") + case .oneMinute: return L("1 minute") + case .fiveMinutes: return L("5 minutes") + case .fifteenMinutes: return L("15 minutes") } } diff --git a/mac/Sources/CodeBurnMenubar/Localization.swift b/mac/Sources/CodeBurnMenubar/Localization.swift new file mode 100644 index 000000000..70798708d --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Localization.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Localization for the menubar app (#1219). No third-party library: one +/// `Localizable.strings` table per locale, shipped as SwiftPM target resources +/// and resolved by AppKit against the user's system language. +/// +/// # Why every lookup is explicit +/// +/// SwiftPM emits target resources into a *sibling* bundle +/// (`CodeBurnMenubar_CodeBurnMenubar.bundle`, copied into the app's +/// `Contents/Resources` by the packaging scripts), never into the app +/// bundle's resource root. `Bundle.main` therefore has no `.lproj` at all, so +/// the implicit `LocalizedStringKey` path that SwiftUI uses for +/// `Text("literal")` would always miss. Routing every string through `L(_:)`, +/// which names `Bundle.module`, is the one form that resolves identically in +/// `swift run`, in `swift test`, and in the packaged `.app`. +/// +/// # Keys are the English copy +/// +/// The key *is* the English string (`"Refresh Now"`, `"%lld sessions"`), so +/// English stays the development language: a key with no translation renders +/// as correct English rather than a visible identifier, and `en.lproj` is an +/// identity table kept only so the bundle advertises `en` as a localization +/// and so the catalog can be diffed against a translation. +/// +/// # What is not translated +/// +/// Provider and model names (`Claude`, `Codex`, `Gemini`, `Sonnet`), units +/// (`tok/s`, `ACU`, `%`), currency codes, shell commands, and anything the +/// `codeburn` CLI itself produces (payload labels, activity and project names, +/// error text forwarded from the subprocess) stay verbatim. Numbers, dates, +/// and currency keep going through the locale-aware formatters they already +/// used — `L(_:_:)` only substitutes already-formatted values. +enum L10n { + /// The bundle that carries the `.lproj` tables. + static let bundle: Bundle = .module + + /// Table name, i.e. `Localizable.strings`. + static let table = "Localizable" + + /// Locales shipped today. Mirrored by `CFBundleLocalizations` in the two + /// packaging scripts once a second language exists. + static let supportedLocalizations = ["en"] +} + +/// Localized copy for `key`, falling back to the key (its English text) when a +/// translation is missing. +func L(_ key: String) -> String { + L10n.bundle.localizedString(forKey: key, value: key, table: L10n.table) +} + +/// Localized format string for `key`, filled with `arguments`. +/// +/// The specifiers in the key are part of the contract between the tables: +/// `%@` for an already-formatted value (currency, token count, provider name), +/// `%lld` for a plain `Int`. Deliberately formatted without a locale so the +/// substituted values keep exactly the grouping the existing formatters chose +/// — re-grouping a `%lld` here would disagree with the +/// `asCurrency()` / `asThousandsSeparated()` output next to it. +func L(_ key: String, _ arguments: CVarArg...) -> String { + String(format: L(key), arguments: arguments) +} diff --git a/mac/Sources/CodeBurnMenubar/MenubarScope.swift b/mac/Sources/CodeBurnMenubar/MenubarScope.swift index 0ffe6bb9c..2e2898024 100644 --- a/mac/Sources/CodeBurnMenubar/MenubarScope.swift +++ b/mac/Sources/CodeBurnMenubar/MenubarScope.swift @@ -8,6 +8,15 @@ enum MenubarScope: String, CaseIterable, Identifiable, Sendable { var id: String { rawValue } + /// What the scope toggle shows. `rawValue` stays the stable identity used + /// for `id`; only this is translated. + var displayLabel: String { + switch self { + case .local: L("Local") + case .combined: L("Combined") + } + } + var cliArg: String { switch self { case .local: "local" diff --git a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..e7ade8510 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings @@ -0,0 +1,603 @@ +/* CodeBurn Menubar — English (development language). + * + * Keys ARE the English copy: a key with no translation renders as correct + * English instead of a visible identifier, and this table exists so the + * resource bundle advertises `en` as a localization and so + * LocalizationCatalogTests can diff it against zh-Hans. + * + * Every entry is reached through `L(_:)` / `L(_:_:)` (see Localization.swift). + * Format specifiers are part of the contract between the two tables: `%@` is an + * already-formatted value (currency, token count, provider or model name), + * `%lld` a plain Int, `%%` a literal percent sign. Keep them identical, in the + * same order, in every locale. + * + * Deliberately NOT in here: provider, model and plan names; units (tok, tok/s, + * ACU, cr); currency codes; shell commands and file paths; and anything the + * `codeburn` CLI produces (payload labels, activity/project names, forwarded + * error text). + */ + +/* MARK: Currencies, filters, periods and insight tabs */ +"US Dollar" = "US Dollar"; +"British Pound" = "British Pound"; +"Euro" = "Euro"; +"Australian Dollar" = "Australian Dollar"; +"Canadian Dollar" = "Canadian Dollar"; +"New Zealand Dollar" = "New Zealand Dollar"; +"Japanese Yen" = "Japanese Yen"; +"Chinese Yuan" = "Chinese Yuan"; +"Swiss Franc" = "Swiss Franc"; +"Indian Rupee" = "Indian Rupee"; +"Brazilian Real" = "Brazilian Real"; +"Swedish Krona" = "Swedish Krona"; +"Singapore Dollar" = "Singapore Dollar"; +"Hong Kong Dollar" = "Hong Kong Dollar"; +"South Korean Won" = "South Korean Won"; +"Mexican Peso" = "Mexican Peso"; +"South African Rand" = "South African Rand"; +"Danish Krone" = "Danish Krone"; +"Romanian Leu" = "Romanian Leu"; +"All" = "All"; +"Plan" = "Plan"; +"Trend" = "Trend"; +"Forecast" = "Forecast"; +"Calendar" = "Calendar"; +"Pulse" = "Pulse"; +"Stats" = "Stats"; +"Optimize" = "Optimize"; +"Today" = "Today"; +"7D" = "7D"; +"30D" = "30D"; +"Month" = "Month"; +"6M" = "6M"; +"Life" = "Life"; +"Week" = "Week"; +"30 Days" = "30 Days"; +"6 Months" = "6 Months"; +"Lifetime" = "Lifetime"; + +/* MARK: Scope toggle */ +"Local" = "Local"; +"Combined" = "Combined"; + +/* MARK: Accent presets */ +"Ember" = "Ember"; +"Blue" = "Blue"; +"Purple" = "Purple"; +"Pink" = "Pink"; +"Red" = "Red"; +"Orange" = "Orange"; +"Yellow" = "Yellow"; +"Green" = "Green"; +"Graphite" = "Graphite"; + +/* MARK: Session counts */ +"Older session logs may be unavailable." = "Older session logs may be unavailable."; +"Session identities are unavailable across devices." = "Session identities are unavailable across devices."; +"Session count unavailable" = "Session count unavailable"; +"At least 1 session" = "At least 1 session"; +"At least %lld sessions" = "At least %lld sessions"; +"1 session" = "1 session"; +"%lld sessions" = "%lld sessions"; +"Unavailable" = "Unavailable"; +"≥%lld sess" = "≥%lld sess"; +"%lld sess" = "%lld sess"; + +/* MARK: Quota windows and reset countdowns */ +"Connect" = "Connect"; +"Reconnect" = "Reconnect"; +"Add API Key" = "Add API Key"; +"now" = "now"; +"%lldd %lldh" = "%lldd %lldh"; +"%lldh %lldm" = "%lldh %lldm"; +"%lldm" = "%lldm"; + +/* MARK: Usage refresh cadence */ +"Auto (2m, less on battery)" = "Auto (2m, less on battery)"; +"Manual" = "Manual"; +"1 minute" = "1 minute"; +"5 minutes" = "5 minutes"; +"15 minutes" = "15 minutes"; + +/* MARK: Quota refresh cadence */ +"2 minutes" = "2 minutes"; + +/* MARK: Capacity Dock appearance */ +"Liquid Glass" = "Liquid Glass"; +"Circle" = "Circle"; +"Squircle" = "Squircle"; + +/* MARK: Provider connection guidance */ +"Installed app or CLI" = "Installed app or CLI"; +"OAuth" = "OAuth"; +"API or cloud credentials" = "API or cloud credentials"; +"Browser session" = "Browser session"; +"Localhost service" = "Localhost service"; +"No sign-in required" = "No sign-in required"; +"Enter an API key or token below, then press Save & Connect." = "Enter an API key or token below, then press Save & Connect."; +"Sign in to %@ in a supported browser, then click Retry." = "Sign in to %@ in a supported browser, then click Retry."; +"Sign in with the %@ app or CLI, then click Retry." = "Sign in with the %@ app or CLI, then click Retry."; +"Complete %@ OAuth, then click Retry." = "Complete %@ OAuth, then click Retry."; +"Start the local %@ service, then click Retry." = "Start the local %@ service, then click Retry."; +"Enter the required API or cloud credentials below, then press Save & Connect." = "Enter the required API or cloud credentials below, then press Save & Connect."; +"No sign-in is required. Click Retry to refresh quota." = "No sign-in is required. Click Retry to refresh quota."; +"Add an API key or token in Provider Settings." = "Add an API key or token in Provider Settings."; +"Add the required API or cloud credentials in Provider Settings." = "Add the required API or cloud credentials in Provider Settings."; + +/* MARK: Provider reconnect copy */ +"Reconnect %@" = "Reconnect %@"; +"Claude Code credentials need to be refreshed." = "Claude Code credentials need to be refreshed."; +"Open Claude Code in your terminal and type `/login`, then click Reconnect." = "Open Claude Code in your terminal and type `/login`, then click Reconnect."; +"Codex credentials need to be refreshed." = "Codex credentials need to be refreshed."; +"Run `codex login` in your terminal, then click Reconnect." = "Run `codex login` in your terminal, then click Reconnect."; +"Kimi Code credentials need to be refreshed." = "Kimi Code credentials need to be refreshed."; +"Run the Kimi CLI once to refresh your login, then click Reconnect." = "Run the Kimi CLI once to refresh your login, then click Reconnect."; +"Gemini credentials need to be refreshed." = "Gemini credentials need to be refreshed."; +"Run the Gemini CLI once to refresh your login, then click Reconnect." = "Run the Gemini CLI once to refresh your login, then click Reconnect."; +"Copilot credentials need to be refreshed." = "Copilot credentials need to be refreshed."; +"Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect." = "Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect."; +"The local Antigravity service is unavailable." = "The local Antigravity service is unavailable."; +"Start the Antigravity app, then click Reconnect." = "Start the Antigravity app, then click Reconnect."; +"%@ credentials need to be refreshed." = "%@ credentials need to be refreshed."; +"Sign in to %@ again, then retry." = "Sign in to %@ again, then retry."; + +/* MARK: Copilot quota copy */ +"No Copilot credentials found" = "No Copilot credentials found"; +"Sign in via an editor's Copilot plugin first. Then click Try Again." = "Sign in via an editor's Copilot plugin first. Then click Try Again."; +"Copilot quota tracking disconnected" = "Copilot quota tracking disconnected"; +"Your Copilot credentials are untouched. Click Connect to resume." = "Your Copilot credentials are untouched. Click Connect to resume."; +"Usage tracking still works. For live quota, sign in with the Copilot CLI or gh auth login, or paste a token below, then click Connect." = "Usage tracking still works. For live quota, sign in with the Copilot CLI or gh auth login, or paste a token below, then click Connect."; +"Quota tracking disconnected. Credentials are untouched. Click Connect to resume." = "Quota tracking disconnected. Credentials are untouched. Click Connect to resume."; + +/* MARK: Updates */ +"Update Check Failed" = "Update Check Failed"; +"CLI Update Failed" = "CLI Update Failed"; +"Menubar Update Failed" = "Menubar Update Failed"; +"CodeBurn could not check GitHub for updates." = "CodeBurn could not check GitHub for updates."; +"CodeBurn could not update the CLI." = "CodeBurn could not update the CLI."; +"CodeBurn could not update the menubar app." = "CodeBurn could not update the menubar app."; +"Click to retry the update check." = "Click to retry the update check."; +"Click to retry the update." = "Click to retry the update."; +"Updating..." = "Updating..."; +"Update" = "Update"; +"Update the CLI and menubar to the latest release" = "Update the CLI and menubar to the latest release"; +"CodeBurn %@ available" = "CodeBurn %@ available"; +"App and CLI %@ updates are ready. Click to install." = "App and CLI %@ updates are ready. Click to install."; +"Click to install the update." = "Click to install the update."; +"CodeBurn CLI %@ available" = "CodeBurn CLI %@ available"; +"Could not find the package manager for %@. Run “%@” manually, then try again." = "Could not find the package manager for %@. Run “%@” manually, then try again."; +"the CLI" = "the CLI"; +"CLI update failed (exit %lld)" = "CLI update failed (exit %lld)"; +"Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again." = "Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again."; +"Update failed (exit %lld)" = "Update failed (exit %lld)"; +"GitHub returned HTTP %lld." = "GitHub returned HTTP %lld."; +"No mac-v release with a menubar zip and checksum was found." = "No mac-v release with a menubar zip and checksum was found."; + +/* MARK: Terminal picker */ +"Terminal (macOS default)" = "Terminal (macOS default)"; + +/* MARK: Status-item menu, tooltip and update alerts */ +"%@ credits" = "%@ credits"; +"CodeBurn %@ · %lld of %lld devices reporting" = "CodeBurn %@ · %lld of %lld devices reporting"; +"Settings…" = "Settings…"; +"Capacity Dock Settings…" = "Capacity Dock Settings…"; +"Refresh Now" = "Refresh Now"; +"Check for Updates" = "Check for Updates"; +"About CodeBurn" = "About CodeBurn"; +"Quit CodeBurn" = "Quit CodeBurn"; +"Today · no usage yet" = "Today · no usage yet"; +"1 call" = "1 call"; +"%lld calls" = "%lld calls"; +"Today · %@ · %@" = "Today · %@ · %@"; +"CodeBurn Settings" = "CodeBurn Settings"; +"Update Available" = "Update Available"; +"%@ is available (you have %@)." = "%@ is available (you have %@)."; +"%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force" = "%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force"; +"%@ Run:\n\ncodeburn menubar --force" = "%@ Run:\n\ncodeburn menubar --force"; +"Up to Date" = "Up to Date"; +"You're on the latest version (%@)." = "You're on the latest version (%@)."; +"OK" = "OK"; + +/* MARK: Popover chrome, header, footer and banners */ +"This total may be incomplete." = "This total may be incomplete."; +"Claude config" = "Claude config"; +"No %@ data for %@" = "No %@ data for %@"; +"Couldn't load %@" = "Couldn't load %@"; +"Retry" = "Retry"; +"Loading %@…" = "Loading %@…"; +"Your AI Bill, Itemized" = "Your AI Bill, Itemized"; +"%@ over limit (%lld%%)" = "%@ over limit (%lld%%)"; +"%@ of quota used" = "%@ of quota used"; +"Change accent color" = "Change accent color"; +"CLI %@ available" = "CLI %@ available"; +"Update now" = "Update now"; +"Update the CLI (and the menubar if one is available) automatically" = "Update the CLI (and the menubar if one is available) automatically"; +"Copy update command to clipboard" = "Copy update command to clipboard"; +"Enjoying CodeBurn?" = "Enjoying CodeBurn?"; +"Star us on GitHub" = "Star us on GitHub"; +"Hide this banner" = "Hide this banner"; +"CSV (folder)" = "CSV (folder)"; +"JSON" = "JSON"; +"Export" = "Export"; +"Full Report" = "Full Report"; + +/* MARK: Hero section */ +"%@ call" = "%@ call"; +"%@ calls" = "%@ calls"; +"Daily budget of %@ exceeded" = "Daily budget of %@ exceeded"; +"Combined unavailable · showing local" = "Combined unavailable · showing local"; +"Combined · %@" = "Combined · %@"; +"Saved %@ with local models" = "Saved %@ with local models"; +"%lld of %lld devices" = "%lld of %lld devices"; +"%@ · local" = "%@ · local"; + +/* MARK: Period strip and date picker */ +"Clear" = "Clear"; +"Done" = "Done"; +"Pick dates" = "Pick dates"; +"1 day" = "1 day"; +"%lld days" = "%lld days"; + +/* MARK: Provider tab strip and quota popover */ +"Show previous providers" = "Show previous providers"; +"Show next providers" = "Show next providers"; +"Loading…" = "Loading…"; +"Sign in with `codex` (ChatGPT mode) to track quota." = "Sign in with `codex` (ChatGPT mode) to track quota."; +"Sign in to Claude Code to track quota." = "Sign in to Claude Code to track quota."; +"Sign in to track quota." = "Sign in to track quota."; +"%@ usage" = "%@ usage"; +"stale" = "stale"; +"retrying" = "retrying"; + +/* MARK: Activity section */ +"Activity" = "Activity"; +"Cost" = "Cost"; +"Turns" = "Turns"; +"1-shot" = "1-shot"; + +/* MARK: Models section */ +"Models" = "Models"; +"Saved" = "Saved"; +"Calls" = "Calls"; +"Tokens" = "Tokens"; +"%@ in" = "%@ in"; +"%@ out" = "%@ out"; +"%@%% cache hit" = "%@%% cache hit"; + +/* MARK: Pull requests section */ +"Pull requests" = "Pull requests"; + +/* MARK: Tooling section */ +"Tooling" = "Tooling"; +"Tools" = "Tools"; +"Skills & Agents" = "Skills & Agents"; +"MCP Servers" = "MCP Servers"; + +/* MARK: Tips section */ +"Tips for you" = "Tips for you"; +"%lld signals" = "%lld signals"; +"Open Full Optimize" = "Open Full Optimize"; +"Cache hit at %lld%% — most prompts reuse cache" = "Cache hit at %lld%% — most prompts reuse cache"; +"%lld%% one-shot — edits landing first try" = "%lld%% one-shot — edits landing first try"; +"Spend down %lld%% vs last 7 days" = "Spend down %lld%% vs last 7 days"; +"%lld-day usage streak" = "%lld-day usage streak"; +"Spend up %lld%% vs prior 7 days" = "Spend up %lld%% vs prior 7 days"; +"Cache hit only %lld%% — paying for cold prompts" = "Cache hit only %lld%% — paying for cold prompts"; +"%lld%% one-shot — lots of iteration" = "%lld%% one-shot — lots of iteration"; +"On pace for %@ this month (+%lld%% vs last)" = "On pace for %@ this month (+%lld%% vs last)"; +"What's working" = "What's working"; +"What to improve" = "What to improve"; +"Risks" = "Risks"; + +/* MARK: Insight tabs (trend, calendar, forecast, pulse, stats, optimize, plan) */ +"Last %lld days" = "Last %lld days"; +"%@%% vs prior %lldd" = "%@%% vs prior %lldd"; +"Avg/day" = "Avg/day"; +"Peak" = "Peak"; +"Yesterday" = "Yesterday"; +"%@ tokens" = "%@ tokens"; +"%@ on %@" = "%@ on %@"; +"Daily activity" = "Daily activity"; +"%lld active days" = "%lld active days"; +"Peak day" = "Peak day"; +"Avg active" = "Avg active"; +"Streak" = "Streak"; +"%lldd" = "%lldd"; +"Mon" = "Mon"; +"Wed" = "Wed"; +"Fri" = "Fri"; +"Sun" = "Sun"; +"Daily detail" = "Daily detail"; +"Hover a day" = "Hover a day"; +"Future day" = "Future day"; +"No tracked usage" = "No tracked usage"; +"%@: future day" = "%@: future day"; +"%@: no tracked usage" = "%@: no tracked usage"; +"%@: %@, %lld calls, %@ tokens" = "%@: %@, %lld calls, %@ tokens"; +"Month-to-date" = "Month-to-date"; +"On pace for" = "On pace for"; +"Avg/day (this wk)" = "Avg/day (this wk)"; +"Last 7d" = "Last 7d"; +"no prior month" = "no prior month"; +"%@%% vs last month (%@)" = "%@%% vs last month (%@)"; +"Cache hit" = "Cache hit"; +"Cost / session" = "Cost / session"; +"Cost/edit" = "Cost/edit"; +"Save ~%@ / ~%@ tokens · 1 finding" = "Save ~%@ / ~%@ tokens · 1 finding"; +"Save ~%@ / ~%@ tokens · %lld findings" = "Save ~%@ / ~%@ tokens · %lld findings"; +"Favorite model" = "Favorite model"; +"Active days (month)" = "Active days (month)"; +"Most active day" = "Most active day"; +"Peak day spend" = "Peak day spend"; +"Sessions" = "Sessions"; +"Current streak" = "Current streak"; +"Longest streak" = "Longest streak"; +"Tracked spend (last %lld days)" = "Tracked spend (last %lld days)"; +"Costliest session" = "Costliest session"; +"Retry tax" = "Retry tax"; +"%lld retries across %lld edits" = "%lld retries across %lld edits"; +"%@ ret/edit" = "%@ ret/edit"; +"Hides session details" = "Hides session details"; +"Shows session details" = "Shows session details"; +"Expanded" = "Expanded"; +"Collapsed" = "Collapsed"; +" %lld call" = " %lld call"; +" %lld calls" = " %lld calls"; +"Potential savings" = "Potential savings"; +"%lld%% of spend" = "%lld%% of spend"; +"could be optimized" = "could be optimized"; +"Routing waste" = "Routing waste"; +"vs %@ @ %@/edit" = "vs %@ @ %@/edit"; +"Connect Claude subscription" = "Connect Claude subscription"; +"CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." = "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically."; +"Reading Claude credentials..." = "Reading Claude credentials..."; +"No Claude credentials found" = "No Claude credentials found"; +"Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again." = "Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again."; +"Anthropic temporarily unreachable. Retrying." = "Anthropic temporarily unreachable. Retrying."; +"Reconnect Claude" = "Reconnect Claude"; +"Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect." = "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect."; +"Resets %@" = "Resets %@"; +"5-hour window" = "5-hour window"; +"7-day total" = "7-day total"; +"7-day Opus" = "7-day Opus"; +"7-day Sonnet" = "7-day Sonnet"; +"7-day %@" = "7-day %@"; +"Try Again" = "Try Again"; +"Couldn't load plan data" = "Couldn't load plan data"; +"Connect ChatGPT subscription" = "Connect ChatGPT subscription"; +"CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically." = "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically."; +"Reading Codex CLI credentials..." = "Reading Codex CLI credentials..."; +"No Codex credentials found" = "No Codex credentials found"; +"Sign in with Codex first: run `codex login` in your terminal. Then click Try Again." = "Sign in with Codex first: run `codex login` in your terminal. Then click Try Again."; +"ChatGPT temporarily unreachable. Retrying." = "ChatGPT temporarily unreachable. Retrying."; +"Reconnect Codex" = "Reconnect Codex"; +"Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect." = "Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect."; +"%@ window" = "%@ window"; +"Credits" = "Credits"; +"Unlimited" = "Unlimited"; +"Limit resets" = "Limit resets"; +"%lld available" = "%lld available"; +"%@ · next expires %@" = "%@ · next expires %@"; +"No Kimi Code credentials found" = "No Kimi Code credentials found"; +"Sign in with the Kimi CLI first. Then click Try Again." = "Sign in with the Kimi CLI first. Then click Try Again."; +"Reading Kimi Code credentials..." = "Reading Kimi Code credentials..."; +"Kimi temporarily unreachable. Retrying." = "Kimi temporarily unreachable. Retrying."; +"Refresh Kimi Code login" = "Refresh Kimi Code login"; +"Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect." = "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect."; +"Parallel sessions" = "Parallel sessions"; +"Login idle. Run the Kimi CLI to refresh." = "Login idle. Run the Kimi CLI to refresh."; +"as of %@" = "as of %@"; +"No Gemini credentials found" = "No Gemini credentials found"; +"Sign in with the Gemini CLI first. Then click Try Again." = "Sign in with the Gemini CLI first. Then click Try Again."; +"Reading Gemini credentials..." = "Reading Gemini credentials..."; +"Gemini temporarily unreachable. Retrying." = "Gemini temporarily unreachable. Retrying."; +"Refresh Gemini login" = "Refresh Gemini login"; +"Your Gemini login has expired. Run the Gemini CLI once to refresh it, then click Reconnect." = "Your Gemini login has expired. Run the Gemini CLI once to refresh it, then click Reconnect."; +"Login idle. Run the Gemini CLI to refresh." = "Login idle. Run the Gemini CLI to refresh."; +"Reading Copilot credentials..." = "Reading Copilot credentials..."; +"GitHub temporarily unreachable. Retrying." = "GitHub temporarily unreachable. Retrying."; +"Refresh Copilot login" = "Refresh Copilot login"; +"Your Copilot sign-in has expired. Sign in via an editor's Copilot plugin again, then click Reconnect." = "Your Copilot sign-in has expired. Sign in via an editor's Copilot plugin again, then click Reconnect."; +"Login idle. Sign in via an editor's Copilot plugin to refresh." = "Login idle. Sign in via an editor's Copilot plugin to refresh."; +"No local Antigravity server found" = "No local Antigravity server found"; +"Start the Antigravity app, then click Try Again." = "Start the Antigravity app, then click Try Again."; +"Probing the local Antigravity server..." = "Probing the local Antigravity server..."; +"Local Antigravity server unreachable. Retrying." = "Local Antigravity server unreachable. Retrying."; +"Reconnect Antigravity" = "Reconnect Antigravity"; +"Server disconnected. Start the Antigravity app to refresh." = "Server disconnected. Start the Antigravity app to refresh."; +"On pace" = "On pace"; +"On pace: %@ at reset" = "On pace: %@ at reset"; +"%@%% in deficit" = "%@%% in deficit"; +"%@%% in reserve" = "%@%% in reserve"; +"%@ · hits 100%% %@" = "%@ · hits 100%% %@"; +"%@ · %@ at reset" = "%@ · %@ at reset"; +"On pace: %@ at reset · hits 100%% %@" = "On pace: %@ at reset · hits 100%% %@"; +"Based on last cycle: %@" = "Based on last cycle: %@"; +"in %lldm" = "in %lldm"; +"in %lldh" = "in %lldh"; +"in %lldd" = "in %lldd"; + +/* MARK: Capacity Dock */ +"Dock to Edge" = "Dock to Edge"; +"Left" = "Left"; +"Right" = "Right"; +"Top" = "Top"; +"Bottom" = "Bottom"; +"Hide Capacity Dock" = "Hide Capacity Dock"; +"Capacity Dock" = "Capacity Dock"; +"Unknown" = "Unknown"; +"Click to keep Capacity Dock expanded" = "Click to keep Capacity Dock expanded"; +"none running" = "none running"; +"1 running" = "1 running"; +"%lld running" = "%lld running"; +"%@ left" = "%@ left"; +"burned" = "burned"; +"today %@ of %@" = "today %@ of %@"; +"no budget set" = "no budget set"; +"Refreshing…" = "Refreshing…"; +"Last known usage · refreshing" = "Last known usage · refreshing"; +"Last known usage · retrying" = "Last known usage · retrying"; +"Not connected" = "Not connected"; +"Reconnect required" = "Reconnect required"; + +/* MARK: Settings */ +"General" = "General"; +"Providers" = "Providers"; +"%lld on" = "%lld on"; +"About" = "About"; +"Settings" = "Settings"; +"Search providers" = "Search providers"; +"Enter an amount above, or the alert stays off." = "Enter an amount above, or the alert stays off."; +"Flame icon turns yellow when today's tokens pass the daily budget." = "Flame icon turns yellow when today's tokens pass the daily budget."; +"Flame icon turns yellow when today's cost pass the daily budget." = "Flame icon turns yellow when today's cost pass the daily budget."; +"Display" = "Display"; +"Currency" = "Currency"; +"Metric" = "Metric"; +"Cost ($)" = "Cost ($)"; +"Tokens (↑↓)" = "Tokens (↑↓)"; +"Total Tokens" = "Total Tokens"; +"Credits (Codex)" = "Credits (Codex)"; +"Icon Only" = "Icon Only"; +"Period" = "Period"; +"Scope" = "Scope"; +"Accent" = "Accent"; +"Usage Refresh" = "Usage Refresh"; +"Update every" = "Update every"; +"How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now." = "How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now."; +"Updates" = "Updates"; +"Notify me about updates" = "Notify me about updates"; +"Posts a notification when a new CodeBurn release is available. Click it to install." = "Posts a notification when a new CodeBurn release is available. Click it to install."; +"Terminal" = "Terminal"; +"Open commands in" = "Open commands in"; +"%@ (not installed)" = "%@ (not installed)"; +"Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed." = "Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed."; +"Alerts" = "Alerts"; +"Daily budget" = "Daily budget"; +"Off" = "Off"; +"Custom…" = "Custom…"; +"Amount" = "Amount"; +"M tokens" = "M tokens"; +"Show Capacity Dock" = "Show Capacity Dock"; +"Resting provider" = "Resting provider"; +"Size" = "Size"; +"Capacity Dock size" = "Capacity Dock size"; +"Appearance" = "Appearance"; +"Gauge shape" = "Gauge shape"; +"Dock providers" = "Dock providers"; +"Connect a provider from its sidebar page to make it available here." = "Connect a provider from its sidebar page to make it available here."; +"Needs attention" = "Needs attention"; +"Connected providers and anything already shown in the dock appear here, so a provider can always be removed even if its connection later fails." = "Connected providers and anything already shown in the dock appear here, so a provider can always be removed even if its connection later fails."; +"Connection" = "Connection"; +"Config Directories" = "Config Directories"; +"Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list." = "Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list."; +"Quota Refresh" = "Quota Refresh"; +"Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand." = "Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand."; +"Connected" = "Connected"; +"Backing off" = "Backing off"; +"Connecting…" = "Connecting…"; +"Ready" = "Ready"; +"Plan: %@" = "Plan: %@"; +"Live quota tracked from Anthropic." = "Live quota tracked from Anthropic."; +"Anthropic rate-limited; auto-retrying." = "Anthropic rate-limited; auto-retrying."; +"macOS may ask permission to read your credentials." = "macOS may ask permission to read your credentials."; +"Background refresh in progress." = "Background refresh in progress."; +"Tap Load Quota to fetch live usage from Anthropic." = "Tap Load Quota to fetch live usage from Anthropic."; +"Click Connect to read your Claude Code credentials and start tracking quota." = "Click Connect to read your Claude Code credentials and start tracking quota."; +"Disconnect" = "Disconnect"; +"Disconnect Claude?" = "Disconnect Claude?"; +"Cancel" = "Cancel"; +"CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your Claude Code credential is untouched. Claude Code keeps working." = "CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your Claude Code credential is untouched. Claude Code keeps working."; +"Load Quota" = "Load Quota"; +"No extra directories. Tracking the default `~/.claude`." = "No extra directories. Tracking the default `~/.claude`."; +"Remove" = "Remove"; +"Add Directory…" = "Add Directory…"; +"Add" = "Add"; +"Choose one or more Claude config directories (each containing a `projects` folder)." = "Choose one or more Claude config directories (each containing a `projects` folder)."; +"Codex live-quota tracking follows the authoritative `~/.codex/auth.json` session directly and does not create a second Keychain copy. A legacy CodeBurn Keychain item, when present, is read only as a migration fallback. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead." = "Codex live-quota tracking follows the authoritative `~/.codex/auth.json` session directly and does not create a second Keychain copy. A legacy CodeBurn Keychain item, when present, is read only as a migration fallback. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead."; +"How it works" = "How it works"; +"Couldn't load Codex quota" = "Couldn't load Codex quota"; +"Live quota tracked from chatgpt.com." = "Live quota tracked from chatgpt.com."; +"Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking." = "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking."; +"Run `codex login` in your terminal to sign in again, then click Reconnect." = "Run `codex login` in your terminal to sign in again, then click Reconnect."; +"ChatGPT rate-limited; auto-retrying." = "ChatGPT rate-limited; auto-retrying."; +"Reading ~/.codex/auth.json." = "Reading ~/.codex/auth.json."; +"Tap Load Quota to fetch live usage from chatgpt.com." = "Tap Load Quota to fetch live usage from chatgpt.com."; +"Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json." = "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json."; +"Disconnect Codex?" = "Disconnect Codex?"; +"CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your ~/.codex/auth.json is untouched. Codex CLI keeps working." = "CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your ~/.codex/auth.json is untouched. Codex CLI keeps working."; +"Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly. Nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect." = "Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly. Nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect."; +"Login refresh required" = "Login refresh required"; +"Couldn't load Kimi quota" = "Couldn't load Kimi quota"; +"Live quota tracked from api.kimi.com." = "Live quota tracked from api.kimi.com."; +"Kimi rate-limited; auto-retrying." = "Kimi rate-limited; auto-retrying."; +"Reading ~/.kimi-code credentials." = "Reading ~/.kimi-code credentials."; +"Tap Load Quota to fetch live usage from api.kimi.com." = "Tap Load Quota to fetch live usage from api.kimi.com."; +"Sign in with the Kimi CLI first, then click Connect." = "Sign in with the Kimi CLI first, then click Connect."; +"Disconnect Kimi Code?" = "Disconnect Kimi Code?"; +"CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched. The Kimi CLI keeps working." = "CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched. The Kimi CLI keeps working."; +"Gemini live-quota tracking reads `~/.gemini/oauth_creds.json` read-only. Nothing is copied or stored, and tokens stay in memory. If the connection shows as expired, run the Gemini CLI once to refresh your login, then click Reconnect." = "Gemini live-quota tracking reads `~/.gemini/oauth_creds.json` read-only. Nothing is copied or stored, and tokens stay in memory. If the connection shows as expired, run the Gemini CLI once to refresh your login, then click Reconnect."; +"Couldn't load Gemini quota" = "Couldn't load Gemini quota"; +"Live quota tracked from Google Code Assist." = "Live quota tracked from Google Code Assist."; +"Gemini rate-limited; auto-retrying." = "Gemini rate-limited; auto-retrying."; +"Reading ~/.gemini credentials." = "Reading ~/.gemini credentials."; +"Tap Load Quota to fetch live usage from Google Code Assist." = "Tap Load Quota to fetch live usage from Google Code Assist."; +"Sign in with the Gemini CLI first, then click Connect." = "Sign in with the Gemini CLI first, then click Connect."; +"Disconnect Gemini?" = "Disconnect Gemini?"; +"CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working." = "CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working."; +"Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token." = "Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token."; +"GitHub token" = "GitHub token"; +"Save & Connect" = "Save & Connect"; +"Clear Token" = "Clear Token"; +"Paste a token" = "Paste a token"; +"Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota." = "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota."; +"Couldn't load Copilot quota" = "Couldn't load Copilot quota"; +"Live quota tracked from api.github.com." = "Live quota tracked from api.github.com."; +"Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." = "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect."; +"GitHub rate-limited; auto-retrying." = "GitHub rate-limited; auto-retrying."; +"Looking for a GitHub token on this Mac." = "Looking for a GitHub token on this Mac."; +"Tap Load Quota to fetch live usage from api.github.com." = "Tap Load Quota to fetch live usage from api.github.com."; +"Disconnect Copilot?" = "Disconnect Copilot?"; +"CodeBurn will stop tracking Copilot quota. Every credential it read stays untouched, and your Copilot clients keep working." = "CodeBurn will stop tracking Copilot quota. Every credential it read stays untouched, and your Copilot clients keep working."; +"Antigravity live-quota tracking talks to the Antigravity app's local language server on 127.0.0.1 only. Nothing leaves the machine and no credential files are read. If it shows as disconnected, start the Antigravity app, then click Reconnect." = "Antigravity live-quota tracking talks to the Antigravity app's local language server on 127.0.0.1 only. Nothing leaves the machine and no credential files are read. If it shows as disconnected, start the Antigravity app, then click Reconnect."; +"Couldn't load Antigravity quota" = "Couldn't load Antigravity quota"; +"Live quota tracked from the local Antigravity server." = "Live quota tracked from the local Antigravity server."; +"Local probe failed; auto-retrying." = "Local probe failed; auto-retrying."; +"Probing the local Antigravity language server." = "Probing the local Antigravity language server."; +"Tap Load Quota to probe the local Antigravity server." = "Tap Load Quota to probe the local Antigravity server."; +"Start the Antigravity app first, then click Connect." = "Start the Antigravity app first, then click Connect."; +"No local Antigravity server found. Start the Antigravity app, then click Reconnect." = "No local Antigravity server found. Start the Antigravity app, then click Reconnect."; +"Disconnect Antigravity?" = "Disconnect Antigravity?"; +"CodeBurn will stop tracking Antigravity quota. Nothing is read from or written to disk. The Antigravity app keeps working." = "CodeBurn will stop tracking Antigravity quota. Nothing is read from or written to disk. The Antigravity app keeps working."; +"Not yet supported" = "Not yet supported"; +"Automatic connection uses the provider's existing app, CLI, OAuth, browser session, or environment credentials first. CodeBurn does not copy those source credentials into its Keychain." = "Automatic connection uses the provider's existing app, CLI, OAuth, browser session, or environment credentials first. CodeBurn does not copy those source credentials into its Keychain."; +"Authentication methods are listed for reference. A native CodeBurn quota adapter is required before this provider can connect to Capacity Dock." = "Authentication methods are listed for reference. A native CodeBurn quota adapter is required before this provider can connect to Capacity Dock."; +"Authentication methods" = "Authentication methods"; +"Source" = "Source"; +"API key or token" = "API key or token"; +"Clear Override" = "Clear Override"; +"Loading saved provider credential" = "Loading saved provider credential"; +"Connection override" = "Connection override"; +"Overrides are optional and are saved only when you press Save & Connect. Secret values use one CodeBurn-owned Keychain item for this provider; background reads suppress authentication UI." = "Overrides are optional and are saved only when you press Save & Connect. Secret values use one CodeBurn-owned Keychain item for this provider; background reads suppress authentication UI."; +"Remove saved override" = "Remove saved override"; +"Saved data" = "Saved data"; +"This credential predates a live CodeBurn quota adapter and is not treated as a connection." = "This credential predates a live CodeBurn quota adapter and is not treated as a connection."; +"Quota adapter not available" = "Quota adapter not available"; +"Retrying" = "Retrying"; +"%@ is catalogued, but CodeBurn cannot fetch its live quota yet." = "%@ is catalogued, but CodeBurn cannot fetch its live quota yet."; +"Live quota is available to Capacity Dock." = "Live quota is available to Capacity Dock."; +"Waiting for quota data." = "Waiting for quota data."; +"Automatic" = "Automatic"; +"CLI" = "CLI"; +"API" = "API"; +"ACU Conversion" = "ACU Conversion"; +"USD per ACU" = "USD per ACU"; +"Save" = "Save"; +"CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost." = "CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost."; +"Saved. Refresh CodeBurn to recalculate Devin cost." = "Saved. Refresh CodeBurn to recalculate Devin cost."; +"Version %@" = "Version %@"; +"%@ is available. Choose Check for Updates in the CodeBurn menu to install it." = "%@ is available. Choose Check for Updates in the CodeBurn menu to install it."; +"GitHub" = "GitHub"; +"Website" = "Website"; +"Issues" = "Issues"; +"Links" = "Links"; +"© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License." = "© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License."; diff --git a/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift b/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift index 7712bd558..9ac87e058 100644 --- a/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift +++ b/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift @@ -23,7 +23,7 @@ enum PreferredTerminal: String, CaseIterable, Identifiable, Sendable { var label: String { switch self { - case .terminal: return "Terminal (macOS default)" + case .terminal: return L("Terminal (macOS default)") case .iTerm2: return "iTerm2" } } diff --git a/mac/Sources/CodeBurnMenubar/SessionCountLabel.swift b/mac/Sources/CodeBurnMenubar/SessionCountLabel.swift index 0c89855a0..be5e6df56 100644 --- a/mac/Sources/CodeBurnMenubar/SessionCountLabel.swift +++ b/mac/Sources/CodeBurnMenubar/SessionCountLabel.swift @@ -1,10 +1,10 @@ /// Shared session-count phrasing. Keep in lockstep with `src/session-count-label.ts`. enum SessionCountLabel { - static let help = "Older session logs may be unavailable." + static var help: String { L("Older session logs may be unavailable.") } /// Combined-scope counts are a per-device numeric sum with no shared identity. /// Do not show that sum as unique or as a lower bound. - static let combinedHelp = "Session identities are unavailable across devices." - static let combinedText = "Session count unavailable" + static var combinedHelp: String { L("Session identities are unavailable across devices.") } + static var combinedText: String { L("Session count unavailable") } static func isExact(_ basis: String?) -> Bool { basis == "identity" @@ -12,18 +12,18 @@ enum SessionCountLabel { static func text(sessions: Int, basis: String?) -> String { if !isExact(basis) { - if sessions <= 0 { return "Session count unavailable" } - return sessions == 1 ? "At least 1 session" : "At least \(sessions) sessions" + if sessions <= 0 { return combinedText } + return sessions == 1 ? L("At least 1 session") : L("At least %lld sessions", sessions) } - return sessions == 1 ? "1 session" : "\(sessions) sessions" + return sessions == 1 ? L("1 session") : L("%lld sessions", sessions) } static func compact(sessions: Int, basis: String?) -> String { if !isExact(basis) { - if sessions <= 0 { return "Unavailable" } - return "≥\(sessions) sess" + if sessions <= 0 { return L("Unavailable") } + return L("≥%lld sess", sessions) } - return "\(sessions) sess" + return L("%lld sess", sessions) } static func averageText(_ value: Double?, basis: String?, format: (Double) -> String) -> String { diff --git a/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift index 9e0444e9b..31aaf5190 100644 --- a/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift +++ b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift @@ -14,6 +14,21 @@ enum AccentPreset: String, CaseIterable, Identifiable { var id: String { rawValue } + /// Accessibility / picker label. `rawValue` stays the persisted identity. + var displayLabel: String { + switch self { + case .ember: L("Ember") + case .blue: L("Blue") + case .purple: L("Purple") + case .pink: L("Pink") + case .red: L("Red") + case .orange: L("Orange") + case .yellow: L("Yellow") + case .green: L("Green") + case .graphite: L("Graphite") + } + } + /// Apple macOS dark-mode system accent colors (NSColor.system*). var base: Color { switch self { diff --git a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift index 318971753..52d971663 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift @@ -6,13 +6,13 @@ struct ActivitySection: View { var body: some View { CollapsibleSection( - caption: "Activity", + caption: L("Activity"), isExpanded: $isExpanded, trailing: { HStack(spacing: 8) { - Text("Cost").frame(minWidth: 54, alignment: .trailing) - Text("Turns").frame(minWidth: 52, alignment: .trailing) - Text("1-shot").frame(minWidth: 44, alignment: .trailing) + Text(L("Cost")).frame(minWidth: 54, alignment: .trailing) + Text(L("Turns")).frame(minWidth: 52, alignment: .trailing) + Text(L("1-shot")).frame(minWidth: 44, alignment: .trailing) } .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index a166cedda..b9395ccce 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -33,7 +33,7 @@ struct AgentTabStrip: View { .buttonStyle(.plain) .foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35)) .disabled(!canMoveBackward) - .help("Show previous providers") + .help(L("Show previous providers")) } ScrollView(.horizontal, showsIndicators: false) { @@ -84,7 +84,7 @@ struct AgentTabStrip: View { .buttonStyle(.plain) .foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35)) .disabled(!canMoveForward) - .help("Show next providers") + .help(L("Show next providers")) } } .onAppear { @@ -357,7 +357,7 @@ private struct QuotaDetailPopover: View { .font(.system(size: 11)) .foregroundStyle(.secondary) case .loading where quota.details.isEmpty: - Text("Loading…") + Text(L("Loading…")) .font(.system(size: 11)) .foregroundStyle(.secondary) default: @@ -370,23 +370,23 @@ private struct QuotaDetailPopover: View { private var disconnectedMessage: String { switch quota.providerFilter { - case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota." - case .claude: return "Sign in to Claude Code to track quota." - default: return "Sign in to track quota." + case .codex: return L("Sign in with `codex` (ChatGPT mode) to track quota.") + case .claude: return L("Sign in to Claude Code to track quota.") + default: return L("Sign in to track quota.") } } private var rowsCard: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { - Text("\(quota.providerFilter.rawValue) usage") + Text(L("%@ usage", quota.providerFilter.displayLabel)) .font(.system(size: 11, weight: .semibold)) if case .stale = quota.connection { - Text("stale") + Text(L("stale")) .font(.system(size: 9.5)) .foregroundStyle(.secondary) } else if case .transientFailure = quota.connection { - Text("retrying") + Text(L("retrying")) .font(.system(size: 9.5)) .foregroundStyle(.orange) } diff --git a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift index e3f758421..e39cbd760 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -429,13 +429,13 @@ struct CapacityDockView: View { .clipShape(railShape) .contentShape(railShape) .contextMenu { - Menu("Dock to Edge") { - Button("Left") { onDock(.left) } - Button("Right") { onDock(.right) } - Button("Top") { onDock(.top) } - Button("Bottom") { onDock(.bottom) } + Menu(L("Dock to Edge")) { + Button(L("Left")) { onDock(.left) } + Button(L("Right")) { onDock(.right) } + Button(L("Top")) { onDock(.top) } + Button(L("Bottom")) { onDock(.bottom) } } - Button("Hide Capacity Dock", action: onHide) + Button(L("Hide Capacity Dock"), action: onHide) } .simultaneousGesture( DragGesture(minimumDistance: 3, coordinateSpace: .global) @@ -443,7 +443,7 @@ struct CapacityDockView: View { .onEnded { _ in onDragEnded() } ) .accessibilityElement(children: .contain) - .accessibilityLabel("Capacity Dock") + .accessibilityLabel(L("Capacity Dock")) } private var contentAlignment: Alignment { @@ -525,9 +525,9 @@ private struct CapacityDockProviderRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel("\(provider.displayName) usage") - .accessibilityValue(headline?.percentLabel ?? "Unknown") - .accessibilityHint("Click to keep Capacity Dock expanded") + .accessibilityLabel(L("%@ usage", provider.displayName)) + .accessibilityValue(headline?.percentLabel ?? L("Unknown")) + .accessibilityHint(L("Click to keep Capacity Dock expanded")) } private var headlinePercentColor: Color { @@ -807,7 +807,7 @@ struct CapacityDockDetailView: View { private func sessionsSection(_ sessions: [LiveSession]) -> some View { let s = model.detailScale VStack(alignment: .leading, spacing: 0) { - sectionCaption("Sessions", trailing: sessionsTrailing(sessions.count)) + sectionCaption(L("Sessions"), trailing: sessionsTrailing(sessions.count)) if !sessions.isEmpty { ScrollView(.vertical) { VStack(spacing: CapacityDockGlance.pillGap * s) { @@ -829,9 +829,9 @@ struct CapacityDockDetailView: View { private func sessionsTrailing(_ count: Int) -> String { switch count { - case 0: return "none running" - case 1: return "1 running" - default: return "\(count) running" + case 0: return L("none running") + case 1: return L("1 running") + default: return L("%lld running", count) } } @@ -867,7 +867,7 @@ struct CapacityDockDetailView: View { ) .frame(height: 15 * s) if let remaining = session.contextRemaining { - Text("\(Double(remaining).asCompactTokens().lowercasedThousands()) left") + Text(L("%@ left", Double(remaining).asCompactTokens().lowercasedThousands())) .font(.system(size: 10)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.6)) @@ -931,14 +931,14 @@ struct CapacityDockDetailView: View { private func todaySection(_ today: ProviderDetail) -> some View { let s = model.detailScale VStack(alignment: .leading, spacing: 0) { - sectionCaption("Today", trailing: nil) + sectionCaption(L("Today"), trailing: nil) HStack(alignment: .center, spacing: 8 * s) { HStack(alignment: .firstTextBaseline, spacing: 5 * s) { Text(today.cost.asUSD()) .font(.system(size: 17, weight: .semibold)) .monospacedDigit() .foregroundStyle(Color.capacityDockText) - Text("burned") + Text(L("burned")) .font(.system(size: 11.5)) .foregroundStyle(Color.capacityDockText.opacity(0.6)) } @@ -949,7 +949,7 @@ struct CapacityDockDetailView: View { // arrows drop out instead. if let input = today.inputTokens { tokenLine("arrow.down", Double(input)) } if let output = today.outputTokens { tokenLine("arrow.up", Double(output)) } - Text("\(today.calls.asThousandsSeparated()) calls") + Text(L("%@ calls", today.calls.asThousandsSeparated())) .font(.system(size: 10)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.6)) @@ -1044,7 +1044,7 @@ struct CapacityDockDetailView: View { private func budgetLine() -> some View { let spend = store.capacityDockToday?.cost ?? 0 let budget = store.activeDailyBudget - Text(budget > 0 ? "today \(spend.asUSD()) of \(budget.asUSD())" : "no budget set") + Text(budget > 0 ? L("today %@ of %@", spend.asUSD(), budget.asUSD()) : L("no budget set")) .font(.system(size: 11)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.6)) @@ -1072,24 +1072,24 @@ struct CapacityDockDetailView: View { case .connected: EmptyView() case .loading: - Text("Refreshing…") + Text(L("Refreshing…")) .font(.system(size: 10)) .foregroundStyle(Color.capacityDockText.opacity(0.52)) case .stale: - Text("Last known usage · refreshing") + Text(L("Last known usage · refreshing")) .font(.system(size: 10)) .foregroundStyle(.yellow.opacity(0.82)) case .transientFailure: - Text("Last known usage · retrying") + Text(L("Last known usage · retrying")) .font(.system(size: 10)) .foregroundStyle(.orange.opacity(0.86)) case .disconnected: - Text("Not connected") + Text(L("Not connected")) .font(.system(size: 11)) .foregroundStyle(Color.capacityDockText.opacity(0.6)) case .terminalFailure(let reason): VStack(alignment: .leading, spacing: 3 * model.detailScale) { - Text("Reconnect required") + Text(L("Reconnect required")) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.red) if let reason, !reason.isEmpty { diff --git a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift index aff1e83aa..8fb95e35a 100644 --- a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift @@ -21,12 +21,12 @@ struct FindingsSection: View { Image(systemName: "lightbulb.fill") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.brandAccent) - Text("Tips for you") + Text(L("Tips for you")) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(.primary) } Spacer() - Text("\(groups.flatMap { $0.items }.count) signals") + Text(L("%lld signals", groups.flatMap { $0.items }.count)) .font(.system(size: 10.5)) .foregroundStyle(.secondary) Image(systemName: "chevron.right") @@ -52,7 +52,7 @@ struct FindingsSection: View { openOptimize() } label: { HStack(spacing: 4) { - Text("Open Full Optimize") + Text(L("Open Full Optimize")) .font(.system(size: 11.5, weight: .semibold)) Image(systemName: "arrow.forward") .font(.system(size: 9, weight: .semibold)) @@ -138,25 +138,25 @@ private struct TipItem: Identifiable { let cacheHit = payload.current.cacheHitPercent if cacheHit >= 80 { wins.append(TipItem( - text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache", + text: L("Cache hit at %lld%% — most prompts reuse cache", Int(cacheHit)), trailing: nil )) } if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 { wins.append(TipItem( - text: "\(Int(oneShot * 100))% one-shot — edits landing first try", + text: L("%lld%% one-shot — edits landing first try", Int(oneShot * 100)), trailing: nil )) } if let delta = stats.weekDeltaPercent, delta < -10 { wins.append(TipItem( - text: "Spend down \(Int(abs(delta)))% vs last 7 days", + text: L("Spend down %lld%% vs last 7 days", Int(abs(delta))), trailing: nil )) } if stats.activeStreakDays >= 5 { wins.append(TipItem( - text: "\(stats.activeStreakDays)-day usage streak", + text: L("%lld-day usage streak", stats.activeStreakDays), trailing: nil )) } @@ -174,33 +174,33 @@ private struct TipItem: Identifiable { var risks: [TipItem] = [] if let delta = stats.weekDeltaPercent, delta > 25 { risks.append(TipItem( - text: "Spend up \(Int(delta))% vs prior 7 days", + text: L("Spend up %lld%% vs prior 7 days", Int(delta)), trailing: nil )) } if cacheHit > 0 && cacheHit < 50 { risks.append(TipItem( - text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts", + text: L("Cache hit only %lld%% — paying for cold prompts", Int(cacheHit)), trailing: nil )) } if let oneShot = payload.current.oneShotRate, oneShot < 0.5 { risks.append(TipItem( - text: "\(Int(oneShot * 100))% one-shot — lots of iteration", + text: L("%lld%% one-shot — lots of iteration", Int(oneShot * 100)), trailing: nil )) } if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 { risks.append(TipItem( - text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)", + text: L("On pace for %@ this month (+%lld%% vs last)", projected.asCompactCurrency(), Int(((projected - prevMonth) / prevMonth) * 100)), trailing: nil )) } return [ - TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins), - TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements), - TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks), + TipGroup(label: L("What's working"), icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins), + TipGroup(label: L("What to improve"), icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements), + TipGroup(label: L("Risks"), icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks), ] } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 53f77406d..58f877458 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -15,17 +15,17 @@ private let yyyymmdd: DateFormatter = { return f }() +// Display-only, so they follow the user's locale (a template, not a fixed +// pattern). `yyyymmdd` above stays en_US_POSIX: it parses and builds data keys. private let prettyDayFormat: DateFormatter = { let f = DateFormatter() - f.dateFormat = "EEE MMM d" - f.locale = Locale(identifier: "en_US_POSIX") + f.setLocalizedDateFormatFromTemplate("EEE MMM d") return f }() private let mmmDayFormat: DateFormatter = { let f = DateFormatter() - f.dateFormat = "MMM d" - f.locale = Locale(identifier: "en_US_POSIX") + f.setLocalizedDateFormatFromTemplate("MMM d") f.timeZone = .current return f }() @@ -115,7 +115,7 @@ private struct InsightPillSwitcher: View { Button { selected = mode } label: { - Text(mode.rawValue) + Text(mode.displayLabel) .font(.system(size: 11, weight: .medium)) .fixedSize() .foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) @@ -169,7 +169,7 @@ private struct TrendInsight: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 1) { - Text("Last \(dayCount) days") + Text(L("Last %lld days", dayCount)) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow)) @@ -182,7 +182,7 @@ private struct TrendInsight: View { HStack(spacing: 3) { Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right") .font(.system(size: 9, weight: .bold)) - Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(dayCount)d") + Text(L("%@%% vs prior %lldd", (delta >= 0 ? "+" : "") + String(format: "%.0f", delta), dayCount)) .font(.system(size: 10.5)) .monospacedDigit() } @@ -201,15 +201,15 @@ private struct TrendInsight: View { .zIndex(1) HStack(spacing: 14) { - MiniStat(label: "Avg/day", value: formatValue(avgValue, useTokens: useTokens)) - MiniStat(label: "Peak", value: peakLabel(peakValue, metric: metric, useTokens: useTokens)) - MiniStat(label: "Yesterday", value: yesterdayValue.map { formatValue($0, useTokens: useTokens) } ?? "—") + MiniStat(label: L("Avg/day"), value: formatValue(avgValue, useTokens: useTokens)) + MiniStat(label: L("Peak"), value: peakLabel(peakValue, metric: metric, useTokens: useTokens)) + MiniStat(label: L("Yesterday"), value: yesterdayValue.map { formatValue($0, useTokens: useTokens) } ?? "—") } } } private func formatHero(useTokens: Bool, tokens: Double, dollars: Double) -> String { - useTokens ? "\(formatTokens(tokens)) tokens" : dollars.asCurrency() + useTokens ? L("%@ tokens", formatTokens(tokens)) : dollars.asCurrency() } private func formatValue(_ v: Double, useTokens: Bool) -> String { @@ -218,7 +218,7 @@ private struct TrendInsight: View { private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String { guard let peak, metric(peak) > 0 else { return "—" } - return "\(formatValue(metric(peak), useTokens: useTokens)) on \(shortDate(peak.date))" + return L("%@ on %@", formatValue(metric(peak), useTokens: useTokens), shortDate(peak.date)) } private func formatTokens(_ n: Double) -> String { @@ -561,7 +561,7 @@ private struct ContributionHeatmapInsight: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 1) { - Text("Daily activity") + Text(L("Daily activity")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Text(stats.total.asCurrency()) @@ -570,7 +570,7 @@ private struct ContributionHeatmapInsight: View { .foregroundStyle(.primary) } Spacer() - Text("\(stats.activeDays) active days") + Text(L("%lld active days", stats.activeDays)) .font(.system(size: 10.5, weight: .medium)) .monospacedDigit() .foregroundStyle(Theme.brandAccent) @@ -609,9 +609,9 @@ private struct ContributionHeatmapInsight: View { .animation(.easeInOut(duration: 0.12), value: hoveredDayID) HStack(spacing: 14) { - MiniStat(label: "Peak day", value: stats.peakLabel) - MiniStat(label: "Avg active", value: stats.avgActive.asCompactCurrency()) - MiniStat(label: "Streak", value: "\(stats.currentStreak)d") + MiniStat(label: L("Peak day"), value: stats.peakLabel) + MiniStat(label: L("Avg active"), value: stats.avgActive.asCompactCurrency()) + MiniStat(label: L("Streak"), value: L("%lldd", stats.currentStreak)) } } } @@ -626,10 +626,10 @@ private struct ContributionHeatmapInsight: View { private func weekdayLabel(for index: Int) -> String { switch index { - case 0: return "Mon" - case 2: return "Wed" - case 4: return "Fri" - case 6: return "Sun" + case 0: return L("Mon") + case 2: return L("Wed") + case 4: return L("Fri") + case 6: return L("Sun") default: return "" } } @@ -692,8 +692,8 @@ private struct ContributionDayDetail: View { Spacer(minLength: 8) - DetailMetric(label: "Calls", value: calls) - DetailMetric(label: "Tokens", value: tokens) + DetailMetric(label: L("Calls"), value: calls) + DetailMetric(label: L("Tokens"), value: tokens) } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -708,14 +708,14 @@ private struct ContributionDayDetail: View { // The header already shows the period total and active-day count, so // the resting state is a short hover hint — not a duplicate of those // (and not the full sentence that previously overflowed and truncated). - guard let day else { return "Daily detail" } + guard let day else { return L("Daily detail") } return prettyDate(day.date) } private var value: String { - guard let day else { return "Hover a day" } - if day.isFuture { return "Future day" } - if day.cost <= 0 && day.calls == 0 { return "No tracked usage" } + guard let day else { return L("Hover a day") } + if day.isFuture { return L("Future day") } + if day.cost <= 0 && day.calls == 0 { return L("No tracked usage") } return day.cost.asCompactCurrency() } @@ -769,9 +769,15 @@ struct ContributionDay: Identifiable, Equatable { var totalTokens: Int { inputTokens + outputTokens } @MainActor var helpText: String { - if isFuture { return "\(prettyDate(date)): future day" } - if cost <= 0 && calls == 0 { return "\(prettyDate(date)): no tracked usage" } - return "\(prettyDate(date)): \(cost.asCompactCurrency()), \(calls) calls, \(formatTokensForContribution(totalTokens)) tokens" + if isFuture { return L("%@: future day", prettyDate(date)) } + if cost <= 0 && calls == 0 { return L("%@: no tracked usage", prettyDate(date)) } + return L( + "%@: %@, %lld calls, %@ tokens", + prettyDate(date), + cost.asCompactCurrency(), + calls, + formatTokensForContribution(totalTokens) + ) } } @@ -853,7 +859,7 @@ func buildContributionWeeks( let total = active.reduce(0.0) { $0 + $1.cost } let avg = active.isEmpty ? 0 : total / Double(active.count) let peak = active.max(by: { $0.cost < $1.cost }) - let peakLabel = peak.map { "\($0.cost.asCompactCurrency()) on \(shortContributionDate($0.date))" } ?? "—" + let peakLabel = peak.map { L("%@ on %@", $0.cost.asCompactCurrency(), shortContributionDate($0.date)) } ?? "—" var streak = 0 for day in days.reversed() { @@ -901,7 +907,7 @@ private struct ForecastInsight: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { - Text("Month-to-date") + Text(L("Month-to-date")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Text(stats.mtd.asCurrency()) @@ -911,7 +917,7 @@ private struct ForecastInsight: View { } Spacer() VStack(alignment: .trailing, spacing: 2) { - Text("On pace for") + Text(L("On pace for")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Text(stats.projection.asCurrency()) @@ -921,9 +927,9 @@ private struct ForecastInsight: View { } HStack(spacing: 14) { - ForecastStat(label: "Avg/day (this wk)", value: stats.weekAvg.asCompactCurrency()) - ForecastStat(label: "Yesterday", value: stats.yesterday.asCompactCurrency()) - ForecastStat(label: "Last 7d", value: stats.weekTotal.asCompactCurrency()) + ForecastStat(label: L("Avg/day (this wk)"), value: stats.weekAvg.asCompactCurrency()) + ForecastStat(label: L("Yesterday"), value: stats.yesterday.asCompactCurrency()) + ForecastStat(label: L("Last 7d"), value: stats.weekTotal.asCompactCurrency()) } if let prevTotal = stats.previousMonthTotal { @@ -940,10 +946,10 @@ private struct ForecastInsight: View { } private func comparisonText(projection: Double, previous: Double) -> String { - guard previous > 0 else { return "no prior month" } + guard previous > 0 else { return L("no prior month") } let diff = ((projection - previous) / previous) * 100 let sign = diff >= 0 ? "+" : "" - return "\(sign)\(String(format: "%.0f", diff))% vs last month (\(previous.asCompactCurrency()))" + return L("%@%% vs last month (%@)", sign + String(format: "%.0f", diff), previous.asCompactCurrency()) } } @@ -1037,10 +1043,10 @@ private struct PulseInsight: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { - PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent) - PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor) + PulseTile(label: L("Cache hit"), value: cacheHitText, color: Theme.brandAccent) + PulseTile(label: L("1-shot"), value: oneShotText, color: oneShotColor) PulseTile( - label: "Cost / session", + label: L("Cost / session"), value: SessionCountLabel.averageText( SessionCountLabel.isExact(payload.current.sessionCountBasis) && payload.current.sessions > 0 ? payload.current.cost / Double(payload.current.sessions) @@ -1109,7 +1115,7 @@ private struct CostPerEditCaption: View { Image(systemName: "pencil.line") .font(.system(size: 9, weight: .medium)) .foregroundStyle(.tertiary) - Text("Cost/edit") + Text(L("Cost/edit")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Text(formatCPE(best.1)) @@ -1182,8 +1188,9 @@ private struct OptimizeSavingsBadge: View { private func captionText(findingCount: Int, savingsUSD: Double) -> String { let tokens = savingsUSD / 9.0 * 1_000_000 // ~$9/M effective tokens (Sonnet-weighted approx) let tokensLabel = formatTokens(tokens) - let plural = findingCount == 1 ? "finding" : "findings" - return "Save ~\(savingsUSD.asCompactCurrency()) / ~\(tokensLabel) tokens · \(findingCount) \(plural)" + return findingCount == 1 + ? L("Save ~%@ / ~%@ tokens · 1 finding", savingsUSD.asCompactCurrency(), tokensLabel) + : L("Save ~%@ / ~%@ tokens · %lld findings", savingsUSD.asCompactCurrency(), tokensLabel, findingCount) } private func openOptimize() { @@ -1208,18 +1215,18 @@ private struct StatsInsight: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: 8) { - StatRow(label: "Favorite model", value: stats.favoriteModel) - StatRow(label: "Active days (month)", value: stats.activeDaysFraction) - StatRow(label: "Most active day", value: stats.mostActiveDay) - StatRow(label: "Peak day spend", value: stats.peakDaySpend) + StatRow(label: L("Favorite model"), value: stats.favoriteModel) + StatRow(label: L("Active days (month)"), value: stats.activeDaysFraction) + StatRow(label: L("Most active day"), value: stats.mostActiveDay) + StatRow(label: L("Peak day spend"), value: stats.peakDaySpend) } .frame(maxWidth: .infinity, alignment: .leading) VStack(alignment: .leading, spacing: 8) { - StatRow(label: "Sessions", value: SessionCountLabel.text(sessions: payload.current.sessions, basis: payload.current.sessionCountBasis)) - StatRow(label: "Calls", value: payload.current.calls.asThousandsSeparated()) - StatRow(label: "Current streak", value: stats.currentStreak) - StatRow(label: "Longest streak", value: stats.longestStreak) + StatRow(label: L("Sessions"), value: SessionCountLabel.text(sessions: payload.current.sessions, basis: payload.current.sessionCountBasis)) + StatRow(label: L("Calls"), value: payload.current.calls.asThousandsSeparated()) + StatRow(label: L("Current streak"), value: stats.currentStreak) + StatRow(label: L("Longest streak"), value: stats.longestStreak) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -1227,7 +1234,7 @@ private struct StatsInsight: View { if let lifetime = stats.lifetimeTotal { Divider().opacity(0.5) HStack { - Text("Tracked spend (last \(stats.historyDayCount) days)") + Text(L("Tracked spend (last %lld days)", stats.historyDayCount)) .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(.tertiary) Spacer() @@ -1248,7 +1255,7 @@ private struct StatsInsight: View { Image(systemName: "flame") .font(.system(size: 9, weight: .medium)) .foregroundStyle(Theme.brandAccent) - Text("Costliest session") + Text(L("Costliest session")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Spacer() @@ -1256,7 +1263,7 @@ private struct StatsInsight: View { .font(.codeMono(size: 10.5, weight: .semibold)) .foregroundStyle(.secondary) .monospacedDigit() - Text("· \(projectDisplayName(top.project))") + Text(verbatim: "· \(projectDisplayName(top.project))") .font(.system(size: 10)) .foregroundStyle(.tertiary) .lineLimit(1) @@ -1280,7 +1287,7 @@ private struct RetryTaxSection: View { Image(systemName: "arrow.2.squarepath") .font(.system(size: 9, weight: .medium)) .foregroundStyle(.orange) - Text("Retry tax") + Text(L("Retry tax")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Spacer() @@ -1305,7 +1312,7 @@ private struct RetryTaxSection: View { } } - Text("\(retryTax.retries) retries across \(retryTax.editTurns) edits") + Text(L("%lld retries across %lld edits", retryTax.retries, retryTax.editTurns)) .font(.system(size: 9.5)) .foregroundStyle(.quaternary) @@ -1318,7 +1325,7 @@ private struct RetryTaxSection: View { .foregroundStyle(.secondary) Spacer() if let rpe = model.retriesPerEdit { - Text(String(format: "%.1f ret/edit", rpe)) + Text(L("%@ ret/edit", String(format: "%.1f", rpe))) .font(.system(size: 9)) .foregroundStyle(.quaternary) .padding(.trailing, 8) @@ -1446,7 +1453,7 @@ private struct TopProjectsList: View { } .buttonStyle(.plain) .accessibilityLabel(projectRowAccessibilityLabel(project, isOpen: isOpen, canExpand: true)) - .accessibilityHint(isOpen ? "Hides session details" : "Shows session details") + .accessibilityHint(isOpen ? L("Hides session details") : L("Shows session details")) } else { header .accessibilityElement(children: .combine) @@ -1461,7 +1468,7 @@ private struct TopProjectsList: View { project.cost.asCompactCurrency() ] if canExpand { - parts.append(isOpen ? "Expanded" : "Collapsed") + parts.append(isOpen ? L("Expanded") : L("Collapsed")) } return parts.joined(separator: ", ") } @@ -1480,7 +1487,7 @@ private struct SessionDetailsList: View { .foregroundStyle(.primary) .monospacedDigit() .frame(width: 52, alignment: .trailing) - Text(" \(sess.calls) \(sess.calls == 1 ? "call" : "calls")") + Text(sess.calls == 1 ? L(" %lld call", sess.calls) : L(" %lld calls", sess.calls)) .font(.system(size: 9)) .foregroundStyle(.quaternary) Spacer() @@ -1551,7 +1558,7 @@ private struct OptimizeInsight: View { if totalWaste > 0, cost > 0 { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { - Text("Potential savings") + Text(L("Potential savings")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Text(totalWaste.asCompactCurrency()) @@ -1561,10 +1568,10 @@ private struct OptimizeInsight: View { } Spacer() VStack(alignment: .trailing, spacing: 2) { - Text("\(Int((totalWaste / cost * 100).rounded()))% of spend") + Text(L("%lld%% of spend", Int((totalWaste / cost * 100).rounded()))) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.orange.opacity(0.8)) - Text("could be optimized") + Text(L("could be optimized")) .font(.system(size: 9.5)) .foregroundStyle(.quaternary) } @@ -1592,7 +1599,7 @@ private struct RoutingWasteSection: View { Image(systemName: "arrow.triangle.swap") .font(.system(size: 9, weight: .medium)) .foregroundStyle(.purple) - Text("Routing waste") + Text(L("Routing waste")) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) Spacer() @@ -1618,7 +1625,7 @@ private struct RoutingWasteSection: View { } if !routingWaste.baselineModel.isEmpty { - Text("vs \(routingWaste.baselineModel) @ \(routingWaste.baselineCostPerEdit.asCompactCurrency())/edit") + Text(L("vs %@ @ %@/edit", routingWaste.baselineModel, routingWaste.baselineCostPerEdit.asCompactCurrency())) .font(.system(size: 9.5)) .foregroundStyle(.quaternary) } @@ -1739,8 +1746,8 @@ private struct AllStats { activeDaysFraction: activeDaysFraction, mostActiveDay: mostActiveDay, peakDaySpend: peakDaySpend, - currentStreak: currentStreak == 0 ? "—" : (currentStreak == 1 ? "1 day" : "\(currentStreak) days"), - longestStreak: longestStreak == 0 ? "—" : (longestStreak == 1 ? "1 day" : "\(longestStreak) days"), + currentStreak: currentStreak == 0 ? "—" : (currentStreak == 1 ? L("1 day") : L("%lld days", currentStreak)), + longestStreak: longestStreak == 0 ? "—" : (longestStreak == 1 ? L("1 day") : L("%lld days", longestStreak)), lifetimeTotal: lifetimeTotal, historyDayCount: history.count ) @@ -1763,21 +1770,21 @@ private struct PlanInsight: View { switch store.subscriptionLoadState { case .notBootstrapped, .dormant: PlanConnectView( - title: "Connect Claude subscription", - message: "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." + title: L("Connect Claude subscription"), + message: L("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.") ) { Task { await store.bootstrapSubscription() } } case .bootstrapping: - PlanLoadingView(message: "Reading Claude credentials...") + PlanLoadingView(message: L("Reading Claude credentials...")) case .loading: if let usage { loadedBody(usage: usage) } else { - PlanLoadingView(message: "Reading Claude credentials...") + PlanLoadingView(message: L("Reading Claude credentials...")) } case .noCredentials: PlanNoCredentialsView( - title: "No Claude credentials found", - message: "Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again." + title: L("No Claude credentials found"), + message: L("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.") ) { Task { await store.bootstrapSubscription() } } case .failed: PlanFailedView( @@ -1788,20 +1795,20 @@ private struct PlanInsight: View { loadedBody(usage: usage) } else { PlanFailedView( - error: store.subscriptionError ?? "Anthropic temporarily unreachable. Retrying." + error: store.subscriptionError ?? L("Anthropic temporarily unreachable. Retrying.") ) { refreshSubscriptionThroughAppDelegate() } } case let .terminalFailure(reason): PlanReconnectView( - title: "Reconnect Claude", + title: L("Reconnect Claude"), reason: reason, - fallback: "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect." + fallback: L("Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.") ) { Task { await store.bootstrapSubscription() } } case .loaded: if let usage { loadedBody(usage: usage) } else { - PlanLoadingView(message: "Reading Claude credentials...") + PlanLoadingView(message: L("Reading Claude credentials...")) } } } @@ -1824,7 +1831,7 @@ private struct PlanInsight: View { .foregroundStyle(Theme.brandAccent) Spacer() if let resets = headlineReset(usage: usage) { - Text("Resets \(resets)") + Text(L("Resets %@", resets)) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } @@ -1832,19 +1839,19 @@ private struct PlanInsight: View { VStack(spacing: 8) { if let p = usage.fiveHourPercent { - UtilizationRow(label: "5-hour window", percent: p, resetsAt: usage.fiveHourResetsAt, projection: projections["five_hour"]) + UtilizationRow(label: L("5-hour window"), percent: p, resetsAt: usage.fiveHourResetsAt, projection: projections["five_hour"]) } if let p = usage.sevenDayPercent { - UtilizationRow(label: "7-day total", percent: p, resetsAt: usage.sevenDayResetsAt, projection: projections["seven_day"]) + UtilizationRow(label: L("7-day total"), percent: p, resetsAt: usage.sevenDayResetsAt, projection: projections["seven_day"]) } if let p = usage.sevenDayOpusPercent { - UtilizationRow(label: "7-day Opus", percent: p, resetsAt: usage.sevenDayOpusResetsAt, projection: projections["seven_day_opus"]) + UtilizationRow(label: L("7-day Opus"), percent: p, resetsAt: usage.sevenDayOpusResetsAt, projection: projections["seven_day_opus"]) } if let p = usage.sevenDaySonnetPercent { - UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"]) + UtilizationRow(label: L("7-day Sonnet"), percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"]) } ForEach(usage.scopedWeekly, id: \.label) { scoped in - UtilizationRow(label: "7-day \(scoped.label)", percent: scoped.percent, resetsAt: scoped.resetsAt, projection: projections["scoped_\(scoped.label)"]) + UtilizationRow(label: L("7-day %@", scoped.label), percent: scoped.percent, resetsAt: scoped.resetsAt, projection: projections["scoped_\(scoped.label)"]) } } @@ -1949,7 +1956,7 @@ private struct PlanNoCredentialsView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 280) - Button("Try Again", action: onRetry) + Button(L("Try Again"), action: onRetry) .controlSize(.small) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) @@ -1968,7 +1975,7 @@ private struct PlanFailedView: View { Image(systemName: "exclamationmark.triangle") .font(.system(size: 18)) .foregroundStyle(Theme.brandAccent) - Text("Couldn't load plan data") + Text(L("Couldn't load plan data")) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) if let error { @@ -1979,7 +1986,7 @@ private struct PlanFailedView: View { .frame(maxWidth: 280) .lineLimit(3) } - Button("Retry", action: onRetry) + Button(L("Retry"), action: onRetry) .controlSize(.small) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) @@ -2011,7 +2018,7 @@ private struct PlanConnectView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 280) - Button("Connect", action: onConnect) + Button(L("Connect"), action: onConnect) .controlSize(.small) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) @@ -2045,7 +2052,7 @@ private struct PlanReconnectView: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) .lineLimit(3) - Button("Reconnect", action: onReconnect) + Button(L("Reconnect"), action: onReconnect) .controlSize(.small) .buttonStyle(.borderedProminent) .tint(.red) @@ -2070,21 +2077,21 @@ private struct CodexPlanInsight: View { switch store.codexLoadState { case .notBootstrapped, .dormant: PlanConnectView( - title: "Connect ChatGPT subscription", - message: "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically." + title: L("Connect ChatGPT subscription"), + message: L("CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically.") ) { Task { await store.bootstrapCodex() } } case .bootstrapping: - PlanLoadingView(message: "Reading Codex CLI credentials...") + PlanLoadingView(message: L("Reading Codex CLI credentials...")) case .loading: if let usage = store.codexUsage { loadedBody(usage: usage) } else { - PlanLoadingView(message: "Reading Codex CLI credentials...") + PlanLoadingView(message: L("Reading Codex CLI credentials...")) } case .noCredentials: PlanNoCredentialsView( - title: "No Codex credentials found", - message: "Sign in with Codex first: run `codex login` in your terminal. Then click Try Again." + title: L("No Codex credentials found"), + message: L("Sign in with Codex first: run `codex login` in your terminal. Then click Try Again.") ) { Task { await store.bootstrapCodex() } } case .failed: PlanFailedView( @@ -2095,20 +2102,20 @@ private struct CodexPlanInsight: View { loadedBody(usage: usage) } else { PlanFailedView( - error: store.codexError ?? "ChatGPT temporarily unreachable. Retrying." + error: store.codexError ?? L("ChatGPT temporarily unreachable. Retrying.") ) { Task { await store.refreshCodex() } } } case let .terminalFailure(reason): PlanReconnectView( - title: "Reconnect Codex", + title: L("Reconnect Codex"), reason: reason, - fallback: "Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect." + fallback: L("Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect.") ) { Task { await store.bootstrapCodex() } } case .loaded: if let usage = store.codexUsage { loadedBody(usage: usage) } else { - PlanLoadingView(message: "Reading Codex CLI credentials...") + PlanLoadingView(message: L("Reading Codex CLI credentials...")) } } } @@ -2123,14 +2130,14 @@ private struct CodexPlanInsight: View { .foregroundStyle(.primary) Spacer() if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt ?? usage.creditLimit?.resetsAt { - Text("Resets \(relativeReset(resetsAt))") + Text(L("Resets %@", relativeReset(resetsAt))) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } } if let primary = usage.primary { UtilizationRow( - label: "\(primary.windowLabel) window", + label: L("%@ window", primary.windowLabel), percent: primary.usedPercent, resetsAt: primary.resetsAt, projection: pace(for: primary) @@ -2138,7 +2145,7 @@ private struct CodexPlanInsight: View { } if let secondary = usage.secondary { UtilizationRow( - label: "\(secondary.windowLabel) window", + label: L("%@ window", secondary.windowLabel), percent: secondary.usedPercent, resetsAt: secondary.resetsAt, projection: pace(for: secondary) @@ -2175,11 +2182,11 @@ private struct CodexPlanInsight: View { } else if usage.creditsUnlimited { // Uncapped on purpose, not a failed fetch. HStack(alignment: .firstTextBaseline) { - Text("Credits") + Text(L("Credits")) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) Spacer() - Text("Unlimited") + Text(L("Unlimited")) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } @@ -2188,7 +2195,7 @@ private struct CodexPlanInsight: View { // plans that never receive these grants see no extra row. if let resets = usage.resetCredits, resets.availableCount > 0 { HStack(alignment: .firstTextBaseline) { - Text("Limit resets") + Text(L("Limit resets")) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) Spacer() @@ -2242,9 +2249,9 @@ private struct CodexPlanInsight: View { } private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String { - let count = "\(resets.availableCount) available" + let count = L("%lld available", resets.availableCount) guard let next = resets.nextExpiresAt else { return count } - return "\(count) · next expires \(relativeReset(next))" + return L("%@ · next expires %@", count, relativeReset(next)) } private func relativeReset(_ date: Date) -> String { @@ -2265,30 +2272,30 @@ private struct KimiPlanInsight: View { switch KimiQuotaPresentation.planContent(loadState: store.kimiLoadState, hasUsage: store.kimiUsage != nil) { case .noCredentials: PlanNoCredentialsView( - title: "No Kimi Code credentials found", - message: "Sign in with the Kimi CLI first. Then click Try Again." + title: L("No Kimi Code credentials found"), + message: L("Sign in with the Kimi CLI first. Then click Try Again.") ) { Task { await store.bootstrapKimi() } } case .loading: - PlanLoadingView(message: "Reading Kimi Code credentials...") + PlanLoadingView(message: L("Reading Kimi Code credentials...")) case .failed: PlanFailedView( error: store.kimiError ) { Task { await store.refreshKimi() } } case .transientFailed: PlanFailedView( - error: store.kimiError ?? "Kimi temporarily unreachable. Retrying." + error: store.kimiError ?? L("Kimi temporarily unreachable. Retrying.") ) { Task { await store.refreshKimi() } } case let .reconnect(reason): PlanReconnectView( - title: "Refresh Kimi Code login", + title: L("Refresh Kimi Code login"), reason: reason, - fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect." + fallback: L("Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect.") ) { Task { await store.bootstrapKimi() } } case let .usage(idle): if let usage = store.kimiUsage { loadedBody(usage: usage, idle: idle) } else { - PlanLoadingView(message: "Reading Kimi Code credentials...") + PlanLoadingView(message: L("Reading Kimi Code credentials...")) } } } @@ -2303,14 +2310,14 @@ private struct KimiPlanInsight: View { .foregroundStyle(.primary) Spacer() if let resetsAt = usage.primary?.resetsAt { - Text("Resets \(relativeReset(resetsAt))") + Text(L("Resets %@", relativeReset(resetsAt))) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } } if let primary = usage.primary { UtilizationRow( - label: "\(primary.label) window", + label: L("%@ window", primary.label), percent: primary.usedPercent, resetsAt: primary.resetsAt, projection: nil @@ -2318,7 +2325,7 @@ private struct KimiPlanInsight: View { } ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in UtilizationRow( - label: "\(window.label) window", + label: L("%@ window", window.label), percent: window.usedPercent, resetsAt: window.resetsAt, projection: nil @@ -2326,7 +2333,7 @@ private struct KimiPlanInsight: View { } if let parallel = usage.parallelLimit, parallel > 0 { HStack(alignment: .firstTextBaseline) { - Text("Parallel sessions") + Text(L("Parallel sessions")) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) Spacer() @@ -2336,12 +2343,12 @@ private struct KimiPlanInsight: View { } } if idle { - Text("Login idle. Run the Kimi CLI to refresh.") + Text(L("Login idle. Run the Kimi CLI to refresh.")) .font(.system(size: 10)) .foregroundStyle(.tertiary) } if KimiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) { - Text("as of \(shortTime(usage.fetchedAt))") + Text(L("as of %@", shortTime(usage.fetchedAt))) .font(.system(size: 10)) .foregroundStyle(.tertiary) } @@ -2376,30 +2383,30 @@ private struct GeminiPlanInsight: View { switch GeminiQuotaPresentation.planContent(loadState: store.geminiLoadState, hasUsage: store.geminiUsage != nil) { case .noCredentials: PlanNoCredentialsView( - title: "No Gemini credentials found", - message: "Sign in with the Gemini CLI first. Then click Try Again." + title: L("No Gemini credentials found"), + message: L("Sign in with the Gemini CLI first. Then click Try Again.") ) { Task { await store.bootstrapGemini() } } case .loading: - PlanLoadingView(message: "Reading Gemini credentials...") + PlanLoadingView(message: L("Reading Gemini credentials...")) case .failed: PlanFailedView( error: store.geminiError ) { Task { await store.refreshGemini() } } case .transientFailed: PlanFailedView( - error: store.geminiError ?? "Gemini temporarily unreachable. Retrying." + error: store.geminiError ?? L("Gemini temporarily unreachable. Retrying.") ) { Task { await store.refreshGemini() } } case let .reconnect(reason): PlanReconnectView( - title: "Refresh Gemini login", + title: L("Refresh Gemini login"), reason: reason, - fallback: "Your Gemini login has expired. Run the Gemini CLI once to refresh it, then click Reconnect." + fallback: L("Your Gemini login has expired. Run the Gemini CLI once to refresh it, then click Reconnect.") ) { Task { await store.bootstrapGemini() } } case let .usage(idle): if let usage = store.geminiUsage { loadedBody(usage: usage, idle: idle) } else { - PlanLoadingView(message: "Reading Gemini credentials...") + PlanLoadingView(message: L("Reading Gemini credentials...")) } } } @@ -2414,7 +2421,7 @@ private struct GeminiPlanInsight: View { .foregroundStyle(.primary) Spacer() if let resetsAt = usage.primary?.resetsAt { - Text("Resets \(relativeReset(resetsAt))") + Text(L("Resets %@", relativeReset(resetsAt))) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } @@ -2428,12 +2435,12 @@ private struct GeminiPlanInsight: View { ) } if idle { - Text("Login idle. Run the Gemini CLI to refresh.") + Text(L("Login idle. Run the Gemini CLI to refresh.")) .font(.system(size: 10)) .foregroundStyle(.tertiary) } if GeminiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) { - Text("as of \(shortTime(usage.fetchedAt))") + Text(L("as of %@", shortTime(usage.fetchedAt))) .font(.system(size: 10)) .foregroundStyle(.tertiary) } @@ -2481,26 +2488,26 @@ private struct CopilotPlanInsight: View { message: CopilotQuotaPresentation.disconnectedPlanMessage ) { Task { await store.connectCopilot() } } case .loading: - PlanLoadingView(message: "Reading Copilot credentials...") + PlanLoadingView(message: L("Reading Copilot credentials...")) case .failed: PlanFailedView( error: store.copilotError ) { Task { await store.refreshCopilot() } } case .transientFailed: PlanFailedView( - error: store.copilotError ?? "GitHub temporarily unreachable. Retrying." + error: store.copilotError ?? L("GitHub temporarily unreachable. Retrying.") ) { Task { await store.refreshCopilot() } } case let .reconnect(reason): PlanReconnectView( - title: "Refresh Copilot login", + title: L("Refresh Copilot login"), reason: reason, - fallback: "Your Copilot sign-in has expired. Sign in via an editor's Copilot plugin again, then click Reconnect." + fallback: L("Your Copilot sign-in has expired. Sign in via an editor's Copilot plugin again, then click Reconnect.") ) { Task { await store.connectCopilot() } } case let .usage(idle): if let usage = store.copilotUsage { loadedBody(usage: usage, idle: idle) } else { - PlanLoadingView(message: "Reading Copilot credentials...") + PlanLoadingView(message: L("Reading Copilot credentials...")) } } } @@ -2524,12 +2531,12 @@ private struct CopilotPlanInsight: View { ) } if idle { - Text("Login idle. Sign in via an editor's Copilot plugin to refresh.") + Text(L("Login idle. Sign in via an editor's Copilot plugin to refresh.")) .font(.system(size: 10)) .foregroundStyle(.tertiary) } if CopilotQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) { - Text("as of \(shortTime(usage.fetchedAt))") + Text(L("as of %@", shortTime(usage.fetchedAt))) .font(.system(size: 10)) .foregroundStyle(.tertiary) } @@ -2559,30 +2566,30 @@ private struct AntigravityPlanInsight: View { switch AntigravityQuotaPresentation.planContent(loadState: store.antigravityLoadState, hasUsage: store.antigravityUsage != nil) { case .noCredentials: PlanNoCredentialsView( - title: "No local Antigravity server found", - message: "Start the Antigravity app, then click Try Again." + title: L("No local Antigravity server found"), + message: L("Start the Antigravity app, then click Try Again.") ) { Task { await store.bootstrapAntigravity() } } case .loading: - PlanLoadingView(message: "Probing the local Antigravity server...") + PlanLoadingView(message: L("Probing the local Antigravity server...")) case .failed: PlanFailedView( error: store.antigravityError ) { Task { await store.refreshAntigravity() } } case .transientFailed: PlanFailedView( - error: store.antigravityError ?? "Local Antigravity server unreachable. Retrying." + error: store.antigravityError ?? L("Local Antigravity server unreachable. Retrying.") ) { Task { await store.refreshAntigravity() } } case let .reconnect(reason): PlanReconnectView( - title: "Reconnect Antigravity", + title: L("Reconnect Antigravity"), reason: reason, - fallback: "Start the Antigravity app, then click Reconnect." + fallback: L("Start the Antigravity app, then click Reconnect.") ) { Task { await store.bootstrapAntigravity() } } case let .usage(idle): if let usage = store.antigravityUsage { loadedBody(usage: usage, idle: idle) } else { - PlanLoadingView(message: "Probing the local Antigravity server...") + PlanLoadingView(message: L("Probing the local Antigravity server...")) } } } @@ -2597,7 +2604,7 @@ private struct AntigravityPlanInsight: View { .foregroundStyle(.primary) Spacer() if let resetsAt = usage.primary?.resetsAt { - Text("Resets \(relativeReset(resetsAt))") + Text(L("Resets %@", relativeReset(resetsAt))) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } @@ -2611,12 +2618,12 @@ private struct AntigravityPlanInsight: View { ) } if idle { - Text("Server disconnected. Start the Antigravity app to refresh.") + Text(L("Server disconnected. Start the Antigravity app to refresh.")) .font(.system(size: 10)) .foregroundStyle(.tertiary) } if AntigravityQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) { - Text("as of \(shortTime(usage.fetchedAt))") + Text(L("as of %@", shortTime(usage.fetchedAt))) .font(.system(size: 10)) .foregroundStyle(.tertiary) } @@ -2722,25 +2729,25 @@ private struct ProjectionCaption: View { // ETA after it, and neither on windows flagged compact. if let delta = projection.deltaPercent { if abs(delta) <= 2 { - return projection.compact ? "On pace" : "On pace: \(projected) at reset" + return projection.compact ? L("On pace") : L("On pace: %@ at reset", projected) } let stage = delta > 0 - ? String(format: "%.0f%% in deficit", delta) - : String(format: "%.0f%% in reserve", -delta) + ? L("%@%% in deficit", String(format: "%.0f", delta)) + : L("%@%% in reserve", String(format: "%.0f", -delta)) if projection.compact { return stage } if projection.willOverflow, let hit = projection.hitsLimitAt { - return "\(stage) · hits 100% \(relativeReset(hit))" + return L("%@ · hits 100%% %@", stage, relativeReset(hit)) } - return "\(stage) · \(projected) at reset" + return L("%@ · %@ at reset", stage, projected) } switch projection.source { case .linear: if projection.willOverflow, let hit = projection.hitsLimitAt { - return "On pace: \(projected) at reset · hits 100% \(relativeReset(hit))" + return L("On pace: %@ at reset · hits 100%% %@", projected, relativeReset(hit)) } - return "On pace: \(projected) at reset" + return L("On pace: %@ at reset", projected) case .historicalBaseline: - return "Based on last cycle: \(projected)" + return L("Based on last cycle: %@", projected) } } } @@ -2772,13 +2779,13 @@ private struct UtilizationBar: View { private func relativeReset(_ date: Date) -> String { let interval = date.timeIntervalSinceNow - if interval <= 0 { return "now" } + if interval <= 0 { return L("now") } let hours = interval / 3600 if hours < 1 { let minutes = Int(ceil(interval / 60)) - return "in \(minutes)m" + return L("in %lldm", minutes) } - if hours < 24 { return "in \(Int(ceil(hours)))h" } + if hours < 24 { return L("in %lldh", Int(ceil(hours))) } let days = Int(ceil(hours / 24)) - return "in \(days)d" + return L("in %lldd", days) } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 4d78b7ded..cb3f08a7e 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -41,7 +41,7 @@ struct HeroSection: View { .monospacedDigit() .foregroundStyle(.tertiary) } else { - Text("\(totals.calls.asThousandsSeparated()) \(totals.calls == 1 ? "call" : "calls")") + Text(totals.calls == 1 ? L("%@ call", totals.calls.asThousandsSeparated()) : L("%@ calls", totals.calls.asThousandsSeparated())) .font(.system(size: 11)) .monospacedDigit() .foregroundStyle(.secondary) @@ -62,7 +62,7 @@ struct HeroSection: View { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) - Text("Daily budget of \(store.dailyBudgetLabel) exceeded") + Text(L("Daily budget of %@ exceeded", store.dailyBudgetLabel)) .font(.system(size: 11, weight: .medium)) } .foregroundStyle(.orange) @@ -75,7 +75,7 @@ struct HeroSection: View { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) - Text("Combined unavailable · showing local") + Text(L("Combined unavailable · showing local")) .font(.system(size: 11, weight: .medium)) } .foregroundStyle(.secondary) @@ -126,7 +126,7 @@ struct HeroSection: View { private var caption: String { let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label if combinedUsage != nil { - return "Combined · \(label)" + return L("Combined · %@", label) } if !store.isDayMode && store.selectedPeriod == .today { return "\(label) · \(todayDate)" @@ -143,12 +143,14 @@ struct HeroSection: View { guard combinedUsage == nil else { return nil } let savings = store.payload.current.localModelSavings.totalUSD guard savings > 0 else { return nil } - return "Saved \(savings.asCurrency()) with local models" + return L("Saved %@ with local models", savings.asCurrency()) } private var todayDate: String { let formatter = DateFormatter() - formatter.dateFormat = "EEE MMM d" + // Localized template, not a fixed pattern: zh-Hans wants "9月11日周五", + // not "Fri Sep 11". + formatter.setLocalizedDateFormatFromTemplate("EEE MMM d") return formatter.string(from: Date()) } } @@ -209,7 +211,7 @@ private struct CombinedDeviceBreakdown: View { HStack(spacing: 4) { Image(systemName: "desktopcomputer") .font(.system(size: 10)) - Text("\(usage.combined.reachableCount) of \(usage.combined.deviceCount) devices") + Text(L("%lld of %lld devices", usage.combined.reachableCount, usage.combined.deviceCount)) .font(.system(size: 11, weight: .medium)) } .foregroundStyle(.secondary) @@ -221,12 +223,12 @@ private struct CombinedDeviceBreakdown: View { .font(.system(size: device.error == nil ? 5 : 9, weight: .semibold)) .foregroundStyle(device.error == nil ? Color.secondary.opacity(0.75) : Theme.semanticWarning) .frame(width: 10) - Text(device.local ? "\(device.name) · local" : device.name) + Text(device.local ? L("%@ · local", device.name) : device.name) .font(.system(size: 10.5, weight: .medium)) .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 6) - Text(device.error == nil ? device.cost.asCurrency() : "Unavailable") + Text(device.error == nil ? device.cost.asCurrency() : L("Unavailable")) .font(.system(size: 10.5)) .monospacedDigit() .foregroundStyle(.secondary) diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index fe34eafd5..e9ec20c49 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -21,7 +21,7 @@ struct MenuBarContent: View { VStack(spacing: 0) { HeroSection() if store.selectedPayloadMayBeIncomplete { - Text("This total may be incomplete.") + Text(L("This total may be incomplete.")) .font(.system(size: 11)) .foregroundStyle(Color.secondary.opacity(0.75)) .frame(maxWidth: .infinity, alignment: .leading) @@ -144,7 +144,7 @@ private struct ScopeSegmentedControl: View { Button { store.switchTo(scope: scope) } label: { - Text(scope.rawValue) + Text(scope.displayLabel) .font(.system(size: 11, weight: .medium)) .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) .frame(maxWidth: .infinity) @@ -181,7 +181,7 @@ private struct ClaudeConfigPicker: View { private var selectedLabel: String { guard let selected = store.selectedClaudeConfigSourceId, let option = store.claudeConfigOptions.first(where: { $0.id == selected }) else { - return "All" + return L("All") } return option.label } @@ -195,7 +195,7 @@ private struct ClaudeConfigPicker: View { if store.selectedClaudeConfigSourceId == nil { Image(systemName: "checkmark") } - Text("All") + Text(L("All")) } } @@ -237,7 +237,7 @@ private struct ClaudeConfigPicker: View { } .menuStyle(.borderlessButton) .fixedSize(horizontal: true, vertical: false) - .help("Claude config") + .help(L("Claude config")) } } @@ -250,7 +250,7 @@ private struct EmptyProviderState: View { Image(systemName: "tray") .font(.system(size: 26)) .foregroundStyle(.tertiary) - Text("No \(provider.rawValue) data for \(periodLabel)") + Text(L("No %@ data for %@", provider.displayLabel, periodLabel)) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -277,7 +277,7 @@ private struct FetchErrorOverlay: View { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 28)) .foregroundStyle(Theme.brandAccent) - Text("Couldn't load \(periodLabel)") + Text(L("Couldn't load %@", periodLabel)) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(.primary) Text(displayError) @@ -286,7 +286,7 @@ private struct FetchErrorOverlay: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) .lineLimit(3) - Button("Retry", action: retry) + Button(L("Retry"), action: retry) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) .controlSize(.small) @@ -323,7 +323,7 @@ private struct BurnLoadingOverlay: View { VStack(spacing: 14) { BurnFlame(size: flameSize, fillProgress: fillProgress, glowing: glowing) - Text("Loading \(periodLabel)…") + Text(L("Loading %@…", periodLabel)) .font(.system(size: 11.5, weight: .medium)) .foregroundStyle(.secondary) } @@ -406,7 +406,7 @@ private struct Header: View { HStack { VStack(alignment: .leading, spacing: 1) { FlameWordmark() - Text("Your AI Bill, Itemized") + Text(L("Your AI Bill, Itemized")) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } @@ -456,9 +456,13 @@ private struct QuotaWarningRow: View { // Reads "Claude over limit (105%)" when any provider exceeds the // quota cap, instead of the awkward "Claude 105% of quota used". if case .danger = status.severity { - return "\(status.warnings[0].name) over limit (\(Int(status.warnings[0].percent.rounded()))%)" + return L( + "%@ over limit (%lld%%)", + status.warnings[0].name, + Int(status.warnings[0].percent.rounded()) + ) } - return "\(parts[0]) of quota used" + return L("%@ of quota used", parts[0]) } return parts.joined(separator: " · ") } @@ -504,7 +508,7 @@ private struct AccentPicker: View { ) } .buttonStyle(.plain) - .accessibilityLabel(preset.rawValue) + .accessibilityLabel(preset.displayLabel) } } .padding(.horizontal, 6) @@ -530,7 +534,7 @@ private struct AccentPicker: View { ) } .buttonStyle(.plain) - .accessibilityLabel("Change accent color") + .accessibilityLabel(L("Change accent color")) .padding(.leading, 4) } } @@ -606,20 +610,20 @@ struct CLIUpdateBanner: View { .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.blue) - Text("CLI \(updateChecker.latestCliVersion ?? "") available") + Text(L("CLI %@ available", updateChecker.latestCliVersion ?? "")) .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(.primary) Button { updateChecker.performFullUpdate() } label: { - Text(updateChecker.isUpdating ? "Updating..." : "Update now") + Text(updateChecker.isUpdating ? L("Updating...") : L("Update now")) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.blue) } .buttonStyle(.plain) .disabled(updateChecker.isUpdating) - .help("Update the CLI (and the menubar if one is available) automatically") + .help(L("Update the CLI (and the menubar if one is available) automatically")) Button { NSPasteboard.general.clearContents() @@ -634,7 +638,7 @@ struct CLIUpdateBanner: View { .foregroundStyle(.blue) } .buttonStyle(.plain) - .help("Copy update command to clipboard") + .help(L("Copy update command to clipboard")) Spacer(minLength: 0) } @@ -669,9 +673,9 @@ struct StarBanner: View { NSWorkspace.shared.open(starBannerGitHubURL) } label: { HStack(spacing: 4) { - Text("Enjoying CodeBurn?") + Text(L("Enjoying CodeBurn?")) .foregroundStyle(.primary) - Text("Star us on GitHub") + Text(L("Star us on GitHub")) .foregroundStyle(Theme.brandAccent) .underline(true, pattern: .solid) } @@ -692,7 +696,7 @@ struct StarBanner: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help("Hide this banner") + .help(L("Hide this banner")) } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -717,9 +721,9 @@ struct FooterBar: View { applyCurrency(code: currency.rawValue) } label: { if currency.rawValue == store.currency { - Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark") + Label(currency.pickerLabel, systemImage: "checkmark") } else { - Text("\(currency.displayName) (\(currency.rawValue))") + Text(currency.pickerLabel) } } } @@ -745,10 +749,10 @@ struct FooterBar: View { .disabled(store.isLoading) Menu { - Button("CSV (folder)") { runExport(format: .csv) } - Button("JSON") { runExport(format: .json) } + Button(L("CSV (folder)")) { runExport(format: .csv) } + Button(L("JSON")) { runExport(format: .json) } } label: { - Label("Export", systemImage: "square.and.arrow.down") + Label(L("Export"), systemImage: "square.and.arrow.down") .font(.system(size: 11, weight: .medium)) .labelStyle(.titleAndIcon) } @@ -765,7 +769,7 @@ struct FooterBar: View { .foregroundStyle(.tertiary) Button { openReport() } label: { - Label("Full Report", systemImage: "terminal") + Label(L("Full Report"), systemImage: "terminal") .font(.system(size: 11, weight: .semibold)) .labelStyle(.titleAndIcon) } diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift index 4cc90d4bc..4a4365d6d 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift @@ -13,15 +13,15 @@ struct ModelsSection: View { var body: some View { CollapsibleSection( - caption: "Models", + caption: L("Models"), isExpanded: $isExpanded, trailing: { HStack(spacing: 8) { - Text("Cost").frame(minWidth: 54, alignment: .trailing) + Text(L("Cost")).frame(minWidth: 54, alignment: .trailing) if showSavings { - Text("Saved").frame(minWidth: 54, alignment: .trailing) + Text(L("Saved")).frame(minWidth: 54, alignment: .trailing) } - Text("Calls").frame(minWidth: 52, alignment: .trailing) + Text(L("Calls")).frame(minWidth: 52, alignment: .trailing) } .font(.system(size: 10, weight: .medium)) .foregroundStyle(.tertiary) @@ -90,17 +90,17 @@ private struct TokensLine: View { let cacheHit = String(format: "%.0f", t.cacheHitPercent) HStack(spacing: 4) { - Text("Tokens") + Text(L("Tokens")) .foregroundStyle(.tertiary) - Text(formatTokens(t.inputTokens) + " in") + Text(L("%@ in", formatTokens(t.inputTokens))) .foregroundStyle(.secondary) - Text("·") + Text(verbatim: "·") .foregroundStyle(.tertiary) - Text(formatTokens(t.outputTokens) + " out") + Text(L("%@ out", formatTokens(t.outputTokens))) .foregroundStyle(.secondary) - Text("·") + Text(verbatim: "·") .foregroundStyle(.tertiary) - Text(cacheHit + "% cache hit") + Text(L("%@%% cache hit", cacheHit)) .foregroundStyle(.secondary) Spacer() } diff --git a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift index 5c2307d41..54a63570c 100644 --- a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift +++ b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift @@ -11,7 +11,7 @@ struct PeriodSegmentedControl: View { Button { store.switchTo(period: period) } label: { - Text(period.rawValue) + Text(period.displayLabel) .font(.system(size: 11, weight: .medium)) .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) .frame(maxWidth: .infinity) @@ -65,7 +65,13 @@ private struct CalendarPopover: View { @State private var pending: Set = [] private let calendar = Calendar.current - private let weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + /// Monday-first, from the current locale's own very-short weekday symbols + /// (Sunday-indexed), so zh-Hans renders 一…日 without a translation key. + private let weekdays: [String] = { + let symbols = Calendar.current.veryShortWeekdaySymbols + guard symbols.count == 7 else { return symbols } + return Array(symbols[1...]) + [symbols[0]] + }() private let cellSize: CGFloat = 30 var body: some View { @@ -128,7 +134,7 @@ private struct CalendarPopover: View { HStack(spacing: 8) { if !pending.isEmpty { - Button("Clear") { + Button(L("Clear")) { pending = [] } .font(.system(size: 11, weight: .medium)) @@ -153,7 +159,7 @@ private struct CalendarPopover: View { } isPresented = false } label: { - Text("Done") + Text(L("Done")) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 14) @@ -185,7 +191,8 @@ private struct CalendarPopover: View { private var monthYearLabel: String { let f = DateFormatter() - f.dateFormat = "MMMM yyyy" + // Localized template, not a fixed pattern: zh-Hans wants "2026年9月". + f.setLocalizedDateFormatFromTemplate("MMMM yyyy") return f.string(from: displayMonth) } @@ -195,9 +202,9 @@ private struct CalendarPopover: View { } private var selectionSummary: String { - if pending.isEmpty { return "Pick dates" } - if pending.count == 1 { return "1 day" } - return "\(pending.count) days" + if pending.isEmpty { return L("Pick dates") } + if pending.count == 1 { return L("1 day") } + return L("%lld days", pending.count) } private func shiftMonth(_ delta: Int) { diff --git a/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift b/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift index be7bbc4d3..5824ed60b 100644 --- a/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift @@ -13,7 +13,7 @@ struct PullRequestsSection: View { VStack(spacing: 0) { Divider().opacity(0.5) VStack(alignment: .leading, spacing: 6) { - SectionCaption(text: "Pull requests") + SectionCaption(text: L("Pull requests")) ForEach(rows, id: \.url) { row in HStack(spacing: 8) { Text(row.label) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 912112818..aafac5ee1 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -107,7 +107,7 @@ struct SettingsView: View { List(selection: selection) { Section { - SettingsSidebarPaneRow(pane: "general", title: "General", systemImage: "gearshape.fill", color: .gray) + SettingsSidebarPaneRow(pane: "general", title: L("General"), systemImage: "gearshape.fill", color: .gray) SettingsSidebarAboutRow() } Section { @@ -117,9 +117,9 @@ struct SettingsView: View { } } header: { HStack(spacing: 4) { - Text("Providers") + Text(L("Providers")) Spacer() - Text("\(providers.filter(\.isConnected).count) on") + Text(L("%lld on", providers.filter(\.isConnected).count)) .foregroundStyle(.tertiary) .monospacedDigit() .padding(.trailing, 10) @@ -134,10 +134,10 @@ struct SettingsView: View { private var currentPaneTitle: String { switch selection.wrappedValue { - case "general": return "General" - case "about": return "About" + case "general": return L("General") + case "about": return L("About") default: - return providers.first { $0.id == selection.wrappedValue }?.name ?? "Settings" + return providers.first { $0.id == selection.wrappedValue }?.name ?? L("Settings") } } @@ -207,7 +207,7 @@ private struct SettingsSidebarAboutRow: View { HStack(spacing: 8) { // Standard info glyph in a chip, matching the General row's style. SettingsIconChip(systemImage: "info.circle.fill", color: .gray) - Text("About") + Text(L("About")) } .tag("about") } @@ -267,7 +267,7 @@ private struct SettingsSidebarSearchField: View { .foregroundStyle(.secondary) .accessibilityHidden(true) - TextField("Search providers", text: $searchText) + TextField(L("Search providers"), text: $searchText) .textFieldStyle(.plain) if !searchText.isEmpty { @@ -276,7 +276,7 @@ private struct SettingsSidebarSearchField: View { } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) - .accessibilityLabel("Clear") + .accessibilityLabel(L("Clear")) } .buttonStyle(.plain) } @@ -330,7 +330,7 @@ private struct SettingsWindowStyleAccessor: NSViewRepresentable { } private final class SettingsWindowStyleView: NSView { - var paneTitle = "Settings" + var paneTitle = L("Settings") override func viewDidMoveToWindow() { super.viewDidMoveToWindow() @@ -414,32 +414,34 @@ private struct GeneralSettingsTab: View { let customEmpty = store.isTokenMetric ? (tokenCustom && store.dailyTokenBudget == 0) : (costCustom && store.dailyBudget == 0) - if customEmpty { return "Enter an amount above, or the alert stays off." } - return "Flame icon turns yellow when today's \(store.isTokenMetric ? "tokens" : "cost") pass the daily budget." + if customEmpty { return L("Enter an amount above, or the alert stays off.") } + return store.isTokenMetric + ? L("Flame icon turns yellow when today's tokens pass the daily budget.") + : L("Flame icon turns yellow when today's cost pass the daily budget.") } var body: some View { Form { - Section("Display") { - Picker("Currency", selection: Binding( + Section(L("Display")) { + Picker(L("Currency"), selection: Binding( get: { store.currency }, set: { applyCurrency(code: $0) } )) { ForEach(SupportedCurrency.allCases) { currency in - Text("\(currency.rawValue) · \(currency.displayName)").tag(currency.rawValue) + Text(currency.pickerLabel).tag(currency.rawValue) } } - Picker("Metric", selection: Binding( + Picker(L("Metric"), selection: Binding( get: { store.displayMetric }, set: { store.displayMetric = $0 } )) { - Text("Cost ($)").tag(DisplayMetric.cost) - Text("Tokens (↑↓)").tag(DisplayMetric.tokens) - Text("Total Tokens").tag(DisplayMetric.totalTokens) - Text("Credits (Codex)").tag(DisplayMetric.credits) - Text("Icon Only").tag(DisplayMetric.iconOnly) + Text(L("Cost ($)")).tag(DisplayMetric.cost) + Text(L("Tokens (↑↓)")).tag(DisplayMetric.tokens) + Text(L("Total Tokens")).tag(DisplayMetric.totalTokens) + Text(L("Credits (Codex)")).tag(DisplayMetric.credits) + Text(L("Icon Only")).tag(DisplayMetric.iconOnly) } - Picker("Period", selection: Binding( + Picker(L("Period"), selection: Binding( get: { store.menubarPeriod }, set: { store.setMenubarPeriod($0) } )) { @@ -448,29 +450,29 @@ private struct GeneralSettingsTab: View { } } .pickerStyle(.menu) - Picker("Scope", selection: Binding( + Picker(L("Scope"), selection: Binding( get: { store.menubarScope }, set: { store.setMenubarScope($0) } )) { ForEach(MenubarScope.allCases) { scope in - Text(scope.rawValue).tag(scope) + Text(scope.displayLabel).tag(scope) } } .pickerStyle(.menu) - Picker("Accent", selection: Binding( + Picker(L("Accent"), selection: Binding( get: { store.accentPreset }, set: { store.accentPreset = $0 } )) { ForEach(AccentPreset.allCases) { preset in - Text(preset.rawValue).tag(preset) + Text(preset.displayLabel).tag(preset) } } } CapacityDockSettingsSection() - Section("Usage Refresh") { - Picker("Update every", selection: Binding( + Section(L("Usage Refresh")) { + Picker(L("Update every"), selection: Binding( get: { UsageRefreshCadence(rawValue: usageRefreshSeconds) ?? .default }, set: { usageRefreshSeconds = $0.rawValue } )) { @@ -479,40 +481,40 @@ private struct GeneralSettingsTab: View { } } .pickerStyle(.menu) - Text("How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now.") + Text(L("How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } - Section("Updates") { - Toggle("Notify me about updates", isOn: $notifyAboutUpdates) - Text("Posts a notification when a new CodeBurn release is available. Click it to install.") + Section(L("Updates")) { + Toggle(L("Notify me about updates"), isOn: $notifyAboutUpdates) + Text(L("Posts a notification when a new CodeBurn release is available. Click it to install.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } - Section("Terminal") { - Picker("Open commands in", selection: Binding( + Section(L("Terminal")) { + Picker(L("Open commands in"), selection: Binding( get: { PreferredTerminal(rawValue: preferredTerminalRaw) ?? .default }, set: { preferredTerminalRaw = $0.rawValue } )) { ForEach(PreferredTerminal.allCases) { terminal in - Text(terminal.isInstalled ? terminal.label : "\(terminal.label) (not installed)") + Text(terminal.isInstalled ? terminal.label : L("%@ (not installed)", terminal.label)) .tag(terminal) } } .pickerStyle(.menu) - Text("Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed.") + Text(L("Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } - Section("Alerts") { + Section(L("Alerts")) { // The budget tracks whatever the menubar metric shows: dollars for // the Cost metric, tokens for the Tokens / Total Tokens metrics. // "Custom…" reveals a field for an exact amount. if store.isTokenMetric { - Picker("Daily budget", selection: Binding( + Picker(L("Daily budget"), selection: Binding( get: { tokenCustom ? -1.0 : store.dailyTokenBudget }, set: { sel in if sel < 0 { @@ -524,26 +526,26 @@ private struct GeneralSettingsTab: View { } } )) { - Text("Off").tag(0.0) + Text(L("Off")).tag(0.0) Text("1M").tag(1_000_000.0) Text("5M").tag(5_000_000.0) Text("10M").tag(10_000_000.0) Text("25M").tag(25_000_000.0) Text("50M").tag(50_000_000.0) Text("100M").tag(100_000_000.0) - Text("Custom…").tag(-1.0) + Text(L("Custom…")).tag(-1.0) } if tokenCustom { HStack { - TextField("Amount", text: $tokenText) + TextField(L("Amount"), text: $tokenText) .multilineTextAlignment(.trailing) .onSubmit { applyTokenBudget() } .onChange(of: tokenText) { _, _ in applyTokenBudget() } - Text("M tokens").foregroundStyle(.secondary) + Text(L("M tokens")).foregroundStyle(.secondary) } } } else { - Picker("Daily budget", selection: Binding( + Picker(L("Daily budget"), selection: Binding( get: { costCustom ? -1.0 : store.dailyBudget }, set: { sel in if sel < 0 { @@ -555,18 +557,18 @@ private struct GeneralSettingsTab: View { } } )) { - Text("Off").tag(0.0) + Text(L("Off")).tag(0.0) Text("$25").tag(25.0) Text("$50").tag(50.0) Text("$100").tag(100.0) Text("$200").tag(200.0) Text("$500").tag(500.0) - Text("Custom…").tag(-1.0) + Text(L("Custom…")).tag(-1.0) } if costCustom { HStack { Text("$").foregroundStyle(.secondary) - TextField("Amount", text: $costText) + TextField(L("Amount"), text: $costText) .multilineTextAlignment(.trailing) .onSubmit { applyCostBudget() } .onChange(of: costText) { _, _ in applyCostBudget() } @@ -623,14 +625,14 @@ private struct CapacityDockSettingsSection: View { } var body: some View { - Section("Capacity Dock") { - Toggle("Show Capacity Dock", isOn: Binding( + Section(L("Capacity Dock")) { + Toggle(L("Show Capacity Dock"), isOn: Binding( get: { snapshot.isEnabled }, set: { CapacityDockPreferences.setEnabled($0) } )) if !enabledEligibleProviders.isEmpty { - Picker("Resting provider", selection: Binding( + Picker(L("Resting provider"), selection: Binding( get: { enabledEligibleProviders.contains(snapshot.preferredProvider) ? snapshot.preferredProvider @@ -646,7 +648,7 @@ private struct CapacityDockSettingsSection: View { } HStack(spacing: 10) { - Text("Size") + Text(L("Size")) Slider( value: Binding( get: { snapshot.scale }, @@ -655,14 +657,14 @@ private struct CapacityDockSettingsSection: View { in: CapacityDockPreferences.scaleRange, step: 0.05 ) - .accessibilityLabel("Capacity Dock size") + .accessibilityLabel(L("Capacity Dock size")) Text("\(Int((snapshot.scale * 100).rounded()))%") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 38, alignment: .trailing) } - Picker("Appearance", selection: Binding( + Picker(L("Appearance"), selection: Binding( get: { snapshot.theme }, set: { CapacityDockPreferences.setTheme($0) } )) { @@ -672,7 +674,7 @@ private struct CapacityDockSettingsSection: View { } .pickerStyle(.menu) - Picker("Gauge shape", selection: Binding( + Picker(L("Gauge shape"), selection: Binding( get: { snapshot.gaugeShape }, set: { CapacityDockPreferences.setGaugeShape($0) } )) { @@ -682,13 +684,13 @@ private struct CapacityDockSettingsSection: View { } .pickerStyle(.menu) - Text("Dock providers") + Text(L("Dock providers")) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .padding(.top, 4) if manageableProviders.isEmpty { - Text("Connect a provider from its sidebar page to make it available here.") + Text(L("Connect a provider from its sidebar page to make it available here.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -708,7 +710,7 @@ private struct CapacityDockSettingsSection: View { } Text(provider.displayName) if !store.capacityDockProviderIsConnected(provider) { - Text("Needs attention") + Text(L("Needs attention")) .font(.system(size: 10)) .foregroundStyle(.red) } @@ -722,7 +724,7 @@ private struct CapacityDockSettingsSection: View { )) } - Text("Connected providers and anything already shown in the dock appear here, so a provider can always be removed even if its connection later fails.") + Text(L("Connected providers and anything already shown in the dock appear here, so a provider can always be removed even if its connection later fails.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -758,20 +760,20 @@ private struct ClaudeSettingsTab: View { var body: some View { Form { - Section("Connection") { + Section(L("Connection")) { ClaudeConnectionRow() } Section { ClaudeConfigDirsSection() } header: { - Text("Config Directories") + Text(L("Config Directories")) } footer: { - Text("Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list.") + Text(L("Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } - Section("Quota Refresh") { - Picker("Update every", selection: Binding( + Section(L("Quota Refresh")) { + Picker(L("Update every"), selection: Binding( get: { SubscriptionRefreshCadence.current }, set: { SubscriptionRefreshCadence.current = $0 } )) { @@ -780,10 +782,10 @@ private struct ClaudeSettingsTab: View { } } .pickerStyle(.menu) - Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.") + Text(L("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.")) .font(.system(size: 11)) .foregroundStyle(.secondary) - Button("Refresh Now") { + Button(L("Refresh Now")) { if let delegate = NSApp.delegate as? AppDelegate { delegate.refreshSubscriptionNow() } else { @@ -843,14 +845,14 @@ private struct ClaudeConnectionRow: View { private var stateTitle: String { switch store.subscriptionLoadState { - case .loaded: return "Connected" - case let .terminalFailure(reason): return reason ?? "Reconnect required" - case .transientFailure: return "Backing off" - case .bootstrapping: return "Connecting…" - case .loading: return "Refreshing…" - case .dormant: return "Ready" - case .notBootstrapped, .noCredentials: return "Not connected" - case .failed: return "Couldn't load plan data" + case .loaded: return L("Connected") + case let .terminalFailure(reason): return reason ?? L("Reconnect required") + case .transientFailure: return L("Backing off") + case .bootstrapping: return L("Connecting…") + case .loading: return L("Refreshing…") + case .dormant: return L("Ready") + case .notBootstrapped, .noCredentials: return L("Not connected") + case .failed: return L("Couldn't load plan data") } } @@ -858,15 +860,15 @@ private struct ClaudeConnectionRow: View { switch store.subscriptionLoadState { case .loaded: if let tier = store.subscription?.tier.displayName { - return "Plan: \(tier)" - } - return "Live quota tracked from Anthropic." - case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect." - case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying." - case .bootstrapping: return "macOS may ask permission to read your credentials." - case .loading: return "Background refresh in progress." - case .dormant: return "Tap Load Quota to fetch live usage from Anthropic." - case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota." + return L("Plan: %@", tier) + } + return L("Live quota tracked from Anthropic.") + case .terminalFailure: return L("Open Claude Code in your terminal and type `/login`, then click Reconnect.") + case .transientFailure: return store.subscriptionError ?? L("Anthropic rate-limited; auto-retrying.") + case .bootstrapping: return L("macOS may ask permission to read your credentials.") + case .loading: return L("Background refresh in progress.") + case .dormant: return L("Tap Load Quota to fetch live usage from Anthropic.") + case .notBootstrapped, .noCredentials: return L("Click Connect to read your Claude Code credentials and start tracking quota.") case .failed: return store.subscriptionError ?? "" } } @@ -875,26 +877,26 @@ private struct ClaudeConnectionRow: View { private var actionButton: some View { switch store.subscriptionLoadState { case .loaded, .transientFailure, .loading: - Button("Disconnect") { showDisconnectConfirm = true } + Button(L("Disconnect")) { showDisconnectConfirm = true } .confirmationDialog( - "Disconnect Claude?", + L("Disconnect Claude?"), isPresented: $showDisconnectConfirm ) { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { store.disconnectSubscription() } - Button("Cancel", role: .cancel) {} + Button(L("Cancel"), role: .cancel) {} } message: { - Text("CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your Claude Code credential is untouched. Claude Code keeps working.") + Text(L("CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your Claude Code credential is untouched. Claude Code keeps working.")) } case .terminalFailure, .noCredentials, .failed: - Button("Reconnect") { Task { await store.bootstrapSubscription() } } + Button(L("Reconnect")) { Task { await store.bootstrapSubscription() } } .buttonStyle(.borderedProminent) case .dormant: - Button("Load Quota") { Task { await store.activateClaudeFromDormant() } } + Button(L("Load Quota")) { Task { await store.activateClaudeFromDormant() } } .buttonStyle(.borderedProminent) case .notBootstrapped: - Button("Connect") { Task { await store.bootstrapSubscription() } } + Button(L("Connect")) { Task { await store.bootstrapSubscription() } } .buttonStyle(.borderedProminent) case .bootstrapping: ProgressView().controlSize(.small) @@ -911,7 +913,7 @@ private struct ClaudeConfigDirsSection: View { var body: some View { VStack(alignment: .leading, spacing: 8) { if dirs.isEmpty { - Text("No extra directories. Tracking the default `~/.claude`.") + Text(L("No extra directories. Tracking the default `~/.claude`.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } else { @@ -933,7 +935,7 @@ private struct ClaudeConfigDirsSection: View { .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Remove") + .help(L("Remove")) } } } @@ -941,7 +943,7 @@ private struct ClaudeConfigDirsSection: View { Button { addDirectory() } label: { - Label("Add Directory…", systemImage: "plus") + Label(L("Add Directory…"), systemImage: "plus") } .controlSize(.small) } @@ -953,8 +955,8 @@ private struct ClaudeConfigDirsSection: View { panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = true - panel.prompt = "Add" - panel.message = "Choose one or more Claude config directories (each containing a `projects` folder)." + panel.prompt = L("Add") + panel.message = L("Choose one or more Claude config directories (each containing a `projects` folder).") guard panel.runModal() == .OK else { return } let added = panel.urls.map { $0.path } @@ -992,11 +994,11 @@ private struct CodexSettingsTab: View { CodexConnectionRow() } Section { - Text("Codex live-quota tracking follows the authoritative `~/.codex/auth.json` session directly and does not create a second Keychain copy. A legacy CodeBurn Keychain item, when present, is read only as a migration fallback. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.") + Text(L("Codex live-quota tracking follows the authoritative `~/.codex/auth.json` session directly and does not create a second Keychain copy. A legacy CodeBurn Keychain item, when present, is read only as a migration fallback. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -1050,14 +1052,14 @@ private struct CodexConnectionRow: View { private var stateTitle: String { switch store.codexLoadState { - case .loaded: return "Connected" - case let .terminalFailure(reason): return reason ?? "Reconnect required" - case .transientFailure: return "Backing off" - case .bootstrapping: return "Connecting…" - case .loading: return "Refreshing…" - case .dormant: return "Ready" - case .notBootstrapped, .noCredentials: return "Not connected" - case .failed: return "Couldn't load Codex quota" + case .loaded: return L("Connected") + case let .terminalFailure(reason): return reason ?? L("Reconnect required") + case .transientFailure: return L("Backing off") + case .bootstrapping: return L("Connecting…") + case .loading: return L("Refreshing…") + case .dormant: return L("Ready") + case .notBootstrapped, .noCredentials: return L("Not connected") + case .failed: return L("Couldn't load Codex quota") } } @@ -1065,23 +1067,23 @@ private struct CodexConnectionRow: View { switch store.codexLoadState { case .loaded: if let plan = store.codexUsage?.plan.displayName { - return "Plan: \(plan)" + return L("Plan: %@", plan) } - return "Live quota tracked from chatgpt.com." + return L("Live quota tracked from chatgpt.com.") case .terminalFailure: // Be specific about the cause: the message we already surface in // codexError will say "API-key mode" if that's the situation, so // the generic "run codex login" hint covers both cases. if let err = store.codexError, err.lowercased().contains("api-key") { - return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking." + return L("Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking.") } - return "Run `codex login` in your terminal to sign in again, then click Reconnect." - case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying." - case .bootstrapping: return "Reading ~/.codex/auth.json." - case .loading: return "Background refresh in progress." - case .dormant: return "Tap Load Quota to fetch live usage from chatgpt.com." + return L("Run `codex login` in your terminal to sign in again, then click Reconnect.") + case .transientFailure: return store.codexError ?? L("ChatGPT rate-limited; auto-retrying.") + case .bootstrapping: return L("Reading ~/.codex/auth.json.") + case .loading: return L("Background refresh in progress.") + case .dormant: return L("Tap Load Quota to fetch live usage from chatgpt.com.") case .notBootstrapped, .noCredentials: - return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json." + return L("Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json.") case .failed: return store.codexError ?? "" } } @@ -1090,26 +1092,26 @@ private struct CodexConnectionRow: View { private var actionButton: some View { switch store.codexLoadState { case .loaded, .transientFailure, .loading: - Button("Disconnect") { showDisconnectConfirm = true } + Button(L("Disconnect")) { showDisconnectConfirm = true } .confirmationDialog( - "Disconnect Codex?", + L("Disconnect Codex?"), isPresented: $showDisconnectConfirm ) { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { store.disconnectCodex() } - Button("Cancel", role: .cancel) {} + Button(L("Cancel"), role: .cancel) {} } message: { - Text("CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your ~/.codex/auth.json is untouched. Codex CLI keeps working.") + Text(L("CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your ~/.codex/auth.json is untouched. Codex CLI keeps working.")) } case .terminalFailure, .noCredentials, .failed: - Button("Reconnect") { Task { await store.bootstrapCodex() } } + Button(L("Reconnect")) { Task { await store.bootstrapCodex() } } .buttonStyle(.borderedProminent) case .dormant: - Button("Load Quota") { Task { await store.activateCodexFromDormant() } } + Button(L("Load Quota")) { Task { await store.activateCodexFromDormant() } } .buttonStyle(.borderedProminent) case .notBootstrapped: - Button("Connect") { Task { await store.bootstrapCodex() } } + Button(L("Connect")) { Task { await store.bootstrapCodex() } } .buttonStyle(.borderedProminent) case .bootstrapping: ProgressView().controlSize(.small) @@ -1122,15 +1124,15 @@ private struct CodexConnectionRow: View { private struct KimiSettingsTab: View { var body: some View { Form { - Section("Connection") { + Section(L("Connection")) { KimiConnectionRow() } Section { - Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly. Nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.") + Text(L("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly. Nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -1184,29 +1186,29 @@ private struct KimiConnectionRow: View { private var stateTitle: String { switch store.kimiLoadState { - case .loaded: return "Connected" - case let .terminalFailure(reason): return reason ?? "Login refresh required" - case .transientFailure: return "Backing off" - case .bootstrapping: return "Connecting…" - case .loading: return "Refreshing…" - case .dormant: return "Ready" - case .notBootstrapped, .noCredentials: return "Not connected" - case .failed: return "Couldn't load Kimi quota" + case .loaded: return L("Connected") + case let .terminalFailure(reason): return reason ?? L("Login refresh required") + case .transientFailure: return L("Backing off") + case .bootstrapping: return L("Connecting…") + case .loading: return L("Refreshing…") + case .dormant: return L("Ready") + case .notBootstrapped, .noCredentials: return L("Not connected") + case .failed: return L("Couldn't load Kimi quota") } } private var stateDetail: String { switch store.kimiLoadState { case .loaded: - return "Live quota tracked from api.kimi.com." + return L("Live quota tracked from api.kimi.com.") case .terminalFailure: - return "Run the Kimi CLI once to refresh your login, then click Reconnect." - case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying." - case .bootstrapping: return "Reading ~/.kimi-code credentials." - case .loading: return "Background refresh in progress." - case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com." + return L("Run the Kimi CLI once to refresh your login, then click Reconnect.") + case .transientFailure: return store.kimiError ?? L("Kimi rate-limited; auto-retrying.") + case .bootstrapping: return L("Reading ~/.kimi-code credentials.") + case .loading: return L("Background refresh in progress.") + case .dormant: return L("Tap Load Quota to fetch live usage from api.kimi.com.") case .notBootstrapped, .noCredentials: - return "Sign in with the Kimi CLI first, then click Connect." + return L("Sign in with the Kimi CLI first, then click Connect.") case .failed: return store.kimiError ?? "" } } @@ -1215,26 +1217,26 @@ private struct KimiConnectionRow: View { private var actionButton: some View { switch store.kimiLoadState { case .loaded, .transientFailure, .loading: - Button("Disconnect") { showDisconnectConfirm = true } + Button(L("Disconnect")) { showDisconnectConfirm = true } .confirmationDialog( - "Disconnect Kimi Code?", + L("Disconnect Kimi Code?"), isPresented: $showDisconnectConfirm ) { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { store.disconnectKimi() } - Button("Cancel", role: .cancel) {} + Button(L("Cancel"), role: .cancel) {} } message: { - Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched. The Kimi CLI keeps working.") + Text(L("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched. The Kimi CLI keeps working.")) } case .terminalFailure, .noCredentials, .failed: - Button("Reconnect") { Task { await store.bootstrapKimi() } } + Button(L("Reconnect")) { Task { await store.bootstrapKimi() } } .buttonStyle(.borderedProminent) case .dormant: - Button("Load Quota") { Task { await store.bootstrapKimi() } } + Button(L("Load Quota")) { Task { await store.bootstrapKimi() } } .buttonStyle(.borderedProminent) case .notBootstrapped: - Button("Connect") { Task { await store.bootstrapKimi() } } + Button(L("Connect")) { Task { await store.bootstrapKimi() } } .buttonStyle(.borderedProminent) case .bootstrapping: ProgressView().controlSize(.small) @@ -1247,15 +1249,15 @@ private struct KimiConnectionRow: View { private struct GeminiSettingsTab: View { var body: some View { Form { - Section("Connection") { + Section(L("Connection")) { GeminiConnectionRow() } Section { - Text("Gemini live-quota tracking reads `~/.gemini/oauth_creds.json` read-only. Nothing is copied or stored, and tokens stay in memory. If the connection shows as expired, run the Gemini CLI once to refresh your login, then click Reconnect.") + Text(L("Gemini live-quota tracking reads `~/.gemini/oauth_creds.json` read-only. Nothing is copied or stored, and tokens stay in memory. If the connection shows as expired, run the Gemini CLI once to refresh your login, then click Reconnect.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -1309,14 +1311,14 @@ private struct GeminiConnectionRow: View { private var stateTitle: String { switch store.geminiLoadState { - case .loaded: return "Connected" - case let .terminalFailure(reason): return reason ?? "Login refresh required" - case .transientFailure: return "Backing off" - case .bootstrapping: return "Connecting…" - case .loading: return "Refreshing…" - case .dormant: return "Ready" - case .notBootstrapped, .noCredentials: return "Not connected" - case .failed: return "Couldn't load Gemini quota" + case .loaded: return L("Connected") + case let .terminalFailure(reason): return reason ?? L("Login refresh required") + case .transientFailure: return L("Backing off") + case .bootstrapping: return L("Connecting…") + case .loading: return L("Refreshing…") + case .dormant: return L("Ready") + case .notBootstrapped, .noCredentials: return L("Not connected") + case .failed: return L("Couldn't load Gemini quota") } } @@ -1324,17 +1326,17 @@ private struct GeminiConnectionRow: View { switch store.geminiLoadState { case .loaded: if let plan = store.geminiUsage?.plan { - return "Plan: \(plan)" + return L("Plan: %@", plan) } - return "Live quota tracked from Google Code Assist." + return L("Live quota tracked from Google Code Assist.") case .terminalFailure: - return "Run the Gemini CLI once to refresh your login, then click Reconnect." - case .transientFailure: return store.geminiError ?? "Gemini rate-limited; auto-retrying." - case .bootstrapping: return "Reading ~/.gemini credentials." - case .loading: return "Background refresh in progress." - case .dormant: return "Tap Load Quota to fetch live usage from Google Code Assist." + return L("Run the Gemini CLI once to refresh your login, then click Reconnect.") + case .transientFailure: return store.geminiError ?? L("Gemini rate-limited; auto-retrying.") + case .bootstrapping: return L("Reading ~/.gemini credentials.") + case .loading: return L("Background refresh in progress.") + case .dormant: return L("Tap Load Quota to fetch live usage from Google Code Assist.") case .notBootstrapped, .noCredentials: - return "Sign in with the Gemini CLI first, then click Connect." + return L("Sign in with the Gemini CLI first, then click Connect.") case .failed: return store.geminiError ?? "" } } @@ -1343,26 +1345,26 @@ private struct GeminiConnectionRow: View { private var actionButton: some View { switch store.geminiLoadState { case .loaded, .transientFailure, .loading: - Button("Disconnect") { showDisconnectConfirm = true } + Button(L("Disconnect")) { showDisconnectConfirm = true } .confirmationDialog( - "Disconnect Gemini?", + L("Disconnect Gemini?"), isPresented: $showDisconnectConfirm ) { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { store.disconnectGemini() } - Button("Cancel", role: .cancel) {} + Button(L("Cancel"), role: .cancel) {} } message: { - Text("CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working.") + Text(L("CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working.")) } case .terminalFailure, .noCredentials, .failed: - Button("Reconnect") { Task { await store.bootstrapGemini() } } + Button(L("Reconnect")) { Task { await store.bootstrapGemini() } } .buttonStyle(.borderedProminent) case .dormant: - Button("Load Quota") { Task { await store.bootstrapGemini() } } + Button(L("Load Quota")) { Task { await store.bootstrapGemini() } } .buttonStyle(.borderedProminent) case .notBootstrapped: - Button("Connect") { Task { await store.bootstrapGemini() } } + Button(L("Connect")) { Task { await store.bootstrapGemini() } } .buttonStyle(.borderedProminent) case .bootstrapping: ProgressView().controlSize(.small) @@ -1375,16 +1377,16 @@ private struct GeminiConnectionRow: View { private struct CopilotSettingsTab: View { var body: some View { Form { - Section("Connection") { + Section(L("Connection")) { CopilotConnectionRow() } CopilotTokenSection() Section { - Text("Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token.") + Text(L("Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -1402,12 +1404,12 @@ private struct CopilotTokenSection: View { var body: some View { Section { - SecureField("GitHub token", text: $token) + SecureField(L("GitHub token"), text: $token) HStack { - Button("Save & Connect") { save(token) } + Button(L("Save & Connect")) { save(token) } .buttonStyle(.borderedProminent) .disabled(isSaving || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - Button("Clear Token") { save("") } + Button(L("Clear Token")) { save("") } .disabled(isSaving) if isSaving { ProgressView().controlSize(.small) @@ -1419,9 +1421,9 @@ private struct CopilotTokenSection: View { .foregroundStyle(.red) } } header: { - Text("Paste a token") + Text(L("Paste a token")) } footer: { - Text("Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota.") + Text(L("Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota.")) .font(.system(size: 11)) } } @@ -1492,14 +1494,14 @@ private struct CopilotConnectionRow: View { private var stateTitle: String { switch store.copilotLoadState { - case .loaded: return "Connected" - case let .terminalFailure(reason): return reason ?? "Login refresh required" - case .transientFailure: return "Backing off" - case .bootstrapping: return "Connecting…" - case .loading: return "Refreshing…" - case .dormant: return "Ready" - case .notBootstrapped, .noCredentials: return "Not connected" - case .failed: return "Couldn't load Copilot quota" + case .loaded: return L("Connected") + case let .terminalFailure(reason): return reason ?? L("Login refresh required") + case .transientFailure: return L("Backing off") + case .bootstrapping: return L("Connecting…") + case .loading: return L("Refreshing…") + case .dormant: return L("Ready") + case .notBootstrapped, .noCredentials: return L("Not connected") + case .failed: return L("Couldn't load Copilot quota") } } @@ -1507,15 +1509,15 @@ private struct CopilotConnectionRow: View { switch store.copilotLoadState { case .loaded: if let plan = store.copilotUsage?.plan { - return "Plan: \(plan)" + return L("Plan: %@", plan) } - return "Live quota tracked from api.github.com." + return L("Live quota tracked from api.github.com.") case .terminalFailure: - return "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." - case .transientFailure: return store.copilotError ?? "GitHub rate-limited; auto-retrying." - case .bootstrapping: return "Looking for a GitHub token on this Mac." - case .loading: return "Background refresh in progress." - case .dormant: return "Tap Load Quota to fetch live usage from api.github.com." + return L("Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect.") + case .transientFailure: return store.copilotError ?? L("GitHub rate-limited; auto-retrying.") + case .bootstrapping: return L("Looking for a GitHub token on this Mac.") + case .loading: return L("Background refresh in progress.") + case .dormant: return L("Tap Load Quota to fetch live usage from api.github.com.") case .notBootstrapped: return CopilotQuotaPresentation.settingsNotConnectedDetail( explicitlyDisconnected: CopilotExplicitDisconnect.isSet(defaults: store.copilotQuotaRuntime.defaults) @@ -1530,26 +1532,26 @@ private struct CopilotConnectionRow: View { private var actionButton: some View { switch store.copilotLoadState { case .loaded, .transientFailure, .loading: - Button("Disconnect") { showDisconnectConfirm = true } + Button(L("Disconnect")) { showDisconnectConfirm = true } .confirmationDialog( - "Disconnect Copilot?", + L("Disconnect Copilot?"), isPresented: $showDisconnectConfirm ) { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { store.disconnectCopilot() } - Button("Cancel", role: .cancel) {} + Button(L("Cancel"), role: .cancel) {} } message: { - Text("CodeBurn will stop tracking Copilot quota. Every credential it read stays untouched, and your Copilot clients keep working.") + Text(L("CodeBurn will stop tracking Copilot quota. Every credential it read stays untouched, and your Copilot clients keep working.")) } case .terminalFailure, .noCredentials, .failed: - Button("Reconnect") { Task { await store.connectCopilot() } } + Button(L("Reconnect")) { Task { await store.connectCopilot() } } .buttonStyle(.borderedProminent) case .dormant: - Button("Load Quota") { Task { await store.connectCopilot() } } + Button(L("Load Quota")) { Task { await store.connectCopilot() } } .buttonStyle(.borderedProminent) case .notBootstrapped: - Button("Connect") { Task { await store.connectCopilot() } } + Button(L("Connect")) { Task { await store.connectCopilot() } } .buttonStyle(.borderedProminent) case .bootstrapping: ProgressView().controlSize(.small) @@ -1562,15 +1564,15 @@ private struct CopilotConnectionRow: View { private struct AntigravitySettingsTab: View { var body: some View { Form { - Section("Connection") { + Section(L("Connection")) { AntigravityConnectionRow() } Section { - Text("Antigravity live-quota tracking talks to the Antigravity app's local language server on 127.0.0.1 only. Nothing leaves the machine and no credential files are read. If it shows as disconnected, start the Antigravity app, then click Reconnect.") + Text(L("Antigravity live-quota tracking talks to the Antigravity app's local language server on 127.0.0.1 only. Nothing leaves the machine and no credential files are read. If it shows as disconnected, start the Antigravity app, then click Reconnect.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -1624,14 +1626,14 @@ private struct AntigravityConnectionRow: View { private var stateTitle: String { switch store.antigravityLoadState { - case .loaded: return "Connected" - case let .terminalFailure(reason): return reason ?? "Reconnect required" - case .transientFailure: return "Backing off" - case .bootstrapping: return "Connecting…" - case .loading: return "Refreshing…" - case .dormant: return "Ready" - case .notBootstrapped, .noCredentials: return "Not connected" - case .failed: return "Couldn't load Antigravity quota" + case .loaded: return L("Connected") + case let .terminalFailure(reason): return reason ?? L("Reconnect required") + case .transientFailure: return L("Backing off") + case .bootstrapping: return L("Connecting…") + case .loading: return L("Refreshing…") + case .dormant: return L("Ready") + case .notBootstrapped, .noCredentials: return L("Not connected") + case .failed: return L("Couldn't load Antigravity quota") } } @@ -1639,19 +1641,19 @@ private struct AntigravityConnectionRow: View { switch store.antigravityLoadState { case .loaded: if let plan = store.antigravityUsage?.plan { - return "Plan: \(plan)" + return L("Plan: %@", plan) } - return "Live quota tracked from the local Antigravity server." + return L("Live quota tracked from the local Antigravity server.") case .terminalFailure: - return "Start the Antigravity app, then click Reconnect." - case .transientFailure: return store.antigravityError ?? "Local probe failed; auto-retrying." - case .bootstrapping: return "Probing the local Antigravity language server." - case .loading: return "Background refresh in progress." - case .dormant: return "Tap Load Quota to probe the local Antigravity server." + return L("Start the Antigravity app, then click Reconnect.") + case .transientFailure: return store.antigravityError ?? L("Local probe failed; auto-retrying.") + case .bootstrapping: return L("Probing the local Antigravity language server.") + case .loading: return L("Background refresh in progress.") + case .dormant: return L("Tap Load Quota to probe the local Antigravity server.") case .notBootstrapped: - return "Start the Antigravity app first, then click Connect." + return L("Start the Antigravity app first, then click Connect.") case .noCredentials: - return "No local Antigravity server found. Start the Antigravity app, then click Reconnect." + return L("No local Antigravity server found. Start the Antigravity app, then click Reconnect.") case .failed: return store.antigravityError ?? "" } } @@ -1660,26 +1662,26 @@ private struct AntigravityConnectionRow: View { private var actionButton: some View { switch store.antigravityLoadState { case .loaded, .transientFailure, .loading: - Button("Disconnect") { showDisconnectConfirm = true } + Button(L("Disconnect")) { showDisconnectConfirm = true } .confirmationDialog( - "Disconnect Antigravity?", + L("Disconnect Antigravity?"), isPresented: $showDisconnectConfirm ) { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { store.disconnectAntigravity() } - Button("Cancel", role: .cancel) {} + Button(L("Cancel"), role: .cancel) {} } message: { - Text("CodeBurn will stop tracking Antigravity quota. Nothing is read from or written to disk. The Antigravity app keeps working.") + Text(L("CodeBurn will stop tracking Antigravity quota. Nothing is read from or written to disk. The Antigravity app keeps working.")) } case .terminalFailure, .noCredentials, .failed: - Button("Reconnect") { Task { await store.bootstrapAntigravity() } } + Button(L("Reconnect")) { Task { await store.bootstrapAntigravity() } } .buttonStyle(.borderedProminent) case .dormant: - Button("Load Quota") { Task { await store.bootstrapAntigravity() } } + Button(L("Load Quota")) { Task { await store.bootstrapAntigravity() } } .buttonStyle(.borderedProminent) case .notBootstrapped: - Button("Connect") { Task { await store.bootstrapAntigravity() } } + Button(L("Connect")) { Task { await store.bootstrapAntigravity() } } .buttonStyle(.borderedProminent) case .bootstrapping: ProgressView().controlSize(.small) @@ -1805,13 +1807,13 @@ private struct GenericProviderConnectionSections: View { } Spacer() if !hasLiveAdapter { - Text("Not yet supported") + Text(L("Not yet supported")) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) } else if isLoading { ProgressView().controlSize(.small) } else if isConnected { - Button("Disconnect", role: .destructive) { + Button(L("Disconnect"), role: .destructive) { Task { do { try await store.disconnectCapacityDockProvider(provider) @@ -1831,15 +1833,15 @@ private struct GenericProviderConnectionSections: View { } .padding(.vertical, 4) } header: { - Text("Connection") + Text(L("Connection")) } footer: { Text(hasLiveAdapter - ? "Automatic connection uses the provider's existing app, CLI, OAuth, browser session, or environment credentials first. CodeBurn does not copy those source credentials into its Keychain." - : "Authentication methods are listed for reference. A native CodeBurn quota adapter is required before this provider can connect to Capacity Dock.") + ? L("Automatic connection uses the provider's existing app, CLI, OAuth, browser session, or environment credentials first. CodeBurn does not copy those source credentials into its Keychain.") + : L("Authentication methods are listed for reference. A native CodeBurn quota adapter is required before this provider can connect to Capacity Dock.")) .font(.system(size: 11)) } - Section("Authentication methods") { + Section(L("Authentication methods")) { ForEach(authMethods, id: \.self) { method in Label(method.title, systemImage: authIcon(method)) .font(.system(size: 11.5)) @@ -1848,7 +1850,7 @@ private struct GenericProviderConnectionSections: View { if hasLiveAdapter { Section { - Picker("Source", selection: $editor.credential.sourceMode) { + Picker(L("Source"), selection: $editor.credential.sourceMode) { ForEach(sourceModes, id: \.self) { source in Text(sourceTitle(source)).tag(source.rawValue) } @@ -1856,13 +1858,13 @@ private struct GenericProviderConnectionSections: View { .pickerStyle(.menu) if supportsAPIKey { - SecureField("API key or token", text: $editor.credential.apiKey) + SecureField(L("API key or token"), text: $editor.credential.apiKey) } HStack { - Button("Save & Connect") { saveAndConnect() } + Button(L("Save & Connect")) { saveAndConnect() } .buttonStyle(.borderedProminent) - Button("Clear Override") { + Button(L("Clear Override")) { Task { do { try await store.disconnectCapacityDockProvider(provider) @@ -1880,7 +1882,7 @@ private struct GenericProviderConnectionSections: View { if credentialIsLoading { ProgressView() .controlSize(.small) - .accessibilityLabel("Loading saved provider credential") + .accessibilityLabel(L("Loading saved provider credential")) } } @@ -1890,15 +1892,15 @@ private struct GenericProviderConnectionSections: View { .foregroundStyle(.red) } } header: { - Text("Connection override") + Text(L("Connection override")) } footer: { - Text("Overrides are optional and are saved only when you press Save & Connect. Secret values use one CodeBurn-owned Keychain item for this provider; background reads suppress authentication UI.") + Text(L("Overrides are optional and are saved only when you press Save & Connect. Secret values use one CodeBurn-owned Keychain item for this provider; background reads suppress authentication UI.")) .font(.system(size: 11)) } .disabled(credentialIsLoading) } else if CapacityDockProviderCredentialPresence.contains(provider.id) { Section { - Button("Remove saved override", role: .destructive) { + Button(L("Remove saved override"), role: .destructive) { Task { do { try await store.disconnectCapacityDockProvider(provider) @@ -1917,9 +1919,9 @@ private struct GenericProviderConnectionSections: View { .foregroundStyle(.red) } } header: { - Text("Saved data") + Text(L("Saved data")) } footer: { - Text("This credential predates a live CodeBurn quota adapter and is not treated as a connection.") + Text(L("This credential predates a live CodeBurn quota adapter and is not treated as a connection.")) .font(.system(size: 11)) } } @@ -1930,22 +1932,22 @@ private struct GenericProviderConnectionSections: View { } private var connectionTitle: String { - guard hasLiveAdapter else { return "Quota adapter not available" } - if isLoading { return "Connecting…" } - guard let summary else { return "Not connected" } + guard hasLiveAdapter else { return L("Quota adapter not available") } + if isLoading { return L("Connecting…") } + guard let summary else { return L("Not connected") } switch summary.connection { - case .connected: return "Connected" - case .loading: return "Connecting…" - case .stale: return "Refreshing…" - case .transientFailure: return "Retrying" - case .terminalFailure: return "Reconnect required" - case .disconnected: return "Not connected" + case .connected: return L("Connected") + case .loading: return L("Connecting…") + case .stale: return L("Refreshing…") + case .transientFailure: return L("Retrying") + case .terminalFailure: return L("Reconnect required") + case .disconnected: return L("Not connected") } } private var connectionDetail: String { guard hasLiveAdapter else { - return "\(provider.displayName) is catalogued, but CodeBurn cannot fetch its live quota yet." + return L("%@ is catalogued, but CodeBurn cannot fetch its live quota yet.", provider.displayName) } if let error = store.capacityDockProviderErrors[provider.id], !error.isEmpty { return "\(error) \(ProviderConnectionGuidance.instruction(for: provider))" @@ -1961,7 +1963,7 @@ private struct GenericProviderConnectionSections: View { if let source = summary.footerLines.first(where: { $0.hasPrefix("Source:") }) { return source } - return isConnected ? "Live quota is available to Capacity Dock." : "Waiting for quota data." + return isConnected ? L("Live quota is available to Capacity Dock.") : L("Waiting for quota data.") } private var connectionIcon: String { @@ -1993,8 +1995,8 @@ private struct GenericProviderConnectionSections: View { private var primaryConnectionButtonTitle: String { switch submissionAction { - case .saveAndConnect: return "Save & Connect" - case .connect, .requiresCredential: return summary == nil ? "Connect" : "Retry" + case .saveAndConnect: return L("Save & Connect") + case .connect, .requiresCredential: return summary == nil ? L("Connect") : L("Retry") } } @@ -2030,11 +2032,11 @@ private struct GenericProviderConnectionSections: View { private func sourceTitle(_ source: ProviderReferenceSourceMode) -> String { switch source { - case .automatic: "Automatic" - case .web: "Browser session" - case .cli: "CLI" - case .oauth: "OAuth" - case .api: "API" + case .automatic: L("Automatic") + case .web: L("Browser session") + case .cli: L("CLI") + case .oauth: L("OAuth") + case .api: L("API") } } @@ -2088,21 +2090,21 @@ private struct DevinSettingsTab: View { Form { GenericProviderConnectionSections(provider: CapacityDockProvider(rawValue: "devin")!) - Section("ACU Conversion") { + Section(L("ACU Conversion")) { HStack(alignment: .center, spacing: 10) { - Text("USD per ACU") + Text(L("USD per ACU")) Spacer() TextField("", text: $rateText) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) .frame(width: 96) - .accessibilityLabel("USD per ACU") + .accessibilityLabel(L("USD per ACU")) Text("USD") .foregroundStyle(.secondary) .frame(width: 36, alignment: .leading) } - Button("Save") { + Button(L("Save")) { saveRate() } .buttonStyle(.borderedProminent) @@ -2116,11 +2118,11 @@ private struct DevinSettingsTab: View { } Section { - Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.") + Text(L("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -2136,7 +2138,7 @@ private struct DevinSettingsTab: View { guard let rate = parsedRate else { return } CLIDevinConfig.persistAcuUsdRate(rate) rateText = Self.format(rate) - statusText = "Saved. Refresh CodeBurn to recalculate Devin cost." + statusText = L("Saved. Refresh CodeBurn to recalculate Devin cost.") } private static func format(_ value: Double) -> String { @@ -2169,8 +2171,8 @@ private struct AboutSettingsTab: View { } Section { - LabeledContent("Version \(versionString)") { - Button("Check for Updates") { + LabeledContent(L("Version %@", versionString)) { + Button(L("Check for Updates")) { Task { await updateChecker.check() } } } @@ -2179,31 +2181,31 @@ private struct AboutSettingsTab: View { .font(.footnote) .foregroundStyle(.secondary) } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { - Text("\(AppVersion.display(latest)) is available. Choose Check for Updates in the CodeBurn menu to install it.") + Text(L("%@ is available. Choose Check for Updates in the CodeBurn menu to install it.", AppVersion.display(latest))) .font(.footnote) .foregroundStyle(.secondary) } } header: { - Text("Updates") + Text(L("Updates")) } Section { AboutLinkRow( icon: "chevron.left.slash.chevron.right", - title: "GitHub", + title: L("GitHub"), url: "https://github.com/getagentseal/codeburn") AboutLinkRow( icon: "globe", - title: "Website", + title: L("Website"), url: "https://codeburn.app") AboutLinkRow( icon: "exclamationmark.bubble", - title: "Issues", + title: L("Issues"), url: "https://github.com/getagentseal/codeburn/issues") } header: { - Text("Links") + Text(L("Links")) } footer: { - Text("© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License.") + Text(L("© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License.")) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) } @@ -2229,9 +2231,9 @@ private struct AboutSettingsTab: View { VStack(spacing: 2) { Text("CodeBurn") .font(.title3).fontWeight(.semibold) - Text("Version \(versionString)") + Text(L("Version %@", versionString)) .foregroundStyle(.secondary) - Text("Your AI Bill, Itemized") + Text(L("Your AI Bill, Itemized")) .font(.footnote) .foregroundStyle(.secondary) } diff --git a/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift b/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift index 1bcf9de76..81031bdba 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift @@ -25,10 +25,10 @@ struct ToolingSection: View { let combined = skillsAndAgents let hasAny = !current.tools.isEmpty || !combined.isEmpty || !current.mcpServers.isEmpty if hasAny { - CollapsibleSection(caption: "Tooling", isExpanded: $isExpanded) { + CollapsibleSection(caption: L("Tooling"), isExpanded: $isExpanded) { VStack(alignment: .leading, spacing: 12) { if !current.tools.isEmpty { - ToolingSubsection(title: "Tools") { + ToolingSubsection(title: L("Tools")) { let maxCalls = current.tools.map(\.calls).max() ?? 1 ForEach(current.tools, id: \.name) { t in CallsRow(name: t.name, calls: t.calls, maxCalls: maxCalls) @@ -36,7 +36,7 @@ struct ToolingSection: View { } } if !combined.isEmpty { - ToolingSubsection(title: "Skills & Agents") { + ToolingSubsection(title: L("Skills & Agents")) { let maxCost = max(combined.map(\.cost).max() ?? 0.01, 0.01) ForEach(combined, id: \.name) { d in CostRow(name: d.name, cost: d.cost, count: d.uses, countLabel: "uses", maxCost: maxCost) @@ -44,7 +44,7 @@ struct ToolingSection: View { } } if !current.mcpServers.isEmpty { - ToolingSubsection(title: "MCP Servers") { + ToolingSubsection(title: L("MCP Servers")) { let maxCalls = current.mcpServers.map(\.calls).max() ?? 1 ForEach(current.mcpServers, id: \.name) { m in CallsRow(name: m.name, calls: m.calls, maxCalls: maxCalls) From bee610a119e94e053da6d9d720d02d49bff351e5 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:33:41 +0300 Subject: [PATCH 2/7] feat(menubar): add Simplified Chinese (zh-Hans) localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 533 catalog keys translated into zh-Hans, so the menubar app now follows the system language with no setting to find and no third-party library. AppKit picks the table: `CFBundleLocalizations` in both packaging scripts advertises `en` and `zh-Hans`, which is also what puts the app under System Settings > Language & Region so a user can override the language for CodeBurn alone. Wording follows macOS system apps rather than a literal gloss: 立即刷新, 断开连接, 存储并连接, 载入配额, 再试一次. Product nouns stay as users already know them: provider, model and plan names (Claude, Codex, Gemini, Opus, Sonnet, Antigravity), Token, Dock, API, OAuth, shell commands and file paths, currency codes. Format specifiers are identical to the English entry in count and argument order. `%%` is a literal percent sign and not an argument, so it may move where Chinese word order demands it ("%@ · %@ 达到 100%%"); the argument-consuming specifiers may not, because String(format:) binds them positionally. The next commit's test enforces exactly that distinction. Numbers, dates and currency are untouched: they already went through NumberFormatter / DateFormatter / asCurrency(), and `L(_:_:)` only substitutes values those produced. --- mac/Package.swift | 3 +- mac/Scripts/build-local.sh | 2 + mac/Scripts/package-app.sh | 9 + .../CodeBurnMenubar/Localization.swift | 8 +- .../zh-Hans.lproj/Localizable.strings | 597 ++++++++++++++++++ 5 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings diff --git a/mac/Package.swift b/mac/Package.swift index b74eb3936..60ca26443 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -30,7 +30,8 @@ let package = Package( // `L(_:)` / `Bundle.module`, never `Bundle.main`: the strings // live in the SwiftPM resource bundle inside Contents/Resources, // not at the app bundle's resource root. - .process("Resources/en.lproj") + .process("Resources/en.lproj"), + .process("Resources/zh-Hans.lproj") ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency") diff --git a/mac/Scripts/build-local.sh b/mac/Scripts/build-local.sh index 96c928400..b6eab6e5c 100755 --- a/mac/Scripts/build-local.sh +++ b/mac/Scripts/build-local.sh @@ -136,6 +136,8 @@ cat > "${BUNDLE}/Contents/Info.plist" < CFBundleDevelopmentRegionen + + CFBundleLocalizationsenzh-Hans CFBundleDisplayNameCodeBurn Menubar CFBundleExecutable${EXE} CFBundleIconFileAppIcon diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 4cbcf7d46..6809732c5 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -80,6 +80,15 @@ cat > "${BUNDLE}/Contents/Info.plist" < CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + zh-Hans + CFBundleDisplayName CodeBurn Menubar CFBundleExecutable diff --git a/mac/Sources/CodeBurnMenubar/Localization.swift b/mac/Sources/CodeBurnMenubar/Localization.swift index 70798708d..f618959a8 100644 --- a/mac/Sources/CodeBurnMenubar/Localization.swift +++ b/mac/Sources/CodeBurnMenubar/Localization.swift @@ -21,7 +21,7 @@ import Foundation /// English stays the development language: a key with no translation renders /// as correct English rather than a visible identifier, and `en.lproj` is an /// identity table kept only so the bundle advertises `en` as a localization -/// and so the catalog can be diffed against a translation. +/// and so `LocalizationCatalogTests` can diff the two tables. /// /// # What is not translated /// @@ -32,15 +32,15 @@ import Foundation /// and currency keep going through the locale-aware formatters they already /// used — `L(_:_:)` only substitutes already-formatted values. enum L10n { - /// The bundle that carries the `.lproj` tables. + /// The bundle that carries `en.lproj` / `zh-Hans.lproj`. static let bundle: Bundle = .module /// Table name, i.e. `Localizable.strings`. static let table = "Localizable" /// Locales shipped today. Mirrored by `CFBundleLocalizations` in the two - /// packaging scripts once a second language exists. - static let supportedLocalizations = ["en"] + /// packaging scripts and asserted by `LocalizationCatalogTests`. + static let supportedLocalizations = ["en", "zh-Hans"] } /// Localized copy for `key`, falling back to the key (its English text) when a diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..dccb4c9a0 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,597 @@ +/* CodeBurn Menubar — 简体中文 (zh-Hans). + * + * Keys are the English development copy; see en.lproj/Localizable.strings for + * the conventions. Format specifiers (%@, %lld, %%) must stay identical to the + * English entry, in the same order — LocalizationCatalogTests fails the build + * otherwise, because a mismatch is a crash or a wrong number at runtime. + * + * Untranslated on purpose: provider, model and plan names (Claude, Codex, + * Gemini, Opus, Sonnet, Antigravity…), units, currency codes, shell commands + * and file paths, and anything the `codeburn` CLI produces. + */ + + +/* MARK: Currencies, filters, periods and insight tabs */ +"US Dollar" = "美元"; +"British Pound" = "英镑"; +"Euro" = "欧元"; +"Australian Dollar" = "澳元"; +"Canadian Dollar" = "加元"; +"New Zealand Dollar" = "新西兰元"; +"Japanese Yen" = "日元"; +"Chinese Yuan" = "人民币"; +"Swiss Franc" = "瑞士法郎"; +"Indian Rupee" = "印度卢比"; +"Brazilian Real" = "巴西雷亚尔"; +"Swedish Krona" = "瑞典克朗"; +"Singapore Dollar" = "新加坡元"; +"Hong Kong Dollar" = "港元"; +"South Korean Won" = "韩元"; +"Mexican Peso" = "墨西哥比索"; +"South African Rand" = "南非兰特"; +"Danish Krone" = "丹麦克朗"; +"Romanian Leu" = "罗马尼亚列伊"; +"All" = "全部"; +"Plan" = "套餐"; +"Trend" = "趋势"; +"Forecast" = "预测"; +"Calendar" = "日历"; +"Pulse" = "效率"; +"Stats" = "统计"; +"Optimize" = "优化"; +"Today" = "今天"; +"7D" = "7天"; +"30D" = "30天"; +"Month" = "本月"; +"6M" = "6个月"; +"Life" = "累计"; +"Week" = "本周"; +"30 Days" = "30 天"; +"6 Months" = "6 个月"; +"Lifetime" = "累计"; + +/* MARK: Scope toggle */ +"Local" = "本机"; +"Combined" = "合并"; + +/* MARK: Accent presets */ +"Ember" = "余烬"; +"Blue" = "蓝色"; +"Purple" = "紫色"; +"Pink" = "粉色"; +"Red" = "红色"; +"Orange" = "橙色"; +"Yellow" = "黄色"; +"Green" = "绿色"; +"Graphite" = "石墨色"; + +/* MARK: Session counts */ +"Older session logs may be unavailable." = "较早的会话日志可能不可用。"; +"Session identities are unavailable across devices." = "跨设备无法识别会话身份。"; +"Session count unavailable" = "会话数不可用"; +"At least 1 session" = "至少 1 个会话"; +"At least %lld sessions" = "至少 %lld 个会话"; +"1 session" = "1 个会话"; +"%lld sessions" = "%lld 个会话"; +"Unavailable" = "不可用"; +"≥%lld sess" = "≥%lld 会话"; +"%lld sess" = "%lld 会话"; + +/* MARK: Quota windows and reset countdowns */ +"Connect" = "连接"; +"Reconnect" = "重新连接"; +"Add API Key" = "添加 API 密钥"; +"now" = "现在"; +"%lldd %lldh" = "%lld 天 %lld 小时"; +"%lldh %lldm" = "%lld 小时 %lld 分"; +"%lldm" = "%lld 分"; + +/* MARK: Usage refresh cadence */ +"Auto (2m, less on battery)" = "自动(2 分钟,使用电池时更慢)"; +"Manual" = "手动"; +"1 minute" = "1 分钟"; +"5 minutes" = "5 分钟"; +"15 minutes" = "15 分钟"; + +/* MARK: Quota refresh cadence */ +"2 minutes" = "2 分钟"; + +/* MARK: Capacity Dock appearance */ +"Liquid Glass" = "液态玻璃"; +"Circle" = "圆形"; +"Squircle" = "圆角方形"; + +/* MARK: Provider connection guidance */ +"Installed app or CLI" = "已安装的应用或命令行工具"; +"OAuth" = "OAuth"; +"API or cloud credentials" = "API 或云凭证"; +"Browser session" = "浏览器会话"; +"Localhost service" = "本地服务"; +"No sign-in required" = "无需登录"; +"Enter an API key or token below, then press Save & Connect." = "在下方输入 API 密钥或令牌,然后点按“存储并连接”。"; +"Sign in to %@ in a supported browser, then click Retry." = "在受支持的浏览器中登录 %@,然后点按“重试”。"; +"Sign in with the %@ app or CLI, then click Retry." = "通过 %@ 应用或命令行工具登录,然后点按“重试”。"; +"Complete %@ OAuth, then click Retry." = "完成 %@ 的 OAuth 授权,然后点按“重试”。"; +"Start the local %@ service, then click Retry." = "启动本地 %@ 服务,然后点按“重试”。"; +"Enter the required API or cloud credentials below, then press Save & Connect." = "在下方填入所需的 API 或云凭证,然后点按“存储并连接”。"; +"No sign-in is required. Click Retry to refresh quota." = "无需登录。点按“重试”即可刷新配额。"; +"Add an API key or token in Provider Settings." = "请在服务商设置中添加 API 密钥或令牌。"; +"Add the required API or cloud credentials in Provider Settings." = "请在服务商设置中填入所需的 API 或云凭证。"; + +/* MARK: Provider reconnect copy */ +"Reconnect %@" = "重新连接 %@"; +"Claude Code credentials need to be refreshed." = "Claude Code 凭证需要刷新。"; +"Open Claude Code in your terminal and type `/login`, then click Reconnect." = "在终端中打开 Claude Code 并输入 `/login`,然后点按“重新连接”。"; +"Codex credentials need to be refreshed." = "Codex 凭证需要刷新。"; +"Run `codex login` in your terminal, then click Reconnect." = "在终端中运行 `codex login`,然后点按“重新连接”。"; +"Kimi Code credentials need to be refreshed." = "Kimi Code 凭证需要刷新。"; +"Run the Kimi CLI once to refresh your login, then click Reconnect." = "运行一次 Kimi 命令行工具以刷新登录,然后点按“重新连接”。"; +"Gemini credentials need to be refreshed." = "Gemini 凭证需要刷新。"; +"Run the Gemini CLI once to refresh your login, then click Reconnect." = "运行一次 Gemini 命令行工具以刷新登录,然后点按“重新连接”。"; +"Copilot credentials need to be refreshed." = "Copilot 凭证需要刷新。"; +"Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect." = "通过 Copilot 命令行工具、编辑器插件或 `gh auth login` 登录,然后点按“重新连接”。"; +"The local Antigravity service is unavailable." = "本地 Antigravity 服务不可用。"; +"Start the Antigravity app, then click Reconnect." = "启动 Antigravity 应用,然后点按“重新连接”。"; +"%@ credentials need to be refreshed." = "%@ 凭证需要刷新。"; +"Sign in to %@ again, then retry." = "请重新登录 %@,然后重试。"; + +/* MARK: Copilot quota copy */ +"No Copilot credentials found" = "未找到 Copilot 凭证"; +"Sign in via an editor's Copilot plugin first. Then click Try Again." = "请先通过编辑器的 Copilot 插件登录,然后点按“再试一次”。"; +"Copilot quota tracking disconnected" = "已断开 Copilot 配额跟踪"; +"Your Copilot credentials are untouched. Click Connect to resume." = "你的 Copilot 凭证不会受到影响。点按“连接”即可恢复。"; +"Usage tracking still works. For live quota, sign in with the Copilot CLI or gh auth login, or paste a token below, then click Connect." = "用量跟踪仍然有效。如需实时配额,请通过 Copilot 命令行工具或 gh auth login 登录,或在下方粘贴令牌,然后点按“连接”。"; +"Quota tracking disconnected. Credentials are untouched. Click Connect to resume." = "已断开配额跟踪。凭证不会受到影响。点按“连接”即可恢复。"; + +/* MARK: Updates */ +"Update Check Failed" = "检查更新失败"; +"CLI Update Failed" = "命令行工具更新失败"; +"Menubar Update Failed" = "菜单栏应用更新失败"; +"CodeBurn could not check GitHub for updates." = "CodeBurn 无法从 GitHub 检查更新。"; +"CodeBurn could not update the CLI." = "CodeBurn 无法更新命令行工具。"; +"CodeBurn could not update the menubar app." = "CodeBurn 无法更新菜单栏应用。"; +"Click to retry the update check." = "点按以重新检查更新。"; +"Click to retry the update." = "点按以重新尝试更新。"; +"Updating..." = "正在更新…"; +"Update" = "更新"; +"Update the CLI and menubar to the latest release" = "将命令行工具和菜单栏应用更新到最新版本"; +"CodeBurn %@ available" = "CodeBurn %@ 可用"; +"App and CLI %@ updates are ready. Click to install." = "应用与命令行工具 %@ 更新已就绪。点按以安装。"; +"Click to install the update." = "点按以安装更新。"; +"CodeBurn CLI %@ available" = "CodeBurn 命令行工具 %@ 可用"; +"Could not find the package manager for %@. Run “%@” manually, then try again." = "找不到 %@ 对应的包管理器。请手动运行“%@”,然后重试。"; +"the CLI" = "命令行工具"; +"CLI update failed (exit %lld)" = "命令行工具更新失败(退出码 %lld)"; +"Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again." = "你的 codeburn 命令行工具(%@)版本过旧,无法更新菜单栏应用。请先运行“%@”,然后重试。"; +"Update failed (exit %lld)" = "更新失败(退出码 %lld)"; +"GitHub returned HTTP %lld." = "GitHub 返回 HTTP %lld。"; +"No mac-v release with a menubar zip and checksum was found." = "未找到包含菜单栏 zip 与校验和的 mac-v 版本。"; + +/* MARK: Terminal picker */ +"Terminal (macOS default)" = "终端(macOS 默认)"; + +/* MARK: Status-item menu, tooltip and update alerts */ +"%@ credits" = "%@ 点数"; +"CodeBurn %@ · %lld of %lld devices reporting" = "CodeBurn %@ · %lld/%lld 台设备已上报"; +"Settings…" = "设置…"; +"Capacity Dock Settings…" = "容量 Dock 设置…"; +"Refresh Now" = "立即刷新"; +"Check for Updates" = "检查更新"; +"About CodeBurn" = "关于 CodeBurn"; +"Quit CodeBurn" = "退出 CodeBurn"; +"Today · no usage yet" = "今天 · 暂无用量"; +"1 call" = "1 次调用"; +"%lld calls" = "%lld 次调用"; +"Today · %@ · %@" = "今天 · %@ · %@"; +"CodeBurn Settings" = "CodeBurn 设置"; +"Update Available" = "有可用更新"; +"%@ is available (you have %@)." = "%@ 已发布(你当前为 %@)。"; +"%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force" = "%@ 你的 codeburn 命令行工具版本过旧,无法安装。请先运行:\n\n%@\n\n然后:\n\ncodeburn menubar --force"; +"%@ Run:\n\ncodeburn menubar --force" = "%@ 运行:\n\ncodeburn menubar --force"; +"Up to Date" = "已是最新版本"; +"You're on the latest version (%@)." = "你已使用最新版本(%@)。"; +"OK" = "好"; + +/* MARK: Popover chrome, header, footer and banners */ +"This total may be incomplete." = "此总计可能不完整。"; +"Claude config" = "Claude 配置"; +"No %@ data for %@" = "%@ 在%@没有数据"; +"Couldn't load %@" = "无法载入%@"; +"Retry" = "重试"; +"Loading %@…" = "正在载入%@…"; +"Your AI Bill, Itemized" = "你的 AI 账单,逐项明细"; +"%@ over limit (%lld%%)" = "%@ 已超限(%lld%%)"; +"%@ of quota used" = "已用配额 %@"; +"Change accent color" = "更改主题色"; +"CLI %@ available" = "命令行工具 %@ 可用"; +"Update now" = "立即更新"; +"Update the CLI (and the menubar if one is available) automatically" = "自动更新命令行工具(以及可用的菜单栏应用)"; +"Copy update command to clipboard" = "将更新命令复制到剪贴板"; +"Enjoying CodeBurn?" = "喜欢 CodeBurn 吗?"; +"Star us on GitHub" = "在 GitHub 上点个 Star"; +"Hide this banner" = "隐藏此横幅"; +"CSV (folder)" = "CSV(文件夹)"; +"JSON" = "JSON"; +"Export" = "导出"; +"Full Report" = "完整报告"; + +/* MARK: Hero section */ +"%@ call" = "%@ 次调用"; +"%@ calls" = "%@ 次调用"; +"Daily budget of %@ exceeded" = "已超出每日预算 %@"; +"Combined unavailable · showing local" = "合并数据不可用 · 显示本机数据"; +"Combined · %@" = "合并 · %@"; +"Saved %@ with local models" = "使用本地模型节省了 %@"; +"%lld of %lld devices" = "%lld/%lld 台设备"; +"%@ · local" = "%@ · 本机"; + +/* MARK: Period strip and date picker */ +"Clear" = "清除"; +"Done" = "完成"; +"Pick dates" = "选择日期"; +"1 day" = "1 天"; +"%lld days" = "%lld 天"; + +/* MARK: Provider tab strip and quota popover */ +"Show previous providers" = "显示上一组服务商"; +"Show next providers" = "显示下一组服务商"; +"Loading…" = "正在载入…"; +"Sign in with `codex` (ChatGPT mode) to track quota." = "使用 `codex`(ChatGPT 模式)登录以跟踪配额。"; +"Sign in to Claude Code to track quota." = "登录 Claude Code 以跟踪配额。"; +"Sign in to track quota." = "登录以跟踪配额。"; +"%@ usage" = "%@ 用量"; +"stale" = "已过期"; +"retrying" = "正在重试"; + +/* MARK: Activity section */ +"Activity" = "活动"; +"Cost" = "花费"; +"Turns" = "轮次"; +"1-shot" = "一次通过"; + +/* MARK: Models section */ +"Models" = "模型"; +"Saved" = "已节省"; +"Calls" = "调用"; +"Tokens" = "Token"; +"%@ in" = "输入 %@"; +"%@ out" = "输出 %@"; +"%@%% cache hit" = "缓存命中 %@%%"; + +/* MARK: Pull requests section */ +"Pull requests" = "拉取请求"; + +/* MARK: Tooling section */ +"Tooling" = "工具链"; +"Tools" = "工具"; +"Skills & Agents" = "技能与子代理"; +"MCP Servers" = "MCP 服务器"; + +/* MARK: Tips section */ +"Tips for you" = "为你的建议"; +"%lld signals" = "%lld 条信号"; +"Open Full Optimize" = "打开完整优化报告"; +"Cache hit at %lld%% — most prompts reuse cache" = "缓存命中率 %lld%% — 多数提示复用了缓存"; +"%lld%% one-shot — edits landing first try" = "一次通过率 %lld%% — 编辑大多一次成功"; +"Spend down %lld%% vs last 7 days" = "支出较前 7 天下降 %lld%%"; +"%lld-day usage streak" = "已连续使用 %lld 天"; +"Spend up %lld%% vs prior 7 days" = "支出较前 7 天上升 %lld%%"; +"Cache hit only %lld%% — paying for cold prompts" = "缓存命中率仅 %lld%% — 正在为冷提示付费"; +"%lld%% one-shot — lots of iteration" = "一次通过率 %lld%% — 反复修改较多"; +"On pace for %@ this month (+%lld%% vs last)" = "按当前速度本月将达 %@(较上月 +%lld%%)"; +"What's working" = "做得好的"; +"What to improve" = "可以改进的"; +"Risks" = "风险"; + +/* MARK: Insight tabs (trend, calendar, forecast, pulse, stats, optimize, plan) */ +"Last %lld days" = "最近 %lld 天"; +"%@%% vs prior %lldd" = "%@%% 较前 %lld 天"; +"Avg/day" = "日均"; +"Peak" = "峰值"; +"Yesterday" = "昨天"; +"%@ tokens" = "%@ Token"; +"%@ on %@" = "%@(%@)"; +"Daily activity" = "每日活动"; +"%lld active days" = "%lld 个活跃日"; +"Peak day" = "峰值日"; +"Avg active" = "活跃日均值"; +"Streak" = "连续天数"; +"%lldd" = "%lld 天"; +"Mon" = "一"; +"Wed" = "三"; +"Fri" = "五"; +"Sun" = "日"; +"Daily detail" = "每日明细"; +"Hover a day" = "悬停查看某天"; +"Future day" = "未来日期"; +"No tracked usage" = "无跟踪到的用量"; +"%@: future day" = "%@:未来日期"; +"%@: no tracked usage" = "%@:无跟踪到的用量"; +"%@: %@, %lld calls, %@ tokens" = "%@:%@,%lld 次调用,%@ Token"; +"Month-to-date" = "本月至今"; +"On pace for" = "预计本月"; +"Avg/day (this wk)" = "日均(本周)"; +"Last 7d" = "最近 7 天"; +"no prior month" = "无上月数据"; +"%@%% vs last month (%@)" = "%@%% 较上月(%@)"; +"Cache hit" = "缓存命中"; +"Cost / session" = "每会话花费"; +"Cost/edit" = "每次编辑花费"; +"Save ~%@ / ~%@ tokens · 1 finding" = "可省约 %@ / 约 %@ Token · 1 项建议"; +"Save ~%@ / ~%@ tokens · %lld findings" = "可省约 %@ / 约 %@ Token · %lld 项建议"; +"Favorite model" = "最常用模型"; +"Active days (month)" = "活跃天数(本月)"; +"Most active day" = "最活跃的一天"; +"Peak day spend" = "峰值日支出"; +"Sessions" = "会话"; +"Current streak" = "当前连续"; +"Longest streak" = "最长连续"; +"Tracked spend (last %lld days)" = "已跟踪支出(最近 %lld 天)"; +"Costliest session" = "花费最高的会话"; +"Retry tax" = "重试成本"; +"%lld retries across %lld edits" = "%lld 次重试,涉及 %lld 次编辑"; +"%@ ret/edit" = "%@ 次重试/编辑"; +"Hides session details" = "隐藏会话详情"; +"Shows session details" = "显示会话详情"; +"Expanded" = "已展开"; +"Collapsed" = "已折叠"; +" %lld call" = " %lld 次调用"; +" %lld calls" = " %lld 次调用"; +"Potential savings" = "潜在节省"; +"%lld%% of spend" = "占支出 %lld%%"; +"could be optimized" = "可以优化"; +"Routing waste" = "路由浪费"; +"vs %@ @ %@/edit" = "对比 %@(%@/次编辑)"; +"Connect Claude subscription" = "连接 Claude 订阅"; +"CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." = "CodeBurn 只会读取一次你的 Claude Code 凭证,macOS 会请求授权。之后实时配额条会显示在 Claude 标签旁并自动更新。"; +"Reading Claude credentials..." = "正在读取 Claude 凭证…"; +"No Claude credentials found" = "未找到 Claude 凭证"; +"Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again." = "请先登录 Claude Code:在终端中打开 `claude` 并输入 `/login`,然后点按“再试一次”。"; +"Anthropic temporarily unreachable. Retrying." = "暂时无法连接 Anthropic,正在重试。"; +"Reconnect Claude" = "重新连接 Claude"; +"Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect." = "你的 Claude 会话已过期。在终端中打开 Claude Code 并输入 `/login`,然后点按“重新连接”。"; +"Resets %@" = "%@重置"; +"5-hour window" = "5 小时窗口"; +"7-day total" = "7 天总计"; +"7-day Opus" = "7 天 Opus"; +"7-day Sonnet" = "7 天 Sonnet"; +"7-day %@" = "7 天 %@"; +"Try Again" = "再试一次"; +"Couldn't load plan data" = "无法载入套餐数据"; +"Connect ChatGPT subscription" = "连接 ChatGPT 订阅"; +"CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically." = "CodeBurn 只会读取一次你的 Codex 命令行工具凭证。之后实时配额条会显示在 Codex 标签旁并自动更新。"; +"Reading Codex CLI credentials..." = "正在读取 Codex 命令行工具凭证…"; +"No Codex credentials found" = "未找到 Codex 凭证"; +"Sign in with Codex first: run `codex login` in your terminal. Then click Try Again." = "请先登录 Codex:在终端中运行 `codex login`,然后点按“再试一次”。"; +"ChatGPT temporarily unreachable. Retrying." = "暂时无法连接 ChatGPT,正在重试。"; +"Reconnect Codex" = "重新连接 Codex"; +"Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect." = "你的 ChatGPT 会话已过期。在终端中运行 `codex login`,然后点按“重新连接”。"; +"%@ window" = "%@ 窗口"; +"Credits" = "点数"; +"Unlimited" = "无限制"; +"Limit resets" = "限额重置"; +"%lld available" = "%lld 个可用"; +"%@ · next expires %@" = "%@ · 下一个将于%@过期"; +"No Kimi Code credentials found" = "未找到 Kimi Code 凭证"; +"Sign in with the Kimi CLI first. Then click Try Again." = "请先通过 Kimi 命令行工具登录,然后点按“再试一次”。"; +"Reading Kimi Code credentials..." = "正在读取 Kimi Code 凭证…"; +"Kimi temporarily unreachable. Retrying." = "暂时无法连接 Kimi,正在重试。"; +"Refresh Kimi Code login" = "刷新 Kimi Code 登录"; +"Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect." = "Kimi Code 的令牌有效期很短。运行一次 Kimi 命令行工具以刷新登录,然后点按“重新连接”。"; +"Parallel sessions" = "并行会话"; +"Login idle. Run the Kimi CLI to refresh." = "登录已闲置。运行 Kimi 命令行工具以刷新。"; +"as of %@" = "截至 %@"; +"No Gemini credentials found" = "未找到 Gemini 凭证"; +"Sign in with the Gemini CLI first. Then click Try Again." = "请先通过 Gemini 命令行工具登录,然后点按“再试一次”。"; +"Reading Gemini credentials..." = "正在读取 Gemini 凭证…"; +"Gemini temporarily unreachable. Retrying." = "暂时无法连接 Gemini,正在重试。"; +"Refresh Gemini login" = "刷新 Gemini 登录"; +"Your Gemini login has expired. Run the Gemini CLI once to refresh it, then click Reconnect." = "你的 Gemini 登录已过期。运行一次 Gemini 命令行工具以刷新,然后点按“重新连接”。"; +"Login idle. Run the Gemini CLI to refresh." = "登录已闲置。运行 Gemini 命令行工具以刷新。"; +"Reading Copilot credentials..." = "正在读取 Copilot 凭证…"; +"GitHub temporarily unreachable. Retrying." = "暂时无法连接 GitHub,正在重试。"; +"Refresh Copilot login" = "刷新 Copilot 登录"; +"Your Copilot sign-in has expired. Sign in via an editor's Copilot plugin again, then click Reconnect." = "你的 Copilot 登录已过期。请重新通过编辑器的 Copilot 插件登录,然后点按“重新连接”。"; +"Login idle. Sign in via an editor's Copilot plugin to refresh." = "登录已闲置。通过编辑器的 Copilot 插件登录以刷新。"; +"No local Antigravity server found" = "未找到本地 Antigravity 服务"; +"Start the Antigravity app, then click Try Again." = "启动 Antigravity 应用,然后点按“再试一次”。"; +"Probing the local Antigravity server..." = "正在探测本地 Antigravity 服务…"; +"Local Antigravity server unreachable. Retrying." = "无法连接本地 Antigravity 服务,正在重试。"; +"Reconnect Antigravity" = "重新连接 Antigravity"; +"Server disconnected. Start the Antigravity app to refresh." = "服务已断开。启动 Antigravity 应用以刷新。"; +"On pace" = "符合节奏"; +"On pace: %@ at reset" = "符合节奏:重置时 %@"; +"%@%% in deficit" = "超出 %@%%"; +"%@%% in reserve" = "结余 %@%%"; +"%@ · hits 100%% %@" = "%@ · %@达到 100%%"; +"%@ · %@ at reset" = "%@ · 重置时 %@"; +"On pace: %@ at reset · hits 100%% %@" = "符合节奏:重置时 %@ · %@达到 100%%"; +"Based on last cycle: %@" = "按上一周期估算:%@"; +"in %lldm" = "%lld 分钟后"; +"in %lldh" = "%lld 小时后"; +"in %lldd" = "%lld 天后"; + +/* MARK: Capacity Dock */ +"Dock to Edge" = "停靠到边缘"; +"Left" = "左侧"; +"Right" = "右侧"; +"Top" = "顶部"; +"Bottom" = "底部"; +"Hide Capacity Dock" = "隐藏容量 Dock"; +"Capacity Dock" = "容量 Dock"; +"Unknown" = "未知"; +"Click to keep Capacity Dock expanded" = "点按以保持容量 Dock 展开"; +"none running" = "无运行中"; +"1 running" = "1 个运行中"; +"%lld running" = "%lld 个运行中"; +"%@ left" = "剩余 %@"; +"burned" = "已消耗"; +"today %@ of %@" = "今天 %@ / %@"; +"no budget set" = "未设置预算"; +"Refreshing…" = "正在刷新…"; +"Last known usage · refreshing" = "最近已知用量 · 正在刷新"; +"Last known usage · retrying" = "最近已知用量 · 正在重试"; +"Not connected" = "未连接"; +"Reconnect required" = "需要重新连接"; + +/* MARK: Settings */ +"General" = "通用"; +"Providers" = "服务商"; +"%lld on" = "%lld 个已开启"; +"About" = "关于"; +"Settings" = "设置"; +"Search providers" = "搜索服务商"; +"Enter an amount above, or the alert stays off." = "请在上方输入金额,否则提醒不会生效。"; +"Flame icon turns yellow when today's tokens pass the daily budget." = "当今天的 Token 超过每日预算时,火焰图标会变为黄色。"; +"Flame icon turns yellow when today's cost pass the daily budget." = "当今天的花费超过每日预算时,火焰图标会变为黄色。"; +"Display" = "显示"; +"Currency" = "货币"; +"Metric" = "指标"; +"Cost ($)" = "花费($)"; +"Tokens (↑↓)" = "Token(↑↓)"; +"Total Tokens" = "Token 总计"; +"Credits (Codex)" = "点数(Codex)"; +"Icon Only" = "仅图标"; +"Period" = "时间范围"; +"Scope" = "范围"; +"Accent" = "主题色"; +"Usage Refresh" = "用量刷新"; +"Update every" = "更新间隔"; +"How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now." = "菜单栏数字重新读取本机会话数据的频率。“自动”在接通电源时每 30 秒刷新一次,使用电池时会放慢;“手动”仅在你打开弹窗或点按“立即刷新”时刷新。"; +"Updates" = "更新"; +"Notify me about updates" = "有更新时通知我"; +"Posts a notification when a new CodeBurn release is available. Click it to install." = "当有新的 CodeBurn 版本发布时发送通知。点按通知即可安装。"; +"Terminal" = "终端"; +"Open commands in" = "命令打开方式"; +"%@ (not installed)" = "%@(未安装)"; +"Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed." = "“完整报告”和“优化”的打开位置。如果所选应用未安装,CodeBurn 会回退到“终端”;若“终端”也不存在,命令将在后台运行。此处仅列出能够将命令写入活动窗口的终端。"; +"Alerts" = "提醒"; +"Daily budget" = "每日预算"; +"Off" = "关闭"; +"Custom…" = "自定义…"; +"Amount" = "金额"; +"M tokens" = "百万 Token"; +"Show Capacity Dock" = "显示容量 Dock"; +"Resting provider" = "默认服务商"; +"Size" = "大小"; +"Capacity Dock size" = "容量 Dock 大小"; +"Appearance" = "外观"; +"Gauge shape" = "仪表形状"; +"Dock providers" = "Dock 中的服务商"; +"Connect a provider from its sidebar page to make it available here." = "请在边栏的服务商页面中连接服务商,它才会出现在这里。"; +"Needs attention" = "需要处理"; +"Connected providers and anything already shown in the dock appear here, so a provider can always be removed even if its connection later fails." = "已连接的服务商以及已显示在 Dock 中的项目都会列在这里,因此即使某个服务商之后连接失败,也始终可以将其移除。"; +"Connection" = "连接"; +"Config Directories" = "配置目录"; +"Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list." = "汇总多个 Claude 配置目录(例如工作与个人账户)的用量。留空则仅跟踪默认的 `~/.claude`。如果设置了 `CLAUDE_CONFIG_DIRS` 环境变量,它会覆盖此列表。"; +"Quota Refresh" = "配额刷新"; +"Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand." = "Anthropic 会按账户对该接口限流。对于 5 小时和每周窗口,2 分钟已经足够;如果只想按需更新,请选择“手动”。"; +"Connected" = "已连接"; +"Backing off" = "正在退避"; +"Connecting…" = "正在连接…"; +"Ready" = "就绪"; +"Plan: %@" = "套餐:%@"; +"Live quota tracked from Anthropic." = "实时配额来自 Anthropic。"; +"Anthropic rate-limited; auto-retrying." = "Anthropic 已限流,正在自动重试。"; +"macOS may ask permission to read your credentials." = "macOS 可能会请求读取你的凭证的权限。"; +"Background refresh in progress." = "正在后台刷新。"; +"Tap Load Quota to fetch live usage from Anthropic." = "点按“载入配额”以从 Anthropic 获取实时用量。"; +"Click Connect to read your Claude Code credentials and start tracking quota." = "点按“连接”以读取你的 Claude Code 凭证并开始跟踪配额。"; +"Disconnect" = "断开连接"; +"Disconnect Claude?" = "要断开 Claude 连接吗?"; +"Cancel" = "取消"; +"CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your Claude Code credential is untouched. Claude Code keeps working." = "CodeBurn 将停止跟踪配额,并清除其连接状态以及任何旧版凭证缓存。你的 Claude Code 凭证不会受到影响,Claude Code 仍可正常使用。"; +"Load Quota" = "载入配额"; +"No extra directories. Tracking the default `~/.claude`." = "没有额外目录,正在跟踪默认的 `~/.claude`。"; +"Remove" = "移除"; +"Add Directory…" = "添加目录…"; +"Add" = "添加"; +"Choose one or more Claude config directories (each containing a `projects` folder)." = "选择一个或多个 Claude 配置目录(每个目录都应包含 `projects` 文件夹)。"; +"Codex live-quota tracking follows the authoritative `~/.codex/auth.json` session directly and does not create a second Keychain copy. A legacy CodeBurn Keychain item, when present, is read only as a migration fallback. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead." = "Codex 实时配额跟踪直接沿用权威的 `~/.codex/auth.json` 会话,不会创建第二份钥匙串副本。若存在旧版 CodeBurn 钥匙串项目,仅作为迁移回退读取。仅支持 ChatGPT 模式认证(Plus / Pro / Team / Business / Edu / Enterprise)。使用 API 密钥的用户按请求计费,报告方式不同。按点数计量的工作区不报告限流窗口,因此会改为显示其每月点数额度。"; +"How it works" = "工作原理"; +"Couldn't load Codex quota" = "无法载入 Codex 配额"; +"Live quota tracked from chatgpt.com." = "实时配额来自 chatgpt.com。"; +"Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking." = "Codex 处于 API 密钥模式。运行 `codex login` 并选择 ChatGPT 套餐以启用配额跟踪。"; +"Run `codex login` in your terminal to sign in again, then click Reconnect." = "在终端中运行 `codex login` 重新登录,然后点按“重新连接”。"; +"ChatGPT rate-limited; auto-retrying." = "ChatGPT 已限流,正在自动重试。"; +"Reading ~/.codex/auth.json." = "正在读取 ~/.codex/auth.json。"; +"Tap Load Quota to fetch live usage from chatgpt.com." = "点按“载入配额”以从 chatgpt.com 获取实时用量。"; +"Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json." = "点按“连接”以读取你的 Codex 命令行工具凭证。如果连接失败,请先在终端中运行 `codex login` 以创建 ~/.codex/auth.json。"; +"Disconnect Codex?" = "要断开 Codex 连接吗?"; +"CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your ~/.codex/auth.json is untouched. Codex CLI keeps working." = "CodeBurn 将停止跟踪配额,并清除其连接状态以及任何旧版凭证缓存。你的 ~/.codex/auth.json 不会受到影响,Codex 命令行工具仍可正常使用。"; +"Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly. Nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect." = "Kimi Code 实时配额跟踪直接读取 `~/.kimi-code/credentials/kimi-code.json`,不会复制或存储任何内容。访问令牌有效期很短(约 15 分钟),且只有 Kimi 命令行工具会刷新它们;因此若连接显示为已过期,请运行一次 Kimi 命令行工具,然后点按“重新连接”。"; +"Login refresh required" = "需要刷新登录"; +"Couldn't load Kimi quota" = "无法载入 Kimi 配额"; +"Live quota tracked from api.kimi.com." = "实时配额来自 api.kimi.com。"; +"Kimi rate-limited; auto-retrying." = "Kimi 已限流,正在自动重试。"; +"Reading ~/.kimi-code credentials." = "正在读取 ~/.kimi-code 凭证。"; +"Tap Load Quota to fetch live usage from api.kimi.com." = "点按“载入配额”以从 api.kimi.com 获取实时用量。"; +"Sign in with the Kimi CLI first, then click Connect." = "请先通过 Kimi 命令行工具登录,然后点按“连接”。"; +"Disconnect Kimi Code?" = "要断开 Kimi Code 连接吗?"; +"CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched. The Kimi CLI keeps working." = "CodeBurn 将停止跟踪 Kimi Code 配额。你的 ~/.kimi-code 凭证不会受到影响,Kimi 命令行工具仍可正常使用。"; +"Gemini live-quota tracking reads `~/.gemini/oauth_creds.json` read-only. Nothing is copied or stored, and tokens stay in memory. If the connection shows as expired, run the Gemini CLI once to refresh your login, then click Reconnect." = "Gemini 实时配额跟踪以只读方式读取 `~/.gemini/oauth_creds.json`,不会复制或存储任何内容,令牌仅保留在内存中。若连接显示为已过期,请运行一次 Gemini 命令行工具以刷新登录,然后点按“重新连接”。"; +"Couldn't load Gemini quota" = "无法载入 Gemini 配额"; +"Live quota tracked from Google Code Assist." = "实时配额来自 Google Code Assist。"; +"Gemini rate-limited; auto-retrying." = "Gemini 已限流,正在自动重试。"; +"Reading ~/.gemini credentials." = "正在读取 ~/.gemini 凭证。"; +"Tap Load Quota to fetch live usage from Google Code Assist." = "点按“载入配额”以从 Google Code Assist 获取实时用量。"; +"Sign in with the Gemini CLI first, then click Connect." = "请先通过 Gemini 命令行工具登录,然后点按“连接”。"; +"Disconnect Gemini?" = "要断开 Gemini 连接吗?"; +"CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working." = "CodeBurn 将停止跟踪 Gemini 配额。你的 ~/.gemini 凭证不会受到影响,Gemini 命令行工具仍可正常使用。"; +"Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token." = "Copilot 实时配额跟踪以只读方式读取这台 Mac 上已有的 GitHub 令牌,不会复制或存储任何内容。CodeBurn 会依次查看 `~/.config/github-copilot` 中的编辑器插件文件、Copilot 命令行工具的 `~/.copilot` 文件、COPILOT_GITHUB_TOKEN、GH_TOKEN 和 GITHUB_TOKEN 环境变量、`gh auth token`,最后是你在下方粘贴的令牌。用量跟踪无需这些内容,只有实时配额条才需要令牌。"; +"GitHub token" = "GitHub 令牌"; +"Save & Connect" = "存储并连接"; +"Clear Token" = "清除令牌"; +"Paste a token" = "粘贴令牌"; +"Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota." = "可选,仅当这台 Mac 上没有其他已登录来源时才需要。一个具有 \"Plan: Read-only\" 权限的细粒度个人访问令牌即可。该令牌保存在 CodeBurn 自己的钥匙串项目中,且仅用于读取你的 Copilot 配额。"; +"Couldn't load Copilot quota" = "无法载入 Copilot 配额"; +"Live quota tracked from api.github.com." = "实时配额来自 api.github.com。"; +"Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." = "请通过 Copilot 命令行工具、编辑器的 Copilot 插件或 gh auth login 重新登录,然后点按“重新连接”。"; +"GitHub rate-limited; auto-retrying." = "GitHub 已限流,正在自动重试。"; +"Looking for a GitHub token on this Mac." = "正在这台 Mac 上查找 GitHub 令牌。"; +"Tap Load Quota to fetch live usage from api.github.com." = "点按“载入配额”以从 api.github.com 获取实时用量。"; +"Disconnect Copilot?" = "要断开 Copilot 连接吗?"; +"CodeBurn will stop tracking Copilot quota. Every credential it read stays untouched, and your Copilot clients keep working." = "CodeBurn 将停止跟踪 Copilot 配额。它读取过的所有凭证都不会受到影响,你的 Copilot 客户端仍可正常使用。"; +"Antigravity live-quota tracking talks to the Antigravity app's local language server on 127.0.0.1 only. Nothing leaves the machine and no credential files are read. If it shows as disconnected, start the Antigravity app, then click Reconnect." = "Antigravity 实时配额跟踪仅通过 127.0.0.1 与 Antigravity 应用的本地语言服务通信。没有任何数据离开这台机器,也不会读取凭证文件。若显示为已断开,请启动 Antigravity 应用,然后点按“重新连接”。"; +"Couldn't load Antigravity quota" = "无法载入 Antigravity 配额"; +"Live quota tracked from the local Antigravity server." = "实时配额来自本地 Antigravity 服务。"; +"Local probe failed; auto-retrying." = "本地探测失败,正在自动重试。"; +"Probing the local Antigravity language server." = "正在探测本地 Antigravity 语言服务。"; +"Tap Load Quota to probe the local Antigravity server." = "点按“载入配额”以探测本地 Antigravity 服务。"; +"Start the Antigravity app first, then click Connect." = "请先启动 Antigravity 应用,然后点按“连接”。"; +"No local Antigravity server found. Start the Antigravity app, then click Reconnect." = "未找到本地 Antigravity 服务。启动 Antigravity 应用,然后点按“重新连接”。"; +"Disconnect Antigravity?" = "要断开 Antigravity 连接吗?"; +"CodeBurn will stop tracking Antigravity quota. Nothing is read from or written to disk. The Antigravity app keeps working." = "CodeBurn 将停止跟踪 Antigravity 配额。不会从磁盘读取或写入任何内容,Antigravity 应用仍可正常使用。"; +"Not yet supported" = "暂不支持"; +"Automatic connection uses the provider's existing app, CLI, OAuth, browser session, or environment credentials first. CodeBurn does not copy those source credentials into its Keychain." = "自动连接会优先使用服务商已有的应用、命令行工具、OAuth、浏览器会话或环境凭证。CodeBurn 不会把这些来源凭证复制到自己的钥匙串中。"; +"Authentication methods are listed for reference. A native CodeBurn quota adapter is required before this provider can connect to Capacity Dock." = "此处列出的认证方式仅供参考。该服务商需要原生的 CodeBurn 配额适配器,才能连接到容量 Dock。"; +"Authentication methods" = "认证方式"; +"Source" = "来源"; +"API key or token" = "API 密钥或令牌"; +"Clear Override" = "清除覆盖设置"; +"Loading saved provider credential" = "正在载入已存储的服务商凭证"; +"Connection override" = "连接覆盖设置"; +"Overrides are optional and are saved only when you press Save & Connect. Secret values use one CodeBurn-owned Keychain item for this provider; background reads suppress authentication UI." = "覆盖设置是可选的,仅在你点按“存储并连接”时才会保存。机密值会为该服务商使用一个由 CodeBurn 拥有的钥匙串项目;后台读取不会弹出认证界面。"; +"Remove saved override" = "移除已存储的覆盖设置"; +"Saved data" = "已存储的数据"; +"This credential predates a live CodeBurn quota adapter and is not treated as a connection." = "此凭证早于 CodeBurn 的实时配额适配器,不会被视为一个连接。"; +"Quota adapter not available" = "配额适配器不可用"; +"Retrying" = "正在重试"; +"%@ is catalogued, but CodeBurn cannot fetch its live quota yet." = "%@ 已收录,但 CodeBurn 还无法获取其实时配额。"; +"Live quota is available to Capacity Dock." = "实时配额已可供容量 Dock 使用。"; +"Waiting for quota data." = "正在等待配额数据。"; +"Automatic" = "自动"; +"CLI" = "命令行工具"; +"API" = "API"; +"ACU Conversion" = "ACU 换算"; +"USD per ACU" = "每 ACU 美元"; +"Save" = "存储"; +"CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost." = "只有在配置了此汇率之后,CodeBurn 才会从本地记录中读取 Devin 的 ACU 用量,并在报告花费前将每一步乘以该汇率。"; +"Saved. Refresh CodeBurn to recalculate Devin cost." = "已存储。刷新 CodeBurn 以重新计算 Devin 花费。"; +"Version %@" = "版本 %@"; +"%@ is available. Choose Check for Updates in the CodeBurn menu to install it." = "%@ 已发布。在 CodeBurn 菜单中选择“检查更新”即可安装。"; +"GitHub" = "GitHub"; +"Website" = "网站"; +"Issues" = "问题反馈"; +"Links" = "链接"; +"© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License." = "© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT 许可证。"; From d7cac4b508cd6c42182d16bfb2249b6e23bdc660 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:34:34 +0300 Subject: [PATCH 3/7] test(menubar): assert the en and zh-Hans catalogs cannot drift apart A translation catalog rots in ways the compiler cannot see: a key added to a view but not to zh-Hans shows English in a Chinese UI, a dropped key shows a raw identifier, and a format specifier that disagrees between the tables is a wrong number or a crash inside String(format:). LocalizationCatalogTests reads both tables out of the resource bundle, the same files L(_:) resolves at runtime, and asserts: the key sets are equal in both directions; no value is blank; en repeats its key verbatim, which is what makes the English fallback correct; argument specifiers match in count and order, because String(format:) binds them positionally; literal `%%` counts match, while allowing `%%` to move where Chinese word order demands it; no key is only specifiers, which would leave a translator nothing to work with; and the shipped localizations agree with L10n.supportedLocalizations, so Package.swift, the two packaging scripts' CFBundleLocalizations and the Swift constant cannot drift. Four cases then resolve representative presentation strings in both locales and check that the English one equals what the presentation struct returns, which is the link proving the struct reads the catalog rather than a stale hardcoded string, and that a substituted product name survives translation. The specifier test caught one bad key while being written: the Capacity Dock connect button's accessibility label was `L("%@ %@", title, providerName)`, which has no translatable content at all. It is plain interpolation now, and both halves were already localized on their own. CHANGELOG and mac/README document the user-visible part: the language follows the system, overridable per app in System Settings, plus what adding a third language requires. Closes #1219 --- CHANGELOG.md | 5 + mac/README.md | 23 ++ .../LocalizationCatalogTests.swift | 245 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d69640b69..e31a3d86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Added (macOS) +- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 533 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages, and `CFBundleLocalizations` in both packaging scripts puts CodeBurn under System Settings > Language & Region so the language can be overridden for this app alone. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own symbols, so a Chinese UI reads `2026年9月` and `一 二 三` rather than `September 2026` and `Mo Tu We`. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219) + ## 0.9.24 - 2026-09-04 ### Added diff --git a/mac/README.md b/mac/README.md index 07e6b1d31..e436b0e49 100644 --- a/mac/README.md +++ b/mac/README.md @@ -8,6 +8,26 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface. - Swift 6.0+ toolchain (bundled with Xcode 16 or standalone) - `codeburn` CLI installed globally (`npm install -g codeburn`) +## Language + +The app ships English and Simplified Chinese (`zh-Hans`) and follows your system +language: no setting inside CodeBurn, because macOS already owns this choice. To +use a different language for CodeBurn than for the rest of the system, open +System Settings > General > Language & Region, scroll to Applications, and add +CodeBurn Menubar there. + +Strings live in `Sources/CodeBurnMenubar/Resources/.lproj/Localizable.strings` +and are reached through `L(_:)` / `L(_:_:)` (see `Localization.swift`). The key +*is* the English copy, so an untranslated string shows correct English rather +than an identifier, and `en.lproj` is an identity table. + +To add a language, copy `en.lproj` to `.lproj`, translate the values, +then add the locale in three places that must stay in step: `.process` in +`Package.swift`, `CFBundleLocalizations` in both `Scripts/package-app.sh` and +`Scripts/build-local.sh`, and `L10n.supportedLocalizations`. +`LocalizationCatalogTests` fails if they disagree, if a key is missing from +either table, if a value is blank, or if the format specifiers do not match. + ## Install (end users) One command: @@ -82,6 +102,9 @@ mac/ ├── Sources/CodeBurnMenubar/ │ ├── CodeBurnApp.swift @main + MenuBarExtra scene │ ├── AppStore.swift @Observable store + enums +│ ├── Localization.swift L(_:) lookups against the module bundle +│ ├── Resources/en.lproj/ Localizable.strings (identity table) +│ ├── Resources/zh-Hans.lproj/ Localizable.strings (简体中文) │ ├── Data/MenubarPayload.swift Codable payload types + placeholder │ ├── Theme/Theme.swift Design tokens (warm terracotta palette) │ └── Views/MenuBarContent.swift Popover layout + footer action bar diff --git a/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift b/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift new file mode 100644 index 000000000..14ac1f886 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift @@ -0,0 +1,245 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Guards the `Localizable.strings` catalogs (#1219). +/// +/// A translation catalog rots silently: a key added to a view but not to +/// `zh-Hans` shows English in a Chinese UI, a dropped key shows a raw +/// identifier, and a format specifier that disagrees between the two tables is +/// a wrong number or a crash inside `String(format:)` — none of which the +/// compiler can see. These tests are the only thing standing between a new +/// string and that class of bug. +@Suite("Localization catalogs") +struct LocalizationCatalogTests { + + // MARK: - Loading + + /// Reads one locale's table straight out of the resource bundle: the same + /// file `L(_:)` resolves at runtime. + static func table(_ localization: String) throws -> [String: String] { + let path = L10n.bundle.path( + forResource: L10n.table, + ofType: "strings", + inDirectory: nil, + forLocalization: localization + ) + let resolved = try #require( + path, + "no Localizable.strings for this localization; is Resources/.lproj declared in Package.swift?" + ) + let parsed = NSDictionary(contentsOfFile: resolved) as? [String: String] + return try #require( + parsed, + "table is not a string/string plist — usually a stray quote or a missing semicolon" + ) + } + + /// Specifier occurrences in order, `%%` included. + static func specifiers(in value: String) -> [String] { + var found: [String] = [] + var rest = Substring(value) + while let percent = rest.firstIndex(of: "%") { + var cursor = rest.index(after: percent) + guard cursor < rest.endIndex else { break } + if rest[cursor] == "%" { + found.append("%%") + rest = rest[rest.index(after: cursor)...] + continue + } + // Flags, width and precision, then any length modifier, then the verb. + var token = "%" + while cursor < rest.endIndex, "0123456789.+- #'".contains(rest[cursor]) { + token.append(rest[cursor]) + cursor = rest.index(after: cursor) + } + while cursor < rest.endIndex, "lhqLzjt".contains(rest[cursor]) { + token.append(rest[cursor]) + cursor = rest.index(after: cursor) + } + if cursor < rest.endIndex { + token.append(rest[cursor]) + cursor = rest.index(after: cursor) + } + found.append(token) + rest = rest[cursor...] + } + return found + } + + /// The specifiers that consume an argument. `String(format:)` binds these + /// positionally, so their order is part of the contract between locales. + static func arguments(in value: String) -> [String] { + specifiers(in: value).filter { $0 != "%%" } + } + + static func literalPercentCount(in value: String) -> Int { + specifiers(in: value).filter { $0 == "%%" }.count + } + + /// Formats `key` out of one locale's table, the way `L(_:_:)` does for + /// whichever locale AppKit picks at runtime. + static func localized(_ key: String, _ localization: String, _ arguments: CVarArg...) throws -> String { + let format = try #require(table(localization)[key], "missing key in this localization") + return String(format: format, arguments: arguments) + } + + // MARK: - Coverage + + @Test("every shipped localization has a table, and they agree with Package.swift") + func shippedLocalizationsMatchTheManifest() throws { + let declared = Set(L10n.supportedLocalizations.map { $0.lowercased() }) + // NSBundle lowercases what it reports, so compare case-insensitively. + let onDisk = Set(L10n.bundle.localizations.map { $0.lowercased() }) + #expect( + declared == onDisk, + "L10n.supportedLocalizations and the bundle disagree; Package.swift, both packaging scripts' CFBundleLocalizations, and L10n have to move together" + ) + + for localization in L10n.supportedLocalizations { + let entries = try Self.table(localization) + #expect(!entries.isEmpty) + } + } + + @Test("en and zh-Hans cover exactly the same keys") + func keySetsMatch() throws { + let en = try Self.table("en") + let zh = try Self.table("zh-Hans") + + let untranslated = Set(en.keys).subtracting(zh.keys).sorted() + #expect( + untranslated.isEmpty, + "these en keys have no zh-Hans entry: \(untranslated.prefix(10))" + ) + + let orphaned = Set(zh.keys).subtracting(en.keys).sorted() + #expect( + orphaned.isEmpty, + "these zh-Hans keys are not in en, so nothing ever reaches them: \(orphaned.prefix(10))" + ) + } + + @Test("no entry is blank in either locale") + func noEmptyValues() throws { + for localization in ["en", "zh-Hans"] { + let blank = try Self.table(localization) + .filter { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .keys + .sorted() + #expect(blank.isEmpty, "blank values render as nothing at all: \(blank.prefix(10))") + } + } + + @Test("en is an identity table, so a missing translation degrades to English") + func englishIsIdentity() throws { + let mismatched = try Self.table("en").filter { $0.key != $0.value }.keys.sorted() + #expect( + mismatched.isEmpty, + "en entries must repeat their key verbatim — L(_:) falls back to the key, so drift means two different English strings for one key: \(mismatched.prefix(10))" + ) + } + + // MARK: - Format specifiers + + @Test("argument specifiers match in count and order across locales") + func argumentSpecifierParity() throws { + let en = try Self.table("en") + let zh = try Self.table("zh-Hans") + + for key in en.keys.sorted() { + guard let english = en[key], let chinese = zh[key] else { continue } + let expected = Self.arguments(in: english) + let actual = Self.arguments(in: chinese) + #expect( + expected == actual, + "specifier mismatch for \(key.debugDescription): en \(expected) vs zh-Hans \(actual). String(format:) binds positionally, so a reorder or a dropped specifier is a wrong value or a crash." + ) + } + } + + @Test("literal percent signs survive translation") + func literalPercentParity() throws { + let en = try Self.table("en") + let zh = try Self.table("zh-Hans") + + for key in en.keys.sorted() { + guard let english = en[key], let chinese = zh[key] else { continue } + // Unlike the argument specifiers above, `%%` may move: Chinese word + // order puts the time before the verb in "%@ · %@ 达到 100%%". + let expected = Self.literalPercentCount(in: english) + let actual = Self.literalPercentCount(in: chinese) + #expect( + expected == actual, + "\(key.debugDescription) has \(expected) literal percent sign(s) in en but \(actual) in zh-Hans" + ) + } + } + + @Test("a key is never only specifiers, which would leave nothing to translate") + func keysCarryContext() throws { + for key in try Self.table("en").keys { + let stripped = Self.specifiers(in: key) + .reduce(key) { $0.replacingOccurrences(of: $1, with: "") } + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + !stripped.isEmpty, + "\(key.debugDescription) is only specifiers and spaces; a translator has no sentence to work with" + ) + } + } + + // MARK: - Representative presentation strings + + @Test("session-count copy resolves in both locales") + func sessionCountResolves() throws { + let english = try Self.localized("%lld sessions", "en", 3) + let chinese = try Self.localized("%lld sessions", "zh-Hans", 3) + #expect(english == "3 sessions") + #expect(chinese == "3 个会话") + + let lowerBound = try Self.localized("At least %lld sessions", "zh-Hans", 12) + #expect(lowerBound == "至少 12 个会话") + + // The test process runs under en, so the presentation struct itself must + // agree with the en table. That is the link proving the struct reads the + // catalog rather than a stale hardcoded string. + #expect(SessionCountLabel.text(sessions: 3, basis: "identity") == english) + let unavailable = try Self.localized("Session count unavailable", "en") + #expect(SessionCountLabel.combinedText == unavailable) + } + + @Test("provider reconnect copy resolves in both locales, keeping the product name") + func reconnectCopyResolves() throws { + let presentation = ProviderReconnectPresentation(provider: .claude) + let englishTitle = try Self.localized("Reconnect %@", "en", "Claude") + #expect(presentation.title == englishTitle) + + let chineseTitle = try Self.localized("Reconnect %@", "zh-Hans", "Claude") + #expect(chineseTitle == "重新连接 Claude") + // Product names are never translated, and the substitution must carry through. + #expect(chineseTitle.contains("Claude")) + + let chineseInstruction = try Self.localized( + "Open Claude Code in your terminal and type `/login`, then click Reconnect.", + "zh-Hans" + ) + #expect(chineseInstruction.contains("Claude Code")) + #expect(chineseInstruction.contains("/login")) + } + + @Test("percent-bearing quota copy formats correctly in both locales") + func quotaCopyResolves() throws { + let englishOverLimit = try Self.localized("%@ over limit (%lld%%)", "en", "Claude", 105) + #expect(englishOverLimit == "Claude over limit (105%)") + + let chineseOverLimit = try Self.localized("%@ over limit (%lld%%)", "zh-Hans", "Claude", 105) + #expect(chineseOverLimit == "Claude 已超限(105%)") + + let countdown = try Self.localized("%lldh %lldm", "zh-Hans", 2, 11) + #expect(countdown == "2 小时 11 分") + + let cacheHit = try Self.localized("%@%% cache hit", "zh-Hans", "87") + #expect(cacheHit == "缓存命中 87%") + } +} From 1b230601912cc565cf6fac5c79f994bede747d90 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:43:07 +0300 Subject: [PATCH 4/7] feat(menubar): translate the strings the two new menubar features added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1252 and #1308's second status-item row, #1243's Capacity Dock glance switch and #1286's Copilot host fix all shipped user-facing copy that bypasses the catalog — neither of the first two PRs' diffs contains a single `L(` call. In a zh-Hans build those strings render in English. Sweeping mac/Sources the way the original extraction did brings 18 new keys, and retires 2 that main reworded (the Copilot help paragraph gained the GitHub Enterprise Cloud host rule; the connection detail now names the host that answered rather than hardcoding api.github.com). 533 keys become 551. Routed here: - Second row: the Settings toggle, the metric picker and its four option names, the help paragraph, and the row text itself — today's cost, today's tokens, and the quota row with and without a provider label. The reset countdown reuses the popover's own `%lldd %lldh` / `%lldh %lldm` / `%lldm` keys rather than minting a second set, so the two countdowns cannot disagree in a translated build. - Capacity Dock glance: the switch hint and the named VoiceOver action. - Copilot: the reworded help paragraph and the host-aware connection detail. Kept verbatim, per the rules in the catalog header: provider, model and plan names; `tok`; the `%.2f` amount and its currency symbol; and the window label the gauge reports, which is provider data and doubles as the matching needle in CapacityDockGlanceWindow — translating the generic fallback alone would make one VoiceOver sentence half-Chinese depending on the provider selected. Also localizes CodexUsage's credit-limit labels. Those are not new, they were missed by the original extraction and the guard test in the next commit finds them; leaving them would mean shipping a test that fails on main. Verified with a standalone swiftc harness over both tables (`swift test` cannot run on this host): key parity, no blank values, en identity, and argument/literal-percent specifier parity, plus the new rows formatting correctly in both locales. --- CHANGELOG.md | 2 +- .../CodeBurnMenubar/Data/CodexUsage.swift | 8 +++-- .../Data/CopilotQuotaPresentation.swift | 6 ++-- .../CodeBurnMenubar/MenubarSecondRow.swift | 26 ++++++++------- .../Resources/en.lproj/Localizable.strings | 32 +++++++++++++++++-- .../zh-Hans.lproj/Localizable.strings | 30 +++++++++++++++-- 6 files changed, 83 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a659e244d..16c2ec518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added (macOS) - **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252) - **The Capacity Dock gauge can report the short usage window instead of the weekly one, without expanding the dock.** The resting rail shows one number per provider, and that number was always the weekly (else monthly) billing window. Clicking the provider already resting in the rail now switches its gauge to the provider's short rolling window — Claude's 5-hour limit, Codex's 5-hour or daily window, any `Hourly`, `Daily` or session row an adapter reports — and clicking again switches back. The choice is stored per provider under `CodeBurnCapacityDockGlanceWindows`, so Claude can sit on its 5-hour window while Codex stays weekly, and it survives relaunch. A per-model row such as `Weekly · Opus` is never read as a short window, a provider that reports only one window keeps the plain click-to-pin behaviour, and a stored horizon the provider stops reporting falls back to the window it does report rather than blanking the gauge to `--`. The rail's geometry is untouched, VoiceOver and keyboard users get the switch as a named action on the provider cell with the window named in the cell's value, and Escape or a click outside still unpins the dock. (#1243) -- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 533 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages, and `CFBundleLocalizations` in both packaging scripts puts CodeBurn under System Settings > Language & Region so the language can be overridden for this app alone. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own symbols, so a Chinese UI reads `2026年9月` and `一 二 三` rather than `September 2026` and `Mo Tu We`. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219) +- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 551 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages, and `CFBundleLocalizations` in both packaging scripts puts CodeBurn under System Settings > Language & Region so the language can be overridden for this app alone. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own symbols, so a Chinese UI reads `2026年9月` and `一 二 三` rather than `September 2026` and `Mo Tu We`. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219) ### Fixed - **Copilot live quota works for GitHub Enterprise Cloud enterprises on a `*.ghe.com` host.** Both readers hardcoded `https://api.github.com/copilot_internal/user` and threw away the host their credential came from, so a data-residency enterprise signed in on `.ghe.com` could only ever report `available: false` with "Temporarily unavailable". A discovered credential now carries its host — `hosts.json` is keyed by host and newer `apps.json` files key by `:` — and the request follows it: `api.github.com` for `github.com` and for any rung that carries no host of its own (an app-name `apps.json` key, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, a pasted token), and `https://api..ghe.com/copilot_internal/user` for an enterprise host. The token and the host always come from the same entry, with `github.com` preferred when several hosts are signed in and otherwise the first `.ghe.com` tenant in sorted order; a host neither rule can address, such as a self-hosted GitHub Enterprise Server install, fails with a message naming that host instead of sending the credential to dotcom, and unreachable-host and HTTP failures name the host that was tried. The macOS Settings connection row now says which host answered. (#1286) diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index 2fbbdbf1a..01bd41dd0 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -105,12 +105,14 @@ struct CodexUsage: Sendable, Equatable { func text(_ value: Double) -> String { formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))" } - let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits" - return reached ? "\(base) · limit reached" : base + // The two figures are already grouped by the formatter above, so + // they are substituted formatted and only the sentence is translated. + let base = L("Monthly usage limit · %@ / %@ credits", text(used), text(limit)) + return reached ? L("%@ · limit reached", base) : base } var shortLabel: String { - reached ? "Monthly usage limit · limit reached" : "Monthly usage limit" + reached ? L("Monthly usage limit · limit reached") : L("Monthly usage limit") } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift index f54751481..09e01b931 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift @@ -74,8 +74,10 @@ enum CopilotQuotaPresentation { /// own `api..ghe.com` endpoint rather than a dotcom claim (#1286). static func connectedSettingsDetail(plan: String?, apiHost: String) -> String { let host = apiHost.isEmpty ? CopilotHostEndpoint.defaultAPIHost : apiHost - guard let plan, !plan.isEmpty else { return "Live quota tracked from \(host)." } - return "Plan: \(plan). Live quota tracked from \(host)." + // The host is an API hostname and the plan comes from GitHub; both are + // substituted verbatim, only the sentence around them is translated. + guard let plan, !plan.isEmpty else { return L("Live quota tracked from %@.", host) } + return L("Plan: %@. Live quota tracked from %@.", plan, host) } static func settingsNotConnectedDetail(explicitlyDisconnected: Bool) -> String { diff --git a/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift b/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift index 379303964..bd34999e5 100644 --- a/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift +++ b/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift @@ -19,10 +19,10 @@ enum MenubarSecondRowMetric: String, CaseIterable, Identifiable, Sendable { /// Settings picker label. var settingsLabel: String { switch self { - case .quotaRemaining: "Quota remaining" - case .todayCost: "Today's cost" - case .todayTokens: "Today's tokens" - case .activeSessions: "Active sessions" + case .quotaRemaining: L("Quota remaining") + case .todayCost: L("Today's cost") + case .todayTokens: L("Today's tokens") + case .activeSessions: L("Active sessions") } } } @@ -161,10 +161,11 @@ enum MenubarRowFormatter { case .todayCost: guard let cost = snapshot.todayCost, cost.isFinite else { return nil } let converted = cost * snapshot.currencyRate - return String(format: "\(snapshot.currencySymbol)%.2f today", converted) + let amount = String(format: "\(snapshot.currencySymbol)%.2f", converted) + return L("%@ today", amount) case .todayTokens: guard let tokens = snapshot.todayTotalTokens else { return nil } - return "\(compactTokens(Double(tokens))) tok today" + return L("%@ tok today", compactTokens(Double(tokens))) case .activeSessions: guard let count = snapshot.activeSessionCount else { return nil } // Live sessions are identity-derived, so the exact phrasing applies. @@ -179,7 +180,9 @@ enum MenubarRowFormatter { guard let quota, quota.percentUsed.isFinite else { return nil } let remaining = min(max(1 - quota.percentUsed, 0), 1) let percent = Int((remaining * 100).rounded()) - var row = quota.label.isEmpty ? "\(percent)% left" : "\(quota.label) \(percent)% left" + var row = quota.label.isEmpty + ? L("%lld%% left", percent) + : L("%@ %lld%% left", quota.label, percent) if let countdown = resetCountdown(quota.resetsAt, now: now) { row += " · \(countdown)" } @@ -191,13 +194,14 @@ enum MenubarRowFormatter { static func resetCountdown(_ resetsAt: Date?, now: Date) -> String? { guard let resetsAt else { return nil } let seconds = max(0, resetsAt.timeIntervalSince(now)) - if seconds < 60 { return "now" } + if seconds < 60 { return L("now") } let minutes = Int(seconds / 60) let hours = minutes / 60 let days = hours / 24 - if days > 0 { return "\(days)d \(hours % 24)h" } - if hours > 0 { return "\(hours)h \(minutes % 60)m" } - return "\(minutes)m" + // d/h/m are unit abbreviations; zh-Hans uses 天/小时/分. + if days > 0 { return L("%lldd %lldh", days, hours % 24) } + if hours > 0 { return L("%lldh %lldm", hours, minutes % 60) } + return L("%lldm", minutes) } /// Menu-bar token shorthand, matching `Double.asCompactTokens()`. Kept here diff --git a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings index e7ade8510..1e4d08237 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings @@ -427,6 +427,10 @@ "Capacity Dock" = "Capacity Dock"; "Unknown" = "Unknown"; "Click to keep Capacity Dock expanded" = "Click to keep Capacity Dock expanded"; +"Click to switch between this provider's usage windows" = "Click to switch between this provider's usage windows"; +/* VoiceOver action on the provider cell. The window name is either the + provider's own label or the generic horizon, both left verbatim. */ +"Show %@ usage" = "Show %@ usage"; "none running" = "none running"; "1 running" = "1 running"; "%lld running" = "%lld running"; @@ -544,14 +548,15 @@ "Sign in with the Gemini CLI first, then click Connect." = "Sign in with the Gemini CLI first, then click Connect."; "Disconnect Gemini?" = "Disconnect Gemini?"; "CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working." = "CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working."; -"Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token." = "Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token."; +"Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token. A credential found for a GitHub Enterprise Cloud host is queried on that tenant's own API (api..ghe.com) and never sent to api.github.com." = "Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token. A credential found for a GitHub Enterprise Cloud host is queried on that tenant's own API (api..ghe.com) and never sent to api.github.com."; "GitHub token" = "GitHub token"; "Save & Connect" = "Save & Connect"; "Clear Token" = "Clear Token"; "Paste a token" = "Paste a token"; "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota." = "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota."; "Couldn't load Copilot quota" = "Couldn't load Copilot quota"; -"Live quota tracked from api.github.com." = "Live quota tracked from api.github.com."; +"Live quota tracked from %@." = "Live quota tracked from %@."; +"Plan: %@. Live quota tracked from %@." = "Plan: %@. Live quota tracked from %@."; "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." = "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect."; "GitHub rate-limited; auto-retrying." = "GitHub rate-limited; auto-retrying."; "Looking for a GitHub token on this Mac." = "Looking for a GitHub token on this Mac."; @@ -601,3 +606,26 @@ "Issues" = "Issues"; "Links" = "Links"; "© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License." = "© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License."; + +/* MARK: Menubar second row (#1252) */ +"Second row" = "Second row"; +"Second row shows" = "Second row shows"; +"Adds a smaller second line under the menubar figure. Quota remaining tracks whichever connected provider is nearest its limit. The line hides itself while the chosen metric has no data." = "Adds a smaller second line under the menubar figure. Quota remaining tracks whichever connected provider is nearest its limit. The line hides itself while the chosen metric has no data."; +"Quota remaining" = "Quota remaining"; +"Today's cost" = "Today's cost"; +"Today's tokens" = "Today's tokens"; +"Active sessions" = "Active sessions"; +/* Row text. The amount, the token figure and the provider name are substituted + already formatted, so only the word around them is translated; `tok` is a + unit and stays verbatim. The reset countdown reuses the popover's own + `%lldd %lldh` / `%lldh %lldm` / `%lldm` keys above. */ +"%@ today" = "%@ today"; +"%@ tok today" = "%@ tok today"; +"%lld%% left" = "%lld%% left"; +"%@ %lld%% left" = "%@ %lld%% left"; + +/* MARK: Codex credit limit */ +"Monthly usage limit" = "Monthly usage limit"; +"Monthly usage limit · limit reached" = "Monthly usage limit · limit reached"; +"Monthly usage limit · %@ / %@ credits" = "Monthly usage limit · %@ / %@ credits"; +"%@ · limit reached" = "%@ · limit reached"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings index dccb4c9a0..42ced014c 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings @@ -421,6 +421,9 @@ "Capacity Dock" = "容量 Dock"; "Unknown" = "未知"; "Click to keep Capacity Dock expanded" = "点按以保持容量 Dock 展开"; +"Click to switch between this provider's usage windows" = "点按以在该服务商的用量周期之间切换"; +/* 服务商单元格的 VoiceOver 操作。周期名称来自服务商,保持原样。 */ +"Show %@ usage" = "显示 %@ 用量"; "none running" = "无运行中"; "1 running" = "1 个运行中"; "%lld running" = "%lld 个运行中"; @@ -538,14 +541,15 @@ "Sign in with the Gemini CLI first, then click Connect." = "请先通过 Gemini 命令行工具登录,然后点按“连接”。"; "Disconnect Gemini?" = "要断开 Gemini 连接吗?"; "CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working." = "CodeBurn 将停止跟踪 Gemini 配额。你的 ~/.gemini 凭证不会受到影响,Gemini 命令行工具仍可正常使用。"; -"Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token." = "Copilot 实时配额跟踪以只读方式读取这台 Mac 上已有的 GitHub 令牌,不会复制或存储任何内容。CodeBurn 会依次查看 `~/.config/github-copilot` 中的编辑器插件文件、Copilot 命令行工具的 `~/.copilot` 文件、COPILOT_GITHUB_TOKEN、GH_TOKEN 和 GITHUB_TOKEN 环境变量、`gh auth token`,最后是你在下方粘贴的令牌。用量跟踪无需这些内容,只有实时配额条才需要令牌。"; +"Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token. A credential found for a GitHub Enterprise Cloud host is queried on that tenant's own API (api..ghe.com) and never sent to api.github.com." = "Copilot 实时配额跟踪以只读方式读取这台 Mac 上已有的 GitHub 令牌,不会复制或存储任何内容。CodeBurn 会依次查看 `~/.config/github-copilot` 中的编辑器插件文件、Copilot 命令行工具的 `~/.copilot` 文件、COPILOT_GITHUB_TOKEN、GH_TOKEN 和 GITHUB_TOKEN 环境变量、`gh auth token`,最后是你在下方粘贴的令牌。用量跟踪无需这些内容,只有实时配额条才需要令牌。为 GitHub Enterprise Cloud 主机找到的凭证,只会向该租户自己的 API(api..ghe.com)查询,绝不会发送到 api.github.com。"; "GitHub token" = "GitHub 令牌"; "Save & Connect" = "存储并连接"; "Clear Token" = "清除令牌"; "Paste a token" = "粘贴令牌"; "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. The token is saved in CodeBurn's own Keychain item and is used only to read your Copilot quota." = "可选,仅当这台 Mac 上没有其他已登录来源时才需要。一个具有 \"Plan: Read-only\" 权限的细粒度个人访问令牌即可。该令牌保存在 CodeBurn 自己的钥匙串项目中,且仅用于读取你的 Copilot 配额。"; "Couldn't load Copilot quota" = "无法载入 Copilot 配额"; -"Live quota tracked from api.github.com." = "实时配额来自 api.github.com。"; +"Live quota tracked from %@." = "实时配额来自 %@。"; +"Plan: %@. Live quota tracked from %@." = "套餐:%@。实时配额来自 %@。"; "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." = "请通过 Copilot 命令行工具、编辑器的 Copilot 插件或 gh auth login 重新登录,然后点按“重新连接”。"; "GitHub rate-limited; auto-retrying." = "GitHub 已限流,正在自动重试。"; "Looking for a GitHub token on this Mac." = "正在这台 Mac 上查找 GitHub 令牌。"; @@ -595,3 +599,25 @@ "Issues" = "问题反馈"; "Links" = "链接"; "© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License." = "© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT 许可证。"; + +/* MARK: Menubar second row (#1252) */ +"Second row" = "第二行"; +"Second row shows" = "第二行显示"; +"Adds a smaller second line under the menubar figure. Quota remaining tracks whichever connected provider is nearest its limit. The line hides itself while the chosen metric has no data." = "在菜单栏数字下方添加一行较小的文字。“剩余配额”会跟踪最接近限额的已连接服务商。当所选指标暂无数据时,该行会自动隐藏。"; +"Quota remaining" = "剩余配额"; +"Today's cost" = "今日花费"; +"Today's tokens" = "今日 Token"; +"Active sessions" = "活跃会话"; +/* 菜单栏空间有限,这几条要尽量短。金额、Token 数和服务商名称都已在代码中格式化, + 此处只翻译它们周围的文字;tok 是单位,保持原样。重置倒计时复用上方弹窗的 + `%lldd %lldh` / `%lldh %lldm` / `%lldm` 三个键。 */ +"%@ today" = "今日 %@"; +"%@ tok today" = "今日 %@ tok"; +"%lld%% left" = "剩余 %lld%%"; +"%@ %lld%% left" = "%@ 剩余 %lld%%"; + +/* MARK: Codex credit limit */ +"Monthly usage limit" = "每月用量上限"; +"Monthly usage limit · limit reached" = "每月用量上限 · 已达上限"; +"Monthly usage limit · %@ / %@ credits" = "每月用量上限 · %@ / %@ 点数"; +"%@ · limit reached" = "%@ · 已达上限"; From 15c06905af517a21199402219b0db1e8122d66b3 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:43:28 +0300 Subject: [PATCH 5/7] test(menubar): fail the build when a string never reaches the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalizationCatalogTests diffs en against zh-Hans. That catches a key translated in one locale and not the other, but it is blind to the failure that actually happens: a feature ships a bare `Text("Second row")`, the literal never becomes a key, both tables stay in perfect agreement, and a zh-Hans build renders English. #1252 and #1243 landed exactly that way, which is why the previous commit exists at all. So this suite reads mac/Sources instead of the tables, in three passes that cover each other: 1. Call sites. The SwiftUI and AppKit surfaces that put a string on screen — Text/Button/Toggle/Picker/…, the accessibility modifiers, NSMenuItem, NSAlert and window titles — must be handed `L(…)`, not a literal. 2. Display-label properties. `displayName`/`displayLabel`/`settingsLabel` are how this codebase names an enum for a picker, and they are not call sites, so a new metric case with a bare literal would slip past pass 1. 3. Catalog round-trip. Every key a view asks for has an entry in both locales, and every entry has a call site — so a key added to the code but not the table, or left behind after a rewording, fails too. Scoped to avoid false positives rather than by suppressing findings: - A literal is exempt when, with `\(…)` segments removed, nothing is left but figures, symbols, or words in a three-entry vocabulary (`CodeBurn`, `tok`, `USD`). That covers `Text("$25")`, `Text("\(count)")` and `Text("— ")` without naming a single file, so the exemption cannot go stale. - Pass 2 only looks at bracket-depth zero, which is what separates the property's result (`case .pro: "Pro"`) from machinery it calls (`Locale(identifier: "en_US")`). - The two denylists are documented with reasons and keyed to the declaring type, so `PlanType.displayName` being exempt does not exempt every `displayName` in the app. Comments are stripped first, or Localization.swift's own documentation of `Text("literal")` would read as a violation; strings win over comment markers so a URL in a literal does not swallow the rest of the file. The scanner lives in its own file with no `import Testing` so the standalone swiftc harness can exercise the shipped code rather than a copy of it — `swift test` cannot run on this host, and a reimplementation would drift. Mutation-checked: un-routing `Toggle(L("Second row"))` fails pass 1, un-routing `MenubarSecondRowMetric.settingsLabel` fails pass 2, and dropping `L("Show %@ usage")` fails pass 3. Writing it also turned up two real bugs in the scan (`Label(` matching inside `.accessibilityLabel(`, and a nested `enum PlanType` reporting its outer type), both now covered by a test. Closes #1289 --- .../LocalizationCoverageTests.swift | 374 ++++++++++++ .../LocalizationSourceScanner.swift | 573 ++++++++++++++++++ 2 files changed, 947 insertions(+) create mode 100644 mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift diff --git a/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift b/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift new file mode 100644 index 000000000..9be8d72a8 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift @@ -0,0 +1,374 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Fails when a user-facing string in `mac/Sources` never reaches the +/// `Localizable.strings` catalog (#1219). +/// +/// `LocalizationCatalogTests` compares the two tables with each other, which +/// passes perfectly while a literal sits in a view and is never a key at all: +/// both locales agree, and a zh-Hans build shows English. #1252 and #1243 both +/// shipped that way — neither diff contained a single `L(` call — so these +/// tests read the source and the call sites instead of the tables. +/// +/// The two halves close the same hole from opposite ends: a literal that never +/// becomes a key, and a key that never becomes an entry. +@Suite("Localization coverage") +struct LocalizationCoverageTests { + + /// `mac/Sources/CodeBurnMenubar`, relative to this file. `Bundle.module` + /// carries the built resources, not the sources, so the path is derived + /// from `#filePath` — which SwiftPM makes an absolute path to this file. + static var sourcesDirectory: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // CodeBurnMenubarTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // mac + .appendingPathComponent("Sources") + .appendingPathComponent("CodeBurnMenubar") + } + + // MARK: - The guard + + @Test("the scanner can actually see the sources it is meant to guard") + func sourcesAreReachable() throws { + let files = try LocalizationSourceScanner.swiftFiles(in: Self.sourcesDirectory) + #expect( + files.count > 50, + "found \(files.count) Swift files under \(Self.sourcesDirectory.path); if the tree moved, this suite silently guards nothing" + ) + } + + @Test("every user-facing literal in mac/Sources is routed through L(…)") + func everyUserFacingLiteralIsRouted() throws { + let findings = try LocalizationSourceScanner.unroutedLiterals(in: Self.sourcesDirectory) + #expect( + findings.isEmpty, + """ + \(findings.count) user-facing string(s) bypass the catalog, so a zh-Hans \ + build renders them in English: + + \(findings.map(\.description).joined(separator: "\n")) + + Fix by wrapping the literal in L("…") and adding the key to both \ + Resources/en.lproj and Resources/zh-Hans.lproj. If the string is \ + genuinely the same in every locale (a product name, a unit, a \ + currency code), add its word to \ + LocalizationSourceScanner.untranslatableWords with a reason. + """ + ) + } + + @Test("every display-label property in mac/Sources is routed through L(…)") + func everyDisplayLabelIsRouted() throws { + let findings = try LocalizationSourceScanner.unroutedLabelProperties(in: Self.sourcesDirectory) + #expect( + findings.isEmpty, + """ + \(findings.count) picker/label option(s) bypass the catalog: + + \(findings.map(\.description).joined(separator: "\n")) + + A new enum case whose display name is a bare literal is how an \ + untranslated Settings picker option arrives. Wrap it in L("…"), or — \ + if the property returns provider, model or plan names — add it to \ + LocalizationSourceScanner.untranslatedLabelProperties with a reason. + """ + ) + } + + @Test("every key a view asks for exists in both catalogs") + func everyRequestedKeyIsTranslated() throws { + let requested = try LocalizationSourceScanner.requestedKeys(in: Self.sourcesDirectory) + #expect(!requested.isEmpty, "no L(…) call sites found at all — the scanner is broken, not the code") + + for localization in L10n.supportedLocalizations { + let table = try LocalizationCatalogTests.table(localization) + let missing = requested.subtracting(table.keys).sorted() + #expect( + missing.isEmpty, + "\(localization) has no entry for \(missing.count) key(s) a view asks for, which render as raw English: \(missing.prefix(10))" + ) + } + } + + @Test("no catalog entry is dead weight") + func noOrphanedKeys() throws { + let requested = try LocalizationSourceScanner.requestedKeys(in: Self.sourcesDirectory) + let orphaned = try LocalizationCatalogTests.table("en").keys + .filter { !requested.contains($0) } + .sorted() + #expect( + orphaned.isEmpty, + "\(orphaned.count) key(s) have no L(…) call site, so a translator is spending effort on copy nobody can see: \(orphaned.prefix(10))" + ) + } + + // MARK: - The scanner's own rules + // + // The guard above is only as good as these: a scanner that quietly stops + // recognising a call site, or starts treating every literal as exempt, + // passes the repository forever while guarding nothing. + + @Test("a bare literal at a user-facing call site is reported") + func flagsBareLiterals() { + let source = """ + struct V: View { + var body: some View { + Text("Second row") + Toggle("Notify me", isOn: $flag) + Button("Reconnect") { } + } + } + """ + let findings = LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + #expect(findings.map(\.literal) == ["Second row", "Notify me", "Reconnect"]) + } + + @Test("the same strings routed through L(…) are not reported") + func acceptsRoutedLiterals() { + let source = """ + struct V: View { + var body: some View { + Text(L("Second row")) + Toggle(L("Notify me"), isOn: $flag) + Button(L("Reconnect")) { } + } + } + """ + #expect(LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift").isEmpty) + } + + @Test("an argument on the next line is still seen") + func findsLiteralsAcrossLineBreaks() { + let source = """ + Text( + "Update Available" + ) + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Update Available"] + ) + } + + @Test("accessibility copy counts as user-facing") + func flagsAccessibilityCopy() { + let source = """ + view.accessibilityLabel("Capacity Dock") + .accessibilityHint("Click to keep it expanded") + .accessibilityAction(named: "Show weekly usage", switchWindow) + """ + #expect(LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift").count == 3) + } + + @Test("one string is reported once, not once per overlapping call site") + func doesNotDoubleReport() { + // `Label(` is a substring of `.accessibilityLabel(` and `Button(` of + // `addButton(withTitle:`; both used to match twice and report the same + // literal as two separate violations. + let source = """ + view.accessibilityLabel("Capacity Dock") + alert.addButton(withTitle: "Try Again") + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Capacity Dock", "Try Again"] + ) + } + + @Test("AppKit menu items, alerts and window titles count too") + func flagsAppKitSurfaces() { + let source = """ + let item = NSMenuItem(title: "Refresh Now", action: nil, keyEquivalent: "") + alert.messageText = "Up to Date" + alert.addButton(withTitle: "OK") + window.title = "CodeBurn Settings" + """ + // "CodeBurn Settings" still carries the translatable word "Settings". + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Refresh Now", "Up to Date", "OK", "CodeBurn Settings"] + ) + } + + @Test("figures, symbols and empty placeholders are not translation failures") + func ignoresNonWords() { + let source = """ + Text("\\(model.calls)") + Text("$25") + Text("1M") + Text("—") + Text("\\(Int(scale * 100))%") + TextField("", text: $rate) + Text("\\(title) \\(provider.displayName)") + """ + #expect(LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift").isEmpty) + } + + @Test("the product name, units and currency codes are exempt, and only those") + func honoursTheUntranslatableVocabulary() { + let exempt = """ + Text("CodeBurn") + Text("USD") + Text("\\(compact(tokens)) tok") + """ + #expect(LocalizationSourceScanner.unroutedLiterals(inSource: exempt, fileName: "V.swift").isEmpty) + + // A sentence that merely contains one of those words is still copy. + let copy = """ + Text("CodeBurn could not check for updates.") + """ + #expect(LocalizationSourceScanner.unroutedLiterals(inSource: copy, fileName: "V.swift").count == 1) + } + + @Test("a comment that mentions a call site is not a call site") + func ignoresComments() { + // Localization.swift's own documentation says `Text("literal")`. + let source = """ + /// The implicit path that SwiftUI uses for `Text("literal")` always misses. + // Text("also not real") + /* Text("nor this") */ + let real = Text("Refresh Now") + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Refresh Now"] + ) + } + + @Test("a URL inside a literal does not read as a comment") + func survivesURLsInLiterals() { + let source = """ + let endpoint = "https://api.github.com/copilot_internal/user" + Text("Reconnect required") + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Reconnect required"] + ) + } + + @Test("a bare enum display label is reported, a routed one is not") + func flagsBareDisplayLabels() { + let bare = """ + enum Metric { + var settingsLabel: String { + switch self { + case .quotaRemaining: "Quota remaining" + case .todayCost: "Today's cost" + } + } + } + """ + #expect( + LocalizationSourceScanner.unroutedLabelProperties(inSource: bare, fileName: "M.swift") + .map(\.literal) == ["Quota remaining", "Today's cost"] + ) + + let routed = """ + enum Metric { + var settingsLabel: String { + switch self { + case .quotaRemaining: L("Quota remaining") + case .todayCost: L("Today's cost") + } + } + } + """ + #expect( + LocalizationSourceScanner.unroutedLabelProperties(inSource: routed, fileName: "M.swift").isEmpty + ) + } + + @Test("a literal passed to machinery inside a label property is not copy") + func ignoresArgumentLiteralsInLabelProperties() { + // CodexUsage's credit label builds a formatter before it builds a + // sentence; the locale id and the separators are not translatable copy. + let source = """ + struct CreditLimit { + var displayLabel: String { + formatter.locale = Locale(identifier: "en_US") + let raw = plan.replacingOccurrences(of: "_", with: " ") + return L("Monthly usage limit") + } + } + """ + #expect( + LocalizationSourceScanner.unroutedLabelProperties(inSource: source, fileName: "C.swift").isEmpty + ) + } + + @Test("the label denylist is keyed to the declaring type, not the bare name") + func labelDenylistIsQualified() { + // Plan names are verbatim in every locale, so PlanType.displayName is exempt… + let exempt = """ + enum PlanType { + var displayName: String { + switch self { + case .pro: "Pro" + case .team: "Team" + } + } + } + """ + #expect( + LocalizationSourceScanner.unroutedLabelProperties(inSource: exempt, fileName: "P.swift").isEmpty + ) + + // …while the same property name on another type is still guarded. + let guarded = """ + enum AccentPreset { + var displayName: String { + switch self { + case .flame: "Flame orange" + } + } + } + """ + #expect( + LocalizationSourceScanner.unroutedLabelProperties(inSource: guarded, fileName: "A.swift") + .map(\.literal) == ["Flame orange"] + ) + } + + @Test("L(…) keys are collected, and only from the localization function") + func collectsRequestedKeys() { + let source = """ + let a = L("Refresh Now") + let b = L("%lld sessions", count) + let c = URL(string: "https://example.com") + let d = someL("not the catalog") + let e = model.L("nor this") + """ + #expect( + LocalizationSourceScanner.requestedKeys(inSource: source) == ["Refresh Now", "%lld sessions"] + ) + } + + @Test("an escaped key is collected as the text the catalog stores") + func unescapesRequestedKeys() { + // CodeBurnApp's update alert carries newlines in its key. + let source = #"let a = L("%@ Run:\n\ncodeburn menubar --force")"# + #expect( + LocalizationSourceScanner.requestedKeys(inSource: source) + == ["%@ Run:\n\ncodeburn menubar --force"] + ) + } + + @Test("findings name the file and line so the failure is actionable") + func reportsLocation() { + let source = """ + struct V: View { + var body: some View { + Text("Second row") + } + } + """ + let finding = LocalizationSourceScanner + .unroutedLiterals(inSource: source, fileName: "SettingsView.swift") + .first + #expect(finding?.file == "SettingsView.swift") + #expect(finding?.line == 3) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift b/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift new file mode 100644 index 000000000..160679596 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift @@ -0,0 +1,573 @@ +import Foundation + +/// Scans `mac/Sources` for user-facing string literals that never reach the +/// `Localizable.strings` catalog (#1219). +/// +/// # Why this exists +/// +/// `LocalizationCatalogTests` diffs the `en` and `zh-Hans` tables against each +/// other. That catches a key translated in one locale and not the other, but it +/// is blind to the failure that actually happens: a new feature ships a bare +/// `Text("Second row")`, the literal never becomes a key, both tables stay in +/// perfect agreement, and a zh-Hans build renders English. That is exactly how +/// #1252 and #1243 landed — neither PR's diff contained a single `L(` call. +/// +/// So this scanner reads the source instead of the catalog: it finds the call +/// sites whose argument AppKit or SwiftUI puts on screen and fails when one of +/// them is handed a literal rather than `L(...)`. +/// +/// # Kept out of the app target on purpose +/// +/// Nothing here runs in the shipped binary — it reads `.swift` files off disk, +/// which only makes sense from the test bundle. It lives in its own file rather +/// than inside the test so the standalone `swiftc` harness (this host cannot run +/// `swift test`) can exercise the same code the suite does, instead of a +/// reimplementation that could drift from it. +enum LocalizationSourceScanner { + + // MARK: - What counts as user-facing + + /// A call site whose string argument is rendered to the user. + /// + /// Each entry is matched as a literal prefix immediately followed by the + /// argument, so `Text(L("…"))` never matches and `Text("…")` always does. + /// Adding a new SwiftUI or AppKit surface that displays a string means + /// adding it here — the list is the definition of "user-facing", and a + /// surface missing from it is a hole in the guard. + static let userFacingCallSites: [String] = [ + // SwiftUI views whose first argument is the visible title. + "Text(", + "Button(", + "Toggle(", + "Picker(", + "Menu(", + "Section(", + "Label(", + "TextField(", + "SecureField(", + "Stepper(", + "Link(", + // SwiftUI accessibility, which VoiceOver reads aloud. + ".accessibilityLabel(", + ".accessibilityValue(", + ".accessibilityHint(", + ".accessibilityAction(named:", + // Tooltips and window/navigation chrome. + ".help(", + ".navigationTitle(", + // AppKit: the status-item menu, alerts, and window titles. + "NSMenuItem(title:", + ".messageText =", + ".informativeText =", + "addButton(withTitle:", + ".title =", + // This app's own section-header helper. + "sectionCaption(", + ] + + /// Words that are the same in every locale, so a literal made only of them + /// is not a translation failure. + /// + /// Deliberately tiny, and deliberately a vocabulary rather than a list of + /// exempt call sites: an exemption keyed to a file and line goes stale the + /// moment the file is edited, and quietly stops guarding anything. These are + /// the three that actually occur: + /// + /// - `CodeBurn` — the product name. + /// - `tok` — the token unit, kept verbatim next to a formatted figure. + /// - `USD` — a currency code. + /// + /// Provider, model and plan names never appear here because they are never + /// literals in a view: they arrive as values and are substituted into a + /// `%@`, which is the routed form this scanner is asking for. + static let untranslatableWords: Set = ["codeburn", "tok", "usd"] + + // MARK: - Findings + + struct Finding: Equatable, CustomStringConvertible { + let file: String + let line: Int + let callSite: String + let literal: String + + var description: String { + "\(file):\(line): \(callSite)\"\(literal)\" is shown to the user but never reaches the catalog — wrap it in L(\"…\")" + } + } + + // MARK: - Scanning + + /// Every user-facing literal in `directory` that is not routed through `L(…)`. + static func unroutedLiterals(in directory: URL) throws -> [Finding] { + var findings: [Finding] = [] + for file in try swiftFiles(in: directory) { + let source = try String(contentsOf: file, encoding: .utf8) + findings += unroutedLiterals( + inSource: source, + fileName: file.lastPathComponent + ) + } + return findings.sorted { + ($0.file, $0.line, $0.literal) < ($1.file, $1.line, $1.literal) + } + } + + static func swiftFiles(in directory: URL) throws -> [URL] { + guard let walker = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil + ) else { return [] } + return walker + .compactMap { $0 as? URL } + .filter { $0.pathExtension == "swift" } + .sorted { $0.path < $1.path } + } + + /// The scan for one file's text. Split out so the rules are testable + /// against a source snippet rather than the repository. + static func unroutedLiterals(inSource source: String, fileName: String) -> [Finding] { + let code = Array(strippingComments(source)) + var findings: [Finding] = [] + + for callSite in userFacingCallSites { + let needle = Array(callSite) + var index = 0 + while index + needle.count <= code.count { + guard Array(code[index..<(index + needle.count)]) == needle, + startsAWord(needle, at: index, in: code) else { + index += 1 + continue + } + var cursor = index + needle.count + // The argument may be on the next line; whitespace is not a + // reason to stop looking for it. + while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } + if cursor < code.count, code[cursor] == "\"", + let literal = stringLiteral(in: code, startingAt: cursor), + needsTranslation(literal.value) { + findings.append( + Finding( + file: fileName, + line: lineNumber(of: index, in: code), + callSite: callSite, + literal: literal.value + ) + ) + } + index += needle.count + } + } + // Call sites are scanned one kind at a time, so sort back into reading + // order — a failure message that jumps around the file is hard to act on. + return findings.sorted { ($0.line, $0.literal) < ($1.line, $1.literal) } + } + + // MARK: - Display-label properties + + /// Computed `String` properties this codebase uses to give an enum its + /// on-screen name — the Settings pickers render exactly these. + /// + /// They are not call sites, so the call-site scan above cannot see them: + /// `case .quotaRemaining: "Quota remaining"` is just a string returned from + /// a switch. Yet a new `MenubarSecondRowMetric` case is precisely how the + /// next untranslated picker option would arrive, so the property bodies get + /// their own pass. + static let displayLabelProperties: [String] = [ + "displayName", + "displayLabel", + "settingsLabel", + ] + + /// Properties that legitimately return untranslated text, with the reason. + /// + /// This is the documented denylist. It names the declaring type as well as + /// the property so that adding a `displayName` elsewhere is still guarded. + /// + /// - `CapacityDockGlanceWindowKind.displayName` — a stand-in for a provider's + /// own window label ("Weekly", "5-hour"), used only when the provider + /// publishes none. Those labels are provider data and are never + /// translated, so translating the fallback alone would make the same + /// VoiceOver sentence half-Chinese depending on which provider is + /// selected. + /// - `CapacityDockProvider.displayName` and `CodexUsage.*.displayName` — + /// provider, model and plan names, which the catalog header lists as + /// verbatim in every locale. + static let untranslatedLabelProperties: Set = [ + "CapacityDockGlanceWindowKind.displayName", + "CapacityDockProvider.displayName", + "CapacityDockProviderCatalogEntry.displayName", + "PlanType.displayName", + "Tier.displayName", + ] + + /// Bare literals returned from a display-label property. + static func unroutedLabelProperties(in directory: URL) throws -> [Finding] { + var findings: [Finding] = [] + for file in try swiftFiles(in: directory) { + let source = try String(contentsOf: file, encoding: .utf8) + findings += unroutedLabelProperties( + inSource: source, + fileName: file.lastPathComponent + ) + } + return findings.sorted { + ($0.file, $0.line, $0.literal) < ($1.file, $1.line, $1.literal) + } + } + + static func unroutedLabelProperties(inSource source: String, fileName: String) -> [Finding] { + let code = Array(strippingComments(source)) + var findings: [Finding] = [] + + for property in displayLabelProperties { + let needle = Array("var \(property): String") + var index = 0 + while index + needle.count <= code.count { + guard Array(code[index..<(index + needle.count)]) == needle else { + index += 1 + continue + } + let owner = enclosingTypeName(before: index, in: code) + let qualified = "\(owner).\(property)" + index += needle.count + guard !untranslatedLabelProperties.contains(qualified) else { continue } + guard let body = propertyBody(in: code, after: index) else { continue } + for literal in valuePositionLiterals(in: Array(code[body])) where needsTranslation(literal.value) { + findings.append( + Finding( + file: fileName, + line: lineNumber(of: body.lowerBound + literal.offset, in: code), + callSite: "\(qualified): ", + literal: literal.value + ) + ) + } + } + } + return findings.sorted { ($0.line, $0.literal) < ($1.line, $1.literal) } + } + + /// The brace-balanced body that follows a property declaration. + static func propertyBody(in code: [Character], after index: Int) -> Range? { + var i = index + while i < code.count, code[i] != "{" { + // A declaration and its body are separated by whitespace only; a + // computed property written with `=` is a stored one, not ours. + if !code[i].isWhitespace { return nil } + i += 1 + } + guard i < code.count else { return nil } + let start = i + 1 + var depth = 0 + while i < code.count { + if code[i] == "{" { depth += 1 } + if code[i] == "}" { + depth -= 1 + if depth == 0 { return start.. [(value: String, offset: Int)] { + var found: [(value: String, offset: Int)] = [] + var depth = 0 + var i = 0 + while i < code.count { + switch code[i] { + case "(", "[": + depth += 1 + case ")", "]": + depth -= 1 + case "\"": + guard let literal = stringLiteral(in: code, startingAt: i) else { break } + if depth == 0 { found.append((value: literal.value, offset: i)) } + i = literal.end + continue + default: + break + } + i += 1 + } + return found + } + + /// The nearest `enum`/`struct`/`class`/`extension` name declared above + /// `index`, so a denylist entry can name the type it exempts. + static func enclosingTypeName(before index: Int, in code: [Character]) -> String { + let keywords = ["enum ", "struct ", "final class ", "class ", "extension "].map(Array.init) + var best = "?" + var i = 0 + // One pass in source order, so the *nearest* preceding declaration wins. + // Scanning keyword-by-keyword instead would make the answer depend on + // the order of the keyword list: `enum PlanType` nested inside + // `struct CodexUsage` would report the outer type and quietly miss its + // denylist entry. + while i < min(index, code.count) { + for needle in keywords where i + needle.count <= code.count { + guard Array(code[i..<(i + needle.count)]) == needle, + startsAWord(needle, at: i, in: code) else { continue } + var j = i + needle.count + var name = "" + while j < code.count, code[j].isLetter || code[j].isNumber || code[j] == "_" { + name.append(code[j]) + j += 1 + } + if !name.isEmpty { best = name } + } + i += 1 + } + return best + } + + // MARK: - Keys the code asks for + + /// Every key passed to `L(…)` anywhere under `directory`. + /// + /// The other direction of the same guard: `unroutedLiterals` catches copy + /// that never became a key, this catches a key that never became an entry. + /// Both ship English in a zh-Hans build, and neither is visible to the + /// compiler or to a catalog-versus-catalog diff. + static func requestedKeys(in directory: URL) throws -> Set { + var keys: Set = [] + for file in try swiftFiles(in: directory) { + let source = try String(contentsOf: file, encoding: .utf8) + keys.formUnion(requestedKeys(inSource: source)) + } + return keys + } + + static func requestedKeys(inSource source: String) -> Set { + let chars = Array(strippingComments(source)) + var keys: Set = [] + var i = 0 + while i < chars.count { + defer { i += 1 } + guard chars[i] == "L" else { continue } + // `L` has to be the whole identifier: `URL(`, `someL(` and `a.L(` + // are not the localization function. + if i > 0 { + let previous = chars[i - 1] + if previous.isLetter || previous.isNumber || previous == "_" || previous == "." { + continue + } + } + var cursor = i + 1 + guard cursor < chars.count, chars[cursor] == "(" else { continue } + cursor += 1 + while cursor < chars.count, chars[cursor].isWhitespace { cursor += 1 } + guard cursor < chars.count, chars[cursor] == "\"", + let literal = stringLiteral(in: chars, startingAt: cursor) else { continue } + // The catalog stores the unescaped text, which is what NSBundle + // matches against, so undo the escapes the source carries. + keys.insert(unescaped(literal.value)) + } + return keys + } + + /// Turns a source-level literal body into the string it denotes. Only the + /// escapes the catalog actually uses are handled; an interpolated key would + /// not be a constant key at all, so it is left alone and will simply fail to + /// match an entry. + static func unescaped(_ literal: String) -> String { + var out = "" + let chars = Array(literal) + var i = 0 + while i < chars.count { + guard chars[i] == "\\", i + 1 < chars.count else { + out.append(chars[i]) + i += 1 + continue + } + switch chars[i + 1] { + case "n": out.append("\n") + case "t": out.append("\t") + case "r": out.append("\r") + case "\"": out.append("\"") + case "'": out.append("'") + case "\\": out.append("\\") + default: + out.append(chars[i]) + out.append(chars[i + 1]) + } + i += 2 + } + return out + } + + /// Whether a match is the start of the call it names rather than the tail of + /// a longer identifier. + /// + /// Without this `Label(` matches inside `.accessibilityLabel(`, and + /// `Button(` inside `addButton(withTitle:`, reporting one string twice. A + /// type name (`Text`, `Label`, `NSMenuItem`) is also rejected after a dot, + /// since that is a member access; a method (`addButton`, `sectionCaption`) + /// is not, because a dot is exactly how it is normally called. + static func startsAWord(_ needle: [Character], at index: Int, in code: [Character]) -> Bool { + guard index > 0, let first = needle.first else { return true } + // A needle written as a member (`.accessibilityLabel(`) carries its own + // boundary: the dot can only follow the receiver. + guard first.isLetter else { return true } + let previous = code[index - 1] + if previous.isLetter || previous.isNumber || previous == "_" { return false } + if previous == ".", first.isUppercase { return false } + return true + } + + // MARK: - Rules + + /// Whether a literal carries words a translator would have to translate. + /// + /// Interpolated segments are dropped first: `"\(count) calls"` is asking + /// about the word `calls`, not about `count`. What is left is then reduced + /// to runs of two or more letters, so a figure, a currency symbol, a unit + /// suffix like `1M`, an em dash or an empty placeholder is never reported. + static func needsTranslation(_ literal: String) -> Bool { + !translatableWords(in: literal).isEmpty + } + + static func translatableWords(in literal: String) -> [String] { + withoutInterpolations(literal) + .split(whereSeparator: { !$0.isLetter }) + .map { $0.lowercased() } + .filter { $0.count >= 2 && !untranslatableWords.contains($0) } + } + + /// Removes `\(…)` segments, brace-counting so a nested call such as + /// `\(f(x))` is dropped whole rather than leaving a stray `)`. + static func withoutInterpolations(_ literal: String) -> String { + var out = "" + let chars = Array(literal) + var i = 0 + while i < chars.count { + if chars[i] == "\\", i + 1 < chars.count, chars[i + 1] == "(" { + var depth = 0 + var j = i + 1 + while j < chars.count { + if chars[j] == "(" { depth += 1 } + if chars[j] == ")" { + depth -= 1 + if depth == 0 { j += 1; break } + } + j += 1 + } + i = j + continue + } + out.append(chars[i]) + i += 1 + } + return out + } + + // MARK: - Lexing + + /// Replaces comment bodies with spaces, keeping newlines so reported line + /// numbers still match the file. Without this a doc comment that *mentions* + /// `Text("literal")` — Localization.swift has one — reads as a violation. + static func strippingComments(_ source: String) -> String { + var out = "" + let chars = Array(source) + var i = 0 + while i < chars.count { + // A string literal can contain "//" (a URL), so strings win. + if chars[i] == "\"" { + if let literal = stringLiteral(in: chars, startingAt: i) { + out += String(chars[i.. (value: String, end: Int)? { + guard start < chars.count, chars[start] == "\"" else { return nil } + + let isMultiline = start + 2 < chars.count + && chars[start + 1] == "\"" + && chars[start + 2] == "\"" + let delimiterLength = isMultiline ? 3 : 1 + + var value = "" + var i = start + delimiterLength + while i < chars.count { + if chars[i] == "\\" { + // Keep the backslash: `\(` has to survive for the + // interpolation stripper to recognise it. + value.append(chars[i]) + if i + 1 < chars.count { value.append(chars[i + 1]) } + i += 2 + continue + } + if chars[i] == "\"" { + if isMultiline { + if i + 2 < chars.count, chars[i + 1] == "\"", chars[i + 2] == "\"" { + return (value, i + 3) + } + } else { + return (value, i + 1) + } + } + if !isMultiline, chars[i] == "\n" { return nil } + value.append(chars[i]) + i += 1 + } + return nil + } + + static func lineNumber(of index: Int, in chars: [Character]) -> Int { + var line = 1 + var i = 0 + while i < index, i < chars.count { + if chars[i] == "\n" { line += 1 } + i += 1 + } + return line + } +} From 0f69b5516fb01a0e6c4338e33833cccefd6a59d4 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:18:09 +0300 Subject: [PATCH 6/7] feat(menubar): translate the strings a day of menubar work added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1328's banked limit resets, #1329's early quota reset detection, the consolidated notifications section, the Copilot per-rung host work and the dock's cache-read row all shipped user-facing copy with no `L(` calls. 67 new keys, 5 retired by main's rewrites and restructures: 551 keys become 613. Most of it was invisible to the guard test, which is the next commit's problem. Both features put their wording in a new pure presentation type — exactly the shape the views render as `Text(presentation.caption)` — so the call-site pass had nothing to look at. Routed here: - Notifications: the section itself plus the banked-reset and early-reset toggles and their help text. - Codex banked resets: the Plan-tab row label, the detail parts the hover card and Plan tab share, the notification title and body, and the compact-age vocabulary. - Early quota resets: both notification shapes, the dock band, the hover help text and the three history-summary sentences, plus the Claude window names and the lead formatter. - Quota pace: the captions, the projection sentences and the window-length and countdown labels. The countdown reuses the popover's own `%lldd %lldh` / `%lldh %lldm` / `%lldm` keys so the two can never disagree. - Copilot: the host-aware dormant and rejection messages, the new host field. - Capacity Dock: the cache-read row and the window/reset accessibility labels. Two notes on judgement calls: `CodexBankedResetPresentation.detail` and `compactAge` carry a documented character-identical mirror in `src/quota/codex.ts`. Routing them keeps that mirror exact — `en` is an identity table, so they emit byte-for-byte what they did before — while letting a zh-Hans menubar say it in Chinese. The CLI stays English-only, so the two diverge in a translated build exactly as every other menubar string already does. The doc comments now say so. `EarlyQuotaResetFormat.windowNoun` trims " limit" and `capitalizedFirst` upper-cases: English morphology that degrades to the identity in Chinese. Left that way deliberately — the trim exists to avoid "weekly limit resets" in English, a repetition Chinese does not produce — rather than keying a second catalog entry per window purely to shorten a noun. Both are commented. Argument order is preserved in every multi-argument key, including the history summary, where the natural Chinese phrasing wanted to reorder two counts: `String(format:)` binds positionally, so the parity test would have caught it, but the reordered sentence would have been wrong in a way no test can see. Verified with the standalone swiftc harness (`swift test` cannot run here) and `swift build`. --- CHANGELOG.md | 2 +- .../Data/CodexBankedResets.swift | 52 ++++++----- .../Data/CopilotQuotaPresentation.swift | 10 ++- .../Data/EarlyQuotaReset.swift | 66 ++++++++------ .../Data/QuotaPacePresentation.swift | 61 +++++++------ .../Resources/en.lproj/Localizable.strings | 89 +++++++++++++++++-- .../zh-Hans.lproj/Localizable.strings | 89 +++++++++++++++++-- .../CodeBurnMenubar/Views/AgentTabStrip.swift | 2 +- .../Views/CapacityDockView.swift | 8 +- .../CodeBurnMenubar/Views/SettingsView.swift | 8 +- 10 files changed, 289 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4745f82eb..72822ce0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - **The Capacity Dock shows today's cache-read tokens and tells you whether each quota window will last to its reset.** The Today section gains a provider-scoped cache-read figure beside input, output and calls; a known zero prints as `0` while missing or incomplete historical accounting stays unknown rather than becoming a fabricated zero, and because cache reads were already priced into the burned figure this adds visibility without changing any total. Each quota window then gets one line under it: `Lasts until reset`, `Runs out in 2d 8h`, or — on windows of six hours or less, where one burst would make a linear ETA cry wolf — the pace stage the Plan tab uses (`On pace`, `40% in deficit`, `30% in reserve`). The same line appears under each bar in the agent-tab quota hover card. The projection runs against the window length the provider adapter reports, never a length guessed from the display label, so a monthly cycle whose label happens to read `Weekly` is still paced against its month; it stays silent early in a window, on an exhausted window, without a reset time or a validated duration, and on stale, disconnected or older-than-ten-minute data. Four quota windows move to a two-column grid so scope labels, reset times and captions stay readable, and the dock reserves the caption's height whether or not a column has one so the bubble cannot resize under the pointer. Status snapshot revision 7 invalidates older cached payloads without purging daily history, and no extra polling is introduced. (#1267) - **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252) - **The Capacity Dock gauge can report the short usage window instead of the weekly one, without expanding the dock.** The resting rail shows one number per provider, and that number was always the weekly (else monthly) billing window. Clicking the provider already resting in the rail now switches its gauge to the provider's short rolling window — Claude's 5-hour limit, Codex's 5-hour or daily window, any `Hourly`, `Daily` or session row an adapter reports — and clicking again switches back. The choice is stored per provider under `CodeBurnCapacityDockGlanceWindows`, so Claude can sit on its 5-hour window while Codex stays weekly, and it survives relaunch. A per-model row such as `Weekly · Opus` is never read as a short window, a provider that reports only one window keeps the plain click-to-pin behaviour, and a stored horizon the provider stops reporting falls back to the window it does report rather than blanking the gauge to `--`. The rail's geometry is untouched, VoiceOver and keyboard users get the switch as a named action on the provider cell with the window named in the cell's value, and Escape or a click outside still unpins the dock. (#1243) -- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 551 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages, and `CFBundleLocalizations` in both packaging scripts puts CodeBurn under System Settings > Language & Region so the language can be overridden for this app alone. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own symbols, so a Chinese UI reads `2026年9月` and `一 二 三` rather than `September 2026` and `Mo Tu We`. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219) +- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 613 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages, and `CFBundleLocalizations` in both packaging scripts puts CodeBurn under System Settings > Language & Region so the language can be overridden for this app alone. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own symbols, so a Chinese UI reads `2026年9月` and `一 二 三` rather than `September 2026` and `Mo Tu We`. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219) ### Fixed - **The menubar second row's quota line now names the provider the flame is warning about.** "Quota remaining" picked each provider's *headline* window — weekly, else monthly, and only then the busiest one — which is the Capacity Dock's billing horizon, not a warning: on a machine with Cursor's API window at 100% and Claude's 5-hour window at 18%, the row reported Monthly 9.5% and Weekly 5% and read as if nothing were near a limit. Each provider now contributes its worst window, per-model rows included, which is exactly what the menu-bar flame already tints by, so the two surfaces agree. A provider backing off after a failed fetch keeps its last-known window instead of dropping out, so the row no longer vanishes — resizing the status item — for the length of a retry, matching what the dock shows dimmed. The row is also capped at 24 characters, shortening a long provider name ("GitHub Copilot 12% left · 6d 3h") rather than letting the second line more than double the item's width, and the two-line title now carries a VoiceOver label that reads as one phrase instead of a string with a newline in it. With the setting off nothing changed: the snapshot that feeds the row is no longer even built on a refresh, and the title is the single-row composition it has always been. (#1310) diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift b/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift index a5d34a8f1..5daeb3e17 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift @@ -102,29 +102,35 @@ enum CodexBankedResetDetector { /// The one place the banked-reset wording lives. `src/quota/codex.ts` carries a /// line-for-line mirror of `detail` and `compactAge` so the CLI and the menubar /// say the same sentence. +/// +/// Every piece now resolves through the catalog (#1219). That keeps the mirror +/// exact in English — `en` is an identity table, so these produce byte-for-byte +/// what they produced before and what `codex.ts` still produces — while letting +/// a zh-Hans menubar say it in Chinese. The CLI stays English-only, so the two +/// diverge in a translated build exactly as every other menubar string does. enum CodexBankedResetPresentation { /// Label the detail hangs off, in the Plan tab and in front of the detail on /// every other surface. - static let rowLabel = "Limit resets" + static var rowLabel: String { L("Limit resets") } /// `2 available · 1 usable now · latest weekly reset granted 2h ago · next expires in 16h` /// Nil when the account holds nothing — the row hides rather than printing a zero. static func detail(_ credits: CodexUsage.ResetCredits, now: Date) -> String? { guard credits.availableCount > 0 else { return nil } - var parts = ["\(credits.availableCount) available"] + var parts = [L("%lld available", credits.availableCount)] // Only worth saying when it disagrees with the headline count: equal // numbers would just be the same fact twice. if let applicable = credits.applicableAvailableCount, applicable != credits.availableCount { - parts.append("\(applicable) usable now") + parts.append(L("%lld usable now", applicable)) } if let grant = credits.latestGrant, let grantedAt = grant.grantedAt { let type = resetTypeLabel(grant.resetType) parts.append(grantedAt > now - ? "next \(type) lands \(compactAge(of: grantedAt, now: now))" - : "latest \(type) granted \(compactAge(of: grantedAt, now: now))") + ? L("next %@ lands %@", type, compactAge(of: grantedAt, now: now)) + : L("latest %@ granted %@", type, compactAge(of: grantedAt, now: now))) } if let expiry = credits.nextExpiresAt { - parts.append("next expires \(compactAge(of: expiry, now: now))") + parts.append(L("next expires %@", compactAge(of: expiry, now: now))) } return parts.joined(separator: " · ") } @@ -147,16 +153,16 @@ enum CodexBankedResetPresentation { var body: String if let grantedAt = grant.grantedAt { body = grantedAt > now - ? "A \(type) lands \(compactAge(of: grantedAt, now: now))." - : "A \(type) was added to your account \(compactAge(of: grantedAt, now: now))." + ? L("A %@ lands %@.", type, compactAge(of: grantedAt, now: now)) + : L("A %@ was added to your account %@.", type, compactAge(of: grantedAt, now: now)) } else { - body = "A \(type) was added to your account." + body = L("A %@ was added to your account.", type) } // `applicable_available_count` is the number that answers "can I use one // right now"; the plain count is the fallback when it is absent. let usable = credits.applicableAvailableCount ?? credits.availableCount - body += " You have \(usable) available to use." - return ("Codex banked a limit reset", body) + body += " " + L("You have %lld available to use.", usable) + return (L("Codex banked a limit reset"), body) } /// "weekly" -> "weekly reset". An absent or empty `reset_type` degrades to @@ -167,25 +173,27 @@ enum CodexBankedResetPresentation { .replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ") .lowercased() - return raw.isEmpty ? "limit reset" : "\(raw) reset" + return raw.isEmpty ? L("limit reset") : L("%@ reset", raw) } - /// Deliberately not `RelativeDateTimeFormatter`: this string has to come out + /// Deliberately not `RelativeDateTimeFormatter`: the thresholds have to be /// character-identical in Swift and in TypeScript, so the rules are spelled - /// out instead of delegated to a locale-aware formatter. + /// out instead of delegated to a locale-aware formatter. Only the rendered + /// unit goes through the catalog, which leaves the English identical and + /// the branch points shared. static func compactAge(of date: Date, now: Date) -> String { let elapsed = now.timeIntervalSince(date) if elapsed >= 0 { - if elapsed < 60 { return "just now" } - if elapsed < 3600 { return "\(Int(elapsed / 60))m ago" } - if elapsed < 86_400 { return "\(Int(elapsed / 3600))h ago" } - return "\(Int(elapsed / 86_400))d ago" + if elapsed < 60 { return L("just now") } + if elapsed < 3600 { return L("%lldm ago", Int(elapsed / 60)) } + if elapsed < 86_400 { return L("%lldh ago", Int(elapsed / 3600)) } + return L("%lldd ago", Int(elapsed / 86_400)) } let ahead = -elapsed - if ahead < 60 { return "in under a minute" } - if ahead < 3600 { return "in \(Int(ahead / 60))m" } - if ahead < 86_400 { return "in \(Int(ahead / 3600))h" } - return "in \(Int(ahead / 86_400))d" + if ahead < 60 { return L("in under a minute") } + if ahead < 3600 { return L("in %lldm", Int(ahead / 60)) } + if ahead < 86_400 { return L("in %lldh", Int(ahead / 3600)) } + return L("in %lldd", Int(ahead / 86_400)) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift index 7cb6b50c7..3e739aef9 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift @@ -91,9 +91,9 @@ enum CopilotQuotaPresentation { /// previous snapshot already proved which host answers. static func dormantSettingsDetail(apiHost: String?) -> String { guard let apiHost, !apiHost.isEmpty, apiHost != CopilotHostEndpoint.defaultAPIHost else { - return "Tap Load Quota to fetch live usage from GitHub." + return L("Tap Load Quota to fetch live usage from GitHub.") } - return "Tap Load Quota to fetch live usage from \(apiHost)." + return L("Tap Load Quota to fetch live usage from %@.", apiHost) } /// Why a host typed next to the pasted token cannot be used, or nil when @@ -103,8 +103,10 @@ enum CopilotQuotaPresentation { static func pastedHostRejection(_ raw: String) -> String? { let host = CopilotHostEndpoint.normalize(raw) ?? CopilotHostEndpoint.defaultHost guard CopilotHostEndpoint.apiHost(for: host) == nil else { return nil } - return "CodeBurn cannot read Copilot quota for \(host). " - + "Use github.com or a GitHub Enterprise Cloud host (*.ghe.com)." + return L( + "CodeBurn cannot read Copilot quota for %@. Use github.com or a GitHub Enterprise Cloud host (*.ghe.com).", + host + ) } /// Snapshot age past which a loaded view stamps an "as of