From d62857ea4e5528c81b09d054535553473221e080 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] 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 | 54 +- .../Views/FindingsSection.swift | 28 +- .../Views/HeatmapSection.swift | 319 ++++----- .../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 | 580 ++++++++--------- .../Views/ToolingSection.swift | 8 +- 30 files changed, 1501 insertions(+), 695 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 4b5c0d8f..b74eb393 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 fbd20f42..96c92840 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 01855bc2..97631b06 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2978,27 +2978,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") } } } @@ -3037,6 +3039,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"] @@ -3137,6 +3145,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 { @@ -3152,6 +3173,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 { @@ -3168,12 +3202,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 7004dba4..ebd6a4e2 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -1357,7 +1357,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM let menubarPeriod = store.menubarPeriod 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)" } @@ -1438,7 +1443,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 @@ -1635,29 +1640,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) @@ -1698,9 +1703,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? @@ -1736,7 +1741,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 @@ -1775,24 +1780,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 76a97572..a44db83a 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 d49331f5..56189446 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 0095c6c1..e610a201 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 b6fa69fd..a528954d 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 8abfeab9..9a5cb265 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -118,11 +118,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 } @@ -142,13 +147,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 5f568e0d..bda03fb2 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 4125531c..21e07a9e 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)" } @@ -203,11 +203,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 } @@ -290,7 +290,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() @@ -302,7 +306,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 } @@ -357,7 +361,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 @@ -393,7 +401,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 @@ -433,8 +441,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 ddc47ab8..7f92fdfc 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 00000000..70798708 --- /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 0ffe6bb9..2e289802 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 00000000..e7ade851 --- /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 7712bd55..9ac87e05 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 0c89855a..be5e6df5 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 9e0444e9..31aaf519 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 31897175..52d97166 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 3c0e5641..cf6b9f19 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 { @@ -358,7 +358,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: @@ -371,9 +371,9 @@ 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.") } } @@ -387,14 +387,14 @@ private struct QuotaDetailPopover: View { ) return 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 d26b75f6..a723141d 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -504,13 +504,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) @@ -518,7 +518,7 @@ struct CapacityDockView: View { .onEnded { _ in onDragEnded() } ) .accessibilityElement(children: .contain) - .accessibilityLabel("Capacity Dock") + .accessibilityLabel(L("Capacity Dock")) } private var contentAlignment: Alignment { @@ -604,7 +604,7 @@ private struct CapacityDockProviderRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel("\(provider.displayName) usage") + .accessibilityLabel(L("%@ usage", provider.displayName)) .accessibilityValue(accessibilityValue) .accessibilityHint(accessibilityHint) // The gauge is the switch, and a click is a pointer gesture. Keyboard @@ -624,21 +624,21 @@ private struct CapacityDockProviderRow: View { /// whichever window feeds it, so the window is the part a screen reader /// has to say out loud. private var accessibilityValue: String { - guard let headline else { return "Unknown" } + guard let headline else { return L("Unknown") } return "\(headline.label) \(headline.percentLabel)" } private var accessibilityHint: String { isSwitchable - ? "Click to switch between this provider's usage windows" - : "Click to keep Capacity Dock expanded" + ? L("Click to switch between this provider's usage windows") + : L("Click to keep Capacity Dock expanded") } private var switchActionName: String { let next = CapacityDockGlanceWindow.next(after: glanceWindow, quota: quota) let label = CapacityDockGlanceWindow.window(next, quota: quota)?.label ?? next.displayName - return "Show \(label) usage" + return L("Show %@ usage", label) } private var headlinePercentColor: Color { @@ -944,7 +944,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) { @@ -966,9 +966,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) } } @@ -1004,7 +1004,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)) @@ -1068,14 +1068,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)) } @@ -1092,7 +1092,7 @@ struct CapacityDockDetailView: View { if let cacheRead = today.cacheReadTokens { cacheReadLine(Double(cacheRead)) } - Text("\(today.calls.asThousandsSeparated()) calls") + Text(L("%@ calls", today.calls.asThousandsSeparated())) .font(.system(size: 10)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.6)) @@ -1331,7 +1331,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)) @@ -1359,26 +1359,26 @@ 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(store.quotaRefreshIsInFlight(for: provider) - ? "Last known usage · refreshing" - : "Last known usage") + ? L("Last known usage · refreshing") + : L("Last known usage")) .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 aff1e83a..8fb95e35 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 a9977301..39f84231 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) } @@ -2261,30 +2268,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...")) } } } @@ -2299,14 +2306,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 @@ -2314,7 +2321,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 @@ -2322,7 +2329,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() @@ -2332,12 +2339,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) } @@ -2372,30 +2379,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...")) } } } @@ -2410,7 +2417,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) } @@ -2424,12 +2431,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) } @@ -2477,26 +2484,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...")) } } } @@ -2520,12 +2527,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) } @@ -2555,30 +2562,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...")) } } } @@ -2593,7 +2600,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) } @@ -2607,12 +2614,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) } @@ -2718,25 +2725,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) } } } @@ -2768,13 +2775,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 4d78b7de..cb3f08a7 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 fe34eafd..e9ec20c4 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 4cc90d4b..4a4365d6 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 5c2307d4..54a63570 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 be7bbc4d..5824ed60 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 66f259ea..db9c6458 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() @@ -420,32 +420,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) } )) { @@ -454,23 +456,23 @@ 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) // Optional second menu-bar line. Off by default, so the status // item keeps its existing single-row figure untouched. - Toggle("Second row", isOn: Binding( + Toggle(L("Second row"), isOn: Binding( get: { store.menubarSecondRowEnabled }, set: { store.menubarSecondRowEnabled = $0 } )) if store.menubarSecondRowEnabled { - Picker("Second row shows", selection: Binding( + Picker(L("Second row shows"), selection: Binding( get: { store.menubarSecondRowMetric }, set: { store.menubarSecondRowMetric = $0 } )) { @@ -479,24 +481,24 @@ private struct GeneralSettingsTab: View { } } .pickerStyle(.menu) - Text("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.") + Text(L("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.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } - 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 } )) { @@ -505,48 +507,48 @@ 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("Notifications") { - Toggle("Notify me about updates", isOn: $notifyAboutUpdates) - Text("Posts a notification when a new CodeBurn release is available. Click it to install.") + Section(L("Notifications")) { + 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) - Toggle("Notify me when Codex banks a limit reset", isOn: $notifyAboutBankedResets) - Text("OpenAI sometimes grants Codex accounts a credit that resets a rate-limit window early. CodeBurn reads these from the quota response it already fetches and tells you once per grant. It never spends one.") + Toggle(L("Notify me when Codex banks a limit reset"), isOn: $notifyAboutBankedResets) + Text(L("OpenAI sometimes grants Codex accounts a credit that resets a rate-limit window early. CodeBurn reads these from the quota response it already fetches and tells you once per grant. It never spends one.")) .font(.system(size: 11)) .foregroundStyle(.secondary) - Toggle("Notify me when a quota resets early", isOn: $notifyAboutEarlyResets) - Text("Posts a notification when a provider resets a usage limit before its scheduled time, so you know the capacity is back. The Capacity Dock shows the same notice for 12 hours either way.") + Toggle(L("Notify me when a quota resets early"), isOn: $notifyAboutEarlyResets) + Text(L("Posts a notification when a provider resets a usage limit before its scheduled time, so you know the capacity is back. The Capacity Dock shows the same notice for 12 hours either way.")) .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 { @@ -558,26 +560,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 { @@ -589,18 +591,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() } @@ -657,14 +659,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 @@ -680,7 +682,7 @@ private struct CapacityDockSettingsSection: View { } HStack(spacing: 10) { - Text("Size") + Text(L("Size")) Slider( value: Binding( get: { snapshot.scale }, @@ -689,14 +691,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) } )) { @@ -706,7 +708,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) } )) { @@ -716,13 +718,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) } @@ -742,7 +744,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) } @@ -756,7 +758,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) @@ -792,20 +794,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 } )) { @@ -814,10 +816,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 { @@ -877,14 +879,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") } } @@ -892,15 +894,15 @@ private struct ClaudeConnectionRow: View { switch store.subscriptionLoadState { case .loaded: if let tier = store.subscription?.tier.displayName { - return "Plan: \(tier)" + return L("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("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 ?? "" } } @@ -909,26 +911,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) @@ -945,7 +947,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 { @@ -967,7 +969,7 @@ private struct ClaudeConfigDirsSection: View { .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Remove") + .help(L("Remove")) } } } @@ -975,7 +977,7 @@ private struct ClaudeConfigDirsSection: View { Button { addDirectory() } label: { - Label("Add Directory…", systemImage: "plus") + Label(L("Add Directory…"), systemImage: "plus") } .controlSize(.small) } @@ -987,8 +989,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 } @@ -1026,11 +1028,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) @@ -1084,14 +1086,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") } } @@ -1099,23 +1101,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 ?? "" } } @@ -1124,26 +1126,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) @@ -1156,15 +1158,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) @@ -1218,29 +1220,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 ?? "" } } @@ -1249,26 +1251,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) @@ -1281,15 +1283,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) @@ -1343,14 +1345,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") } } @@ -1358,17 +1360,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 ?? "" } } @@ -1377,26 +1379,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) @@ -1409,16 +1411,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. Every one of those carries the GitHub host it belongs to — the plugin files are keyed by host, the variables are read with GH_HOST, the `gh` login with the host in gh's own hosts.yml, and a pasted token with the host you type beside it — and a credential for a GitHub Enterprise Cloud host is queried on that tenant's own API (api..ghe.com), never sent to api.github.com.") + 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. Every one of those carries the GitHub host it belongs to — the plugin files are keyed by host, the variables are read with GH_HOST, the `gh` login with the host in gh's own hosts.yml, and a pasted token with the host you type beside it — and a credential for a GitHub Enterprise Cloud host is queried on that tenant's own API (api..ghe.com), never sent to api.github.com.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { - Text("How it works") + Text(L("How it works")) } } .formStyle(.grouped) @@ -1440,14 +1442,14 @@ private struct CopilotTokenSection: View { var body: some View { Section { - SecureField("GitHub token", text: $token) - TextField("GitHub host", text: $host, prompt: Text(CopilotHostEndpoint.defaultHost)) + SecureField(L("GitHub token"), text: $token) + TextField(L("GitHub host"), text: $host, prompt: Text(CopilotHostEndpoint.defaultHost)) .disableAutocorrection(true) 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) @@ -1459,9 +1461,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. Leave the host at github.com unless the token was minted on a GitHub Enterprise Cloud tenant, in which case enter that tenant's host (.ghe.com) so the token is never sent to api.github.com. Both are saved in CodeBurn's own Keychain item and are 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. Leave the host at github.com unless the token was minted on a GitHub Enterprise Cloud tenant, in which case enter that tenant's host (.ghe.com) so the token is never sent to api.github.com. Both are saved in CodeBurn's own Keychain item and are used only to read your Copilot quota.")) .font(.system(size: 11)) } .task { await loadSavedHost() } @@ -1563,14 +1565,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") } } @@ -1582,10 +1584,10 @@ private struct CopilotConnectionRow: View { apiHost: store.copilotUsage?.apiHost ?? CopilotHostEndpoint.defaultAPIHost ) 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." + 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 CopilotQuotaPresentation.dormantSettingsDetail(apiHost: store.copilotUsage?.apiHost) case .notBootstrapped: @@ -1602,26 +1604,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) @@ -1634,15 +1636,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) @@ -1696,14 +1698,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") } } @@ -1711,19 +1713,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 ?? "" } } @@ -1732,26 +1734,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) @@ -1877,13 +1879,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) @@ -1903,15 +1905,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)) @@ -1920,7 +1922,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) } @@ -1928,13 +1930,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) @@ -1952,7 +1954,7 @@ private struct GenericProviderConnectionSections: View { if credentialIsLoading { ProgressView() .controlSize(.small) - .accessibilityLabel("Loading saved provider credential") + .accessibilityLabel(L("Loading saved provider credential")) } } @@ -1962,15 +1964,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) @@ -1989,9 +1991,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)) } } @@ -2002,22 +2004,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 store.quotaRefreshIsInFlight(for: provider) ? "Refreshing…" : "Connected" - 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 store.quotaRefreshIsInFlight(for: provider) ? L("Refreshing…") : L("Connected") + 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))" @@ -2033,7 +2035,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 { @@ -2065,8 +2067,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") } } @@ -2102,11 +2104,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") } } @@ -2160,21 +2162,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) @@ -2188,11 +2190,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) @@ -2208,7 +2210,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 { @@ -2241,8 +2243,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() } } } @@ -2251,31 +2253,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) } @@ -2301,9 +2303,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 1bcf9de7..81031bdb 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)