diff --git a/CHANGELOG.md b/CHANGELOG.md index b7aa85df3..4b7a6170a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **CodeBurn tells you when OpenAI banks a limit reset on your Codex account.** These grants — the "banked" or "goodwill" resets that restore a rate-limit window early — are sometimes announced and sometimes not, and until now the only way to notice one was to go looking. The reset-credit inventory CodeBurn already reads on every Codex quota refresh is now compared against the previous reading: a credit seen for the first time posts one notification naming what was granted, when it landed and how many you have available to use. The first reading after connecting is a baseline, not news; a credit that disappears because you spent it says nothing; a failed fetch or a malformed payload is treated as no opinion rather than as an empty account, so reconnecting does not re-announce what you were already told; and the fired event is persisted next to the existing quota snapshots, so a relaunch does not repeat it. Settings → Notifications gains a switch for it, on by default. The count and the most recent grant also appear in the Codex Plan tab, in the agent-tab quota hover card, and as a `Limit resets · …` line in `codeburn quota` (text and `--format json`), worded identically on both sides. No new endpoint, no new polling loop and no new data source: this reads fields off a response already fetched on the existing cadence, per the rule #702/#724 established. CodeBurn never spends a credit — this is a notice only. (#725) ### Added (macOS) +- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 628 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages; Settings > General > Language overrides it for CodeBurn alone by writing `AppleLanguages` into the app's own preferences domain, which is the same key `CFBundleLocalizations` makes System Settings > Language & Region > Applications write, so the two surfaces are one setting rather than two. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own short symbols clipped to two units, so a Chinese UI reads `2026年9月` and `周一 周二` while English keeps `Mo Tu We`. That locale move is the one place English output changes: `EEE MMM d` reads `Sat, Sep 12` in en_US and `Sat 12 Sep` in en_GB, and `MMM d` reads `12 Sep` in en_GB. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219) - **The menubar tells you when a vendor resets your quota early, and what that provider's early resets have looked like.** Vendors sometimes reset a usage window ahead of schedule as a goodwill gesture; CodeBurn showed the new percentage but never said it had happened, so the free capacity went unused. Two signals now catch it on the refresh lifecycle that already runs, with no new polling: a reset time that jumps to a new cycle while the stored one still had time to run, and usage emptying (a fall of at least 40 points landing at or under 10%) while the advertised reset time stands still. A system notification through the existing notifier names the provider, the window and the lead, worded for the signal that saw it ("Claude's weekly limit reset 18h early. You're back to 100%." when the cycle rolled over, "Claude cleared your weekly usage 18h before its reset. You're back to 100%." when the counter emptied but the reset time held), the Capacity Dock carries a band saying the same for twelve hours, and the quota hover card gains this Mac's own pattern from the 30 days of snapshots already on disk ("Last 3 weekly resets came ~18h early"), derived from the stored reset times with no network and no external feed. One goodwill reset is one notification per provider however many windows it empties, and a reset already announced is never announced again — after a relaunch, or when the vendor briefly serves the old cycle back. It stays silent on a normal scheduled reset, a plan change, clock or timestamp skew, a window appearing or disappearing between fetches, a window's first observation, a reconnect after a terminal failure or a fresh bootstrap, and a window whose length the adapter does not validate; a missing or malformed stored reading is no opinion rather than an event. Settings → General → Notifications turns the notification off, default on, and with it off the dock band still appears. Claude is the provider wired up today, because it is the one whose quota readings are persisted. (#725) - **The Capacity Dock shows today's cache-read tokens and tells you whether each quota window will last to its reset.** The Today section gains a provider-scoped cache-read figure beside input, output and calls; a known zero prints as `0` while missing or incomplete historical accounting stays unknown rather than becoming a fabricated zero, and because cache reads were already priced into the burned figure this adds visibility without changing any total. Each quota window then gets one line under it: `Lasts until reset`, `Runs out in 2d 8h`, or — on windows of six hours or less, where one burst would make a linear ETA cry wolf — the pace stage the Plan tab uses (`On pace`, `40% in deficit`, `30% in reserve`). The same line appears under each bar in the agent-tab quota hover card. The projection runs against the window length the provider adapter reports, never a length guessed from the display label, so a monthly cycle whose label happens to read `Weekly` is still paced against its month; it stays silent early in a window, on an exhausted window, without a reset time or a validated duration, and on stale, disconnected or older-than-ten-minute data. Four quota windows move to a two-column grid so scope labels, reset times and captions stay readable, and the dock reserves the caption's height whether or not a column has one so the bubble cannot resize under the pointer. Status snapshot revision 7 invalidates older cached payloads without purging daily history, and no extra polling is introduced. (#1267) - **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252) diff --git a/mac/Package.swift b/mac/Package.swift index 4b5c0d8f4..60ca26443 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,15 @@ 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"), + .process("Resources/zh-Hans.lproj") ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency") diff --git a/mac/README.md b/mac/README.md index 07e6b1d31..8880dbd54 100644 --- a/mac/README.md +++ b/mac/README.md @@ -8,6 +8,27 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface. - Swift 6.0+ toolchain (bundled with Xcode 16 or standalone) - `codeburn` CLI installed globally (`npm install -g codeburn`) +## Language + +The app ships English and Simplified Chinese (`zh-Hans`) and follows your system +language. Settings > General > Language overrides it for CodeBurn alone, with +System as the default; the change applies on relaunch. It writes `AppleLanguages` +into CodeBurn's own preferences domain, which is the same key System Settings > +General > Language & Region > Applications writes, so the two are one setting +rather than two. + +Strings live in `Sources/CodeBurnMenubar/Resources/.lproj/Localizable.strings` +and are reached through `L(_:)` / `L(_:_:)` (see `Localization.swift`). The key +*is* the English copy, so an untranslated string shows correct English rather +than an identifier, and `en.lproj` is an identity table. + +To add a language, copy `en.lproj` to `.lproj`, translate the values, +then add the locale in three places that must stay in step: `.process` in +`Package.swift`, `CFBundleLocalizations` in both `Scripts/package-app.sh` and +`Scripts/build-local.sh`, and `L10n.supportedLocalizations`. +`LocalizationCatalogTests` fails if they disagree, if a key is missing from +either table, if a value is blank, or if the format specifiers do not match. + ## Install (end users) One command: @@ -82,6 +103,9 @@ mac/ ├── Sources/CodeBurnMenubar/ │ ├── CodeBurnApp.swift @main + MenuBarExtra scene │ ├── AppStore.swift @Observable store + enums +│ ├── Localization.swift L(_:) lookups against the module bundle +│ ├── Resources/en.lproj/ Localizable.strings (identity table) +│ ├── Resources/zh-Hans.lproj/ Localizable.strings (简体中文) │ ├── Data/MenubarPayload.swift Codable payload types + placeholder │ ├── Theme/Theme.swift Design tokens (warm terracotta palette) │ └── Views/MenuBarContent.swift Popover layout + footer action bar diff --git a/mac/Scripts/build-local.sh b/mac/Scripts/build-local.sh index fbd20f42e..b6eab6e5c 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 @@ -121,6 +136,8 @@ cat > "${BUNDLE}/Contents/Info.plist" < CFBundleDevelopmentRegionen + + CFBundleLocalizationsenzh-Hans CFBundleDisplayNameCodeBurn Menubar CFBundleExecutable${EXE} CFBundleIconFileAppIcon diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 4cbcf7d46..6809732c5 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -80,6 +80,15 @@ cat > "${BUNDLE}/Contents/Info.plist" < CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + zh-Hans + CFBundleDisplayName CodeBurn Menubar CFBundleExecutable diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 01855bc2a..97631b06b 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 7004dba47..ebd6a4e2d 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 76a975722..a44db83ab 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/CodexBankedResets.swift b/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift index a5d34a8f1..ddbf7db6c 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift @@ -105,26 +105,26 @@ enum CodexBankedResetDetector { enum CodexBankedResetPresentation { /// Label the detail hangs off, in the Plan tab and in front of the detail on /// every other surface. - static let rowLabel = "Limit resets" + static var rowLabel: String { L("Limit resets") } /// `2 available · 1 usable now · latest weekly reset granted 2h ago · next expires in 16h` /// Nil when the account holds nothing — the row hides rather than printing a zero. static func detail(_ credits: CodexUsage.ResetCredits, now: Date) -> String? { guard credits.availableCount > 0 else { return nil } - var parts = ["\(credits.availableCount) available"] + var parts = [L("%lld available", credits.availableCount)] // Only worth saying when it disagrees with the headline count: equal // numbers would just be the same fact twice. if let applicable = credits.applicableAvailableCount, applicable != credits.availableCount { - parts.append("\(applicable) usable now") + parts.append(L("%lld usable now", applicable)) } if let grant = credits.latestGrant, let grantedAt = grant.grantedAt { let type = resetTypeLabel(grant.resetType) parts.append(grantedAt > now - ? "next \(type) lands \(compactAge(of: grantedAt, now: now))" - : "latest \(type) granted \(compactAge(of: grantedAt, now: now))") + ? L("next %@ lands %@", type, compactAge(of: grantedAt, now: now)) + : L("latest %@ granted %@", type, compactAge(of: grantedAt, now: now))) } if let expiry = credits.nextExpiresAt { - parts.append("next expires \(compactAge(of: expiry, now: now))") + parts.append(L("next expires %@", compactAge(of: expiry, now: now))) } return parts.joined(separator: " · ") } @@ -147,45 +147,59 @@ enum CodexBankedResetPresentation { var body: String if let grantedAt = grant.grantedAt { body = grantedAt > now - ? "A \(type) lands \(compactAge(of: grantedAt, now: now))." - : "A \(type) was added to your account \(compactAge(of: grantedAt, now: now))." + ? L("A %@ lands %@.", type, compactAge(of: grantedAt, now: now)) + : L("A %@ was added to your account %@.", type, compactAge(of: grantedAt, now: now)) } else { - body = "A \(type) was added to your account." + body = L("A %@ was added to your account.", type) } // `applicable_available_count` is the number that answers "can I use one // right now"; the plain count is the fallback when it is absent. let usable = credits.applicableAvailableCount ?? credits.availableCount - body += " You have \(usable) available to use." - return ("Codex banked a limit reset", body) + body += " " + L("You have %lld available to use.", usable) + return (L("Codex banked a limit reset"), body) } /// "weekly" -> "weekly reset". An absent or empty `reset_type` degrades to /// the bare noun rather than inventing a scope the payload did not state. + /// + /// `reset_type` is vendor data with no fixed vocabulary, so only the cadence + /// words OpenAI has actually sent are translated; anything else rides + /// through verbatim inside a translated frame. static func resetTypeLabel(_ resetType: String?) -> String { let raw = (resetType ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ") .lowercased() - return raw.isEmpty ? "limit reset" : "\(raw) reset" + guard !raw.isEmpty else { return L("limit reset") } + let cadence = switch raw { + case "weekly": L("weekly") + case "daily": L("daily") + case "monthly": L("monthly") + case "hourly": L("hourly") + default: raw + } + return L("%@ reset", cadence) } - /// Deliberately not `RelativeDateTimeFormatter`: this string has to come out - /// character-identical in Swift and in TypeScript, so the rules are spelled - /// out instead of delegated to a locale-aware formatter. + /// Deliberately not `RelativeDateTimeFormatter`: the English form has to + /// come out character-identical in Swift and in TypeScript, so the rules + /// are spelled out instead of delegated to a locale-aware formatter. The + /// catalog keeps that: `en` is an identity table, so English is the key. + /// Other locales diverge from the CLI, which is not localized. static func compactAge(of date: Date, now: Date) -> String { let elapsed = now.timeIntervalSince(date) if elapsed >= 0 { - if elapsed < 60 { return "just now" } - if elapsed < 3600 { return "\(Int(elapsed / 60))m ago" } - if elapsed < 86_400 { return "\(Int(elapsed / 3600))h ago" } - return "\(Int(elapsed / 86_400))d ago" + if elapsed < 60 { return L("just now") } + if elapsed < 3600 { return L("%lldm ago", Int(elapsed / 60)) } + if elapsed < 86_400 { return L("%lldh ago", Int(elapsed / 3600)) } + return L("%lldd ago", Int(elapsed / 86_400)) } let ahead = -elapsed - if ahead < 60 { return "in under a minute" } - if ahead < 3600 { return "in \(Int(ahead / 60))m" } - if ahead < 86_400 { return "in \(Int(ahead / 3600))h" } - return "in \(Int(ahead / 86_400))d" + if ahead < 60 { return L("in under a minute") } + if ahead < 3600 { return L("in %lldm", Int(ahead / 60)) } + if ahead < 86_400 { return L("in %lldh", Int(ahead / 3600)) } + return L("in %lldd", Int(ahead / 86_400)) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index fcf7f02e8..c51a7e20f 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -145,12 +145,14 @@ struct CodexUsage: Sendable, Equatable { func text(_ value: Double) -> String { formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))" } - let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits" - return reached ? "\(base) · limit reached" : base + // The two figures are already grouped by the formatter above, so + // they are substituted formatted and only the sentence is translated. + let base = L("Monthly usage limit · %@ / %@ credits", text(used), text(limit)) + return reached ? L("%@ · limit reached", base) : base } var shortLabel: String { - reached ? "Monthly usage limit · limit reached" : "Monthly usage limit" + reached ? L("Monthly usage limit · limit reached") : L("Monthly usage limit") } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift index d49331f51..36fa313a5 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, @@ -70,8 +74,10 @@ enum CopilotQuotaPresentation { /// own `api..ghe.com` endpoint rather than a dotcom claim (#1286). static func connectedSettingsDetail(plan: String?, apiHost: String) -> String { let host = apiHost.isEmpty ? CopilotHostEndpoint.defaultAPIHost : apiHost - guard let plan, !plan.isEmpty else { return "Live quota tracked from \(host)." } - return "Plan: \(plan). Live quota tracked from \(host)." + // The host is an API hostname and the plan comes from GitHub; both are + // substituted verbatim, only the sentence around them is translated. + guard let plan, !plan.isEmpty else { return L("Live quota tracked from %@.", host) } + return L("Plan: %@. Live quota tracked from %@.", plan, host) } static func settingsNotConnectedDetail(explicitlyDisconnected: Bool) -> String { @@ -85,9 +91,9 @@ enum CopilotQuotaPresentation { /// previous snapshot already proved which host answers. static func dormantSettingsDetail(apiHost: String?) -> String { guard let apiHost, !apiHost.isEmpty, apiHost != CopilotHostEndpoint.defaultAPIHost else { - return "Tap Load Quota to fetch live usage from GitHub." + return L("Tap Load Quota to fetch live usage from GitHub.") } - return "Tap Load Quota to fetch live usage from \(apiHost)." + return L("Tap Load Quota to fetch live usage from %@.", apiHost) } /// Why a host typed next to the pasted token cannot be used, or nil when @@ -97,8 +103,7 @@ enum CopilotQuotaPresentation { static func pastedHostRejection(_ raw: String) -> String? { let host = CopilotHostEndpoint.normalize(raw) ?? CopilotHostEndpoint.defaultHost guard CopilotHostEndpoint.apiHost(for: host) == nil else { return nil } - return "CodeBurn cannot read Copilot quota for \(host). " - + "Use github.com or a GitHub Enterprise Cloud host (*.ghe.com)." + return L("CodeBurn cannot read Copilot quota for %@. Use github.com or a GitHub Enterprise Cloud host (*.ghe.com).", host) } /// Snapshot age past which a loaded view stamps an "as of