Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion mac/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<locale>.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
Expand All @@ -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 `<locale>.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")
Expand Down
24 changes: 24 additions & 0 deletions mac/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<locale>.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 `<locale>.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:
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions mac/Scripts/build-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -121,6 +136,8 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<!-- Keep in sync with package-app.sh and Package.swift's localizations. -->
<key>CFBundleLocalizations</key><array><string>en</string><string>zh-Hans</string></array>
<key>CFBundleDisplayName</key><string>CodeBurn Menubar</string>
<key>CFBundleExecutable</key><string>${EXE}</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
Expand Down
9 changes: 9 additions & 0 deletions mac/Scripts/package-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<!-- Advertises the localizations shipped in the SwiftPM resource bundle
(Sources/CodeBurnMenubar/Resources/<locale>.lproj). Without this the
app does not appear under System Settings > Language & Region > per-app
language, so users could not override the system language for it. -->
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
</array>
<key>CFBundleDisplayName</key>
<string>CodeBurn Menubar</string>
<key>CFBundleExecutable</key>
Expand Down
84 changes: 59 additions & 25 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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")
}
}

Expand Down
Loading
Loading