Skip to content
Closed
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 @@ -10,6 +10,7 @@
- **The Capacity Dock shows today's cache-read tokens and tells you whether each quota window will last to its reset.** The Today section gains a provider-scoped cache-read figure beside input, output and calls; a known zero prints as `0` while missing or incomplete historical accounting stays unknown rather than becoming a fabricated zero, and because cache reads were already priced into the burned figure this adds visibility without changing any total. Each quota window then gets one line under it: `Lasts until reset`, `Runs out in 2d 8h`, or — on windows of six hours or less, where one burst would make a linear ETA cry wolf — the pace stage the Plan tab uses (`On pace`, `40% in deficit`, `30% in reserve`). The same line appears under each bar in the agent-tab quota hover card. The projection runs against the window length the provider adapter reports, never a length guessed from the display label, so a monthly cycle whose label happens to read `Weekly` is still paced against its month; it stays silent early in a window, on an exhausted window, without a reset time or a validated duration, and on stale, disconnected or older-than-ten-minute data. Four quota windows move to a two-column grid so scope labels, reset times and captions stay readable, and the dock reserves the caption's height whether or not a column has one so the bubble cannot resize under the pointer. Status snapshot revision 7 invalidates older cached payloads without purging daily history, and no extra polling is introduced. (#1267)
- **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252)
- **The Capacity Dock gauge can report the short usage window instead of the weekly one, without expanding the dock.** The resting rail shows one number per provider, and that number was always the weekly (else monthly) billing window. Clicking the provider already resting in the rail now switches its gauge to the provider's short rolling window — Claude's 5-hour limit, Codex's 5-hour or daily window, any `Hourly`, `Daily` or session row an adapter reports — and clicking again switches back. The choice is stored per provider under `CodeBurnCapacityDockGlanceWindows`, so Claude can sit on its 5-hour window while Codex stays weekly, and it survives relaunch. A per-model row such as `Weekly · Opus` is never read as a short window, a provider that reports only one window keeps the plain click-to-pin behaviour, and a stored horizon the provider stops reporting falls back to the window it does report rather than blanking the gauge to `--`. The rail's geometry is untouched, VoiceOver and keyboard users get the switch as a named action on the provider cell with the window named in the cell's value, and Escape or a click outside still unpins the dock. (#1243)
- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 613 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages, and `CFBundleLocalizations` in both packaging scripts puts CodeBurn under System Settings > Language & Region so the language can be overridden for this app alone. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own symbols, so a Chinese UI reads `2026年9月` and `一 二 三` rather than `September 2026` and `Mo Tu We`. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — at a SwiftUI or AppKit call site, in an enum's display label, or anywhere in a `…Presentation` type whose whole job is producing sentences — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219)

### Fixed
- **The menubar second row's quota line now names the provider the flame is warning about.** "Quota remaining" picked each provider's *headline* window — weekly, else monthly, and only then the busiest one — which is the Capacity Dock's billing horizon, not a warning: on a machine with Cursor's API window at 100% and Claude's 5-hour window at 18%, the row reported Monthly 9.5% and Weekly 5% and read as if nothing were near a limit. Each provider now contributes its worst window, per-model rows included, which is exactly what the menu-bar flame already tints by, so the two surfaces agree. A provider backing off after a failed fetch keeps its last-known window instead of dropping out, so the row no longer vanishes — resizing the status item — for the length of a retry, matching what the dock shows dimmed. The row is also capped at 24 characters, shortening a long provider name ("GitHub Copilot 12% left · 6d 3h") rather than letting the second line more than double the item's width, and the two-line title now carries a VoiceOver label that reads as one phrase instead of a string with a newline in it. With the setting off nothing changed: the snapshot that feeds the row is no longer even built on a refresh, and the title is the single-row composition it has always been. (#1310)
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
23 changes: 23 additions & 0 deletions mac/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface.
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
- `codeburn` CLI installed globally (`npm install -g codeburn`)

## Language

The app ships English and Simplified Chinese (`zh-Hans`) and follows your system
language: no setting inside CodeBurn, because macOS already owns this choice. To
use a different language for CodeBurn than for the rest of the system, open
System Settings > General > Language & Region, scroll to Applications, and add
CodeBurn Menubar there.

Strings live in `Sources/CodeBurnMenubar/Resources/<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 +102,9 @@ mac/
├── Sources/CodeBurnMenubar/
│ ├── CodeBurnApp.swift @main + MenuBarExtra scene
│ ├── AppStore.swift @Observable store + enums
│ ├── Localization.swift L(_:) lookups against the module bundle
│ ├── Resources/en.lproj/ Localizable.strings (identity table)
│ ├── Resources/zh-Hans.lproj/ Localizable.strings (简体中文)
│ ├── Data/MenubarPayload.swift Codable payload types + placeholder
│ ├── Theme/Theme.swift Design tokens (warm terracotta palette)
│ └── Views/MenuBarContent.swift Popover layout + footer action bar
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