Three ServeConnectionTests failed on roughly half of all macOS Menubar CI
runs and always passed on re-run. Each one raced a real clock or a real
child process against Swift-side work. A cooperative pool starved by
CPU-heavy suites that swift-testing schedules alongside can delay that work
by seconds.
- "cancelling a hung request returns promptly" asserted a 500 ms wall-clock
budget, and its fixture child exited after `sleep 1`. When the cancellation
hop was starved past those exits, the connection retried the request on
replacement children until the death budget answered ServeUnavailable. The
timeout path never fired, because the test used the real ten-minute budget.
The child now stays alive, the timeout comes from the manual clock, and the
test asserts that the caller sees CancellationError before any timeout fires.
- "a request queued during cancelled hydration completes on the same child"
read the child's event log as soon as the reply resumed the caller, but the
child appends "second-replied" only after writing that reply. The test now
waits for the log line. The manual clock also replaces its timeout recorder,
which really fired after 5 s.
- "external cancellations keep one child and safely discard late replies" let
the fixture reply 50 ms after reading, so a delayed cancel lost to the reply.
The child now holds each reply until the test has observed the cancellation.
Polling is bounded by a 30 s hang guard instead of 2 s. When a guarded await
expires, the guard releases the fixture, so a regression fails its assertions
instead of hanging the run. No production code changes.
Closes#1333
The notification, the Plan-tab detail and the hover-card line #725 added were
composed in a presentation type and shipped English to a zh-Hans user. Each is
one key now. reset_type is vendor data with no fixed vocabulary, so the four
cadence words OpenAI sends are translated and anything else rides through
verbatim inside a translated frame. compactAge stays character-identical with
the TypeScript side in English, which is what the contract was about: en is an
identity table. The dock's cache-read tooltip was bound to a let before its
call site, which is why the scan never saw it.
Every sentence #725 added is now one key with placeholders. The window label
keeps its English form in the persisted event and is translated at render
through a lookup on that form, because "weekly limit" -> "weekly" is a suffix
rule only English obeys; a label outside the known set reads through as before.
The 697 existing early-reset expectations pass untouched.
Every caption and help sentence #1267 added rendered English in a zh-Hans
build: they are composed in a presentation type, not at a view call site, so
neither the catalog diff nor the source scan could see them. Each sentence is
now one key with placeholders. The two stage captions reuse the Plan tab's
existing keys, and the countdown reuses the popover's. English output is
unchanged: the 329 existing pace expectations pass untouched.
Settings > General > Language offers System, English and 简体中文. It writes
AppleLanguages into the app's own preferences domain, the same key System
Settings > Language & Region > Applications writes, so the two surfaces are one
setting. The current value is read back from that domain rather than through
UserDefaults.standard, which falls through to the global domain and would
report the system language as CodeBurn's own override: System could then never
show as selected. An inline row offers the relaunch that applies it.
The 24-unit budget was a Character count, so "GitHub Copilot 剩余 12% · 6 小时
2 分" fitted it at 24 Characters while drawing 31 cells: a third wider than
the first row it is not allowed to exceed. abbreviate now measures East Asian
wide and fullwidth scalars as two. All-Latin rows are unchanged.
veryShortWeekdaySymbols gives en "M T W T F S S": two ambiguous pairs, and
two duplicate ForEach ids. shortWeekdaySymbols clipped to two units keeps
"Mo Tu We" in English and reads 周一 周二 in zh-Hans, 19.9pt in a 30pt cell.
Rebase resolution for #1289 onto main (#1306, #1313, #1314, #1315, #1328,
#1329). Conflicts kept both intents: main's second-row title composition,
glance-window accessibility actions, cache-read line, host field and merged
Notifications section keep their behaviour and now resolve through L(…).
Adds 14 keys to both tables, drops 4 that no longer have a call site, and
retranslates the two Copilot explanations main rewrote.
LocalizationCatalogTests diffs en against zh-Hans. That catches a key
translated in one locale and not the other, but it is blind to the failure
that actually happens: a feature ships a bare `Text("Second row")`, the
literal never becomes a key, both tables stay in perfect agreement, and a
zh-Hans build renders English. #1252 and #1243 landed exactly that way, which
is why the previous commit exists at all.
So this suite reads mac/Sources instead of the tables, in three passes that
cover each other:
1. Call sites. The SwiftUI and AppKit surfaces that put a string on screen —
Text/Button/Toggle/Picker/…, the accessibility modifiers, NSMenuItem,
NSAlert and window titles — must be handed `L(…)`, not a literal.
2. Display-label properties. `displayName`/`displayLabel`/`settingsLabel` are
how this codebase names an enum for a picker, and they are not call sites,
so a new metric case with a bare literal would slip past pass 1.
3. Catalog round-trip. Every key a view asks for has an entry in both
locales, and every entry has a call site — so a key added to the code but
not the table, or left behind after a rewording, fails too.
Scoped to avoid false positives rather than by suppressing findings:
- A literal is exempt when, with `\(…)` segments removed, nothing is left but
figures, symbols, or words in a three-entry vocabulary (`CodeBurn`, `tok`,
`USD`). That covers `Text("$25")`, `Text("\(count)")` and `Text("— ")`
without naming a single file, so the exemption cannot go stale.
- Pass 2 only looks at bracket-depth zero, which is what separates the
property's result (`case .pro: "Pro"`) from machinery it calls
(`Locale(identifier: "en_US")`).
- The two denylists are documented with reasons and keyed to the declaring
type, so `PlanType.displayName` being exempt does not exempt every
`displayName` in the app.
Comments are stripped first, or Localization.swift's own documentation of
`Text("literal")` would read as a violation; strings win over comment markers
so a URL in a literal does not swallow the rest of the file.
The scanner lives in its own file with no `import Testing` so the standalone
swiftc harness can exercise the shipped code rather than a copy of it —
`swift test` cannot run on this host, and a reimplementation would drift.
Mutation-checked: un-routing `Toggle(L("Second row"))` fails pass 1,
un-routing `MenubarSecondRowMetric.settingsLabel` fails pass 2, and dropping
`L("Show %@ usage")` fails pass 3. Writing it also turned up two real bugs in
the scan (`Label(` matching inside `.accessibilityLabel(`, and a nested
`enum PlanType` reporting its outer type), both now covered by a test.
Closes#1289
and #1286's Copilot host fix all shipped user-facing copy that bypasses the
catalog — neither of the first two PRs' diffs contains a single `L(` call. In a
zh-Hans build those strings render in English.
Sweeping mac/Sources the way the original extraction did brings 18 new keys,
and retires 2 that main reworded (the Copilot help paragraph gained the
GitHub Enterprise Cloud host rule; the connection detail now names the host
that answered rather than hardcoding api.github.com). 533 keys become 551.
Routed here:
- Second row: the Settings toggle, the metric picker and its four option
names, the help paragraph, and the row text itself — today's cost, today's
tokens, and the quota row with and without a provider label. The reset
countdown reuses the popover's own `%lldd %lldh` / `%lldh %lldm` / `%lldm`
keys rather than minting a second set, so the two countdowns cannot
disagree in a translated build.
- Capacity Dock glance: the switch hint and the named VoiceOver action.
- Copilot: the reworded help paragraph and the host-aware connection detail.
Kept verbatim, per the rules in the catalog header: provider, model and plan
names; `tok`; the `%.2f` amount and its currency symbol; and the window label
the gauge reports, which is provider data and doubles as the matching needle
in CapacityDockGlanceWindow — translating the generic fallback alone would
make one VoiceOver sentence half-Chinese depending on the provider selected.
Also localizes CodexUsage's credit-limit labels. Those are not new, they were
missed by the original extraction and the guard test in the next commit finds
them; leaving them would mean shipping a test that fails on main.
Verified with a standalone swiftc harness over both tables (`swift test`
cannot run on this host): key parity, no blank values, en identity, and
argument/literal-percent specifier parity, plus the new rows formatting
correctly in both locales.
A translation catalog rots in ways the compiler cannot see: a key added to a
view but not to zh-Hans shows English in a Chinese UI, a dropped key shows a
raw identifier, and a format specifier that disagrees between the tables is a
wrong number or a crash inside String(format:).
LocalizationCatalogTests reads both tables out of the resource bundle, the same
files L(_:) resolves at runtime, and asserts: the key sets are equal in both
directions; no value is blank; en repeats its key verbatim, which is what makes
the English fallback correct; argument specifiers match in count and order,
because String(format:) binds them positionally; literal `%%` counts match,
while allowing `%%` to move where Chinese word order demands it; no key is only
specifiers, which would leave a translator nothing to work with; and the shipped
localizations agree with L10n.supportedLocalizations, so Package.swift, the two
packaging scripts' CFBundleLocalizations and the Swift constant cannot drift.
Four cases then resolve representative presentation strings in both locales and
check that the English one equals what the presentation struct returns, which is
the link proving the struct reads the catalog rather than a stale hardcoded
string, and that a substituted product name survives translation.
The specifier test caught one bad key while being written: the Capacity Dock
connect button's accessibility label was `L("%@ %@", title, providerName)`,
which has no translatable content at all. It is plain interpolation now, and
both halves were already localized on their own.
CHANGELOG and mac/README document the user-visible part: the language follows
the system, overridable per app in System Settings, plus what adding a third
language requires.
Closes#1219
All 533 catalog keys translated into zh-Hans, so the menubar app now follows
the system language with no setting to find and no third-party library. AppKit
picks the table: `CFBundleLocalizations` in both packaging scripts advertises
`en` and `zh-Hans`, which is also what puts the app under System Settings >
Language & Region so a user can override the language for CodeBurn alone.
Wording follows macOS system apps rather than a literal gloss: 立即刷新,
断开连接, 存储并连接, 载入配额, 再试一次. Product nouns stay as users
already know them: provider, model and plan names (Claude, Codex, Gemini,
Opus, Sonnet, Antigravity), Token, Dock, API, OAuth, shell commands and file
paths, currency codes.
Format specifiers are identical to the English entry in count and argument
order. `%%` is a literal percent sign and not an argument, so it may move where
Chinese word order demands it ("%@ · %@ 达到 100%%"); the argument-consuming
specifiers may not, because String(format:) binds them positionally. The next
commit's test enforces exactly that distinction.
Numbers, dates and currency are untouched: they already went through
NumberFormatter / DateFormatter / asCurrency(), and `L(_:_:)` only substitutes
values those produced.
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).
Updates, Codex banked resets and early quota resets each had their own
one-toggle Section in General. Three sibling headers for three checkboxes
is not an information architecture. One Notifications section, three
toggles, each keeping its own help text and defaults key.
Also assert the early-reset notification identifier does not carry
UpdateChecker's prefix, so the delegate's tap guard cannot install an
update from it.
Both early-reset signals shared one sentence, so a window whose counter
the vendor emptied mid-cycle was announced as "reset 18h early" even
though its reset time had not moved and the cycle still ended when it
always would have. Replaying this Mac's own 30 days of snapshots through
the monitor produces exactly that: on 2026-09-01 the weekly counter went
81% to 0% with resets_at still at 2026-09-04T07:00:00Z, and the copy
promised a new window that was not coming.
usageDropped now gets its own title, body, band and help text; the
reset-moved-forward wording is unchanged. lead() rounds to the unit it
prints instead of truncating, so a 1h57m lead reads "2h", not "1h".
Tests pin both, assert a usage-drop event never says "reset early", and
pin the notice expiry and the cycle anchor tolerance by value rather
than by reading the constants back.
swift-testing's `#expect` captures each operand separately, so the literal
`18 * 3600` on the right of a `TimeInterval` comparison was typed as Int
and the equality never resolved as Double == Double. CI reported
`(event?.earlyBySeconds → 64800.0) == (18 * 3600 → 64800)` as a failure
while the two numbers plainly agree. A bare literal in the same position
(`percentBefore == 80`) is inferred as Double and passed, which is why
only the two compound expressions failed.
The lead is now one explicitly typed `TimeInterval` constant, the event is
unwrapped with `#require` so a nil event fails with a clear message rather
than a silently false comparison, and the percent expectations are spelled
as Double literals. The rest of the file was swept for the same shape: the
remaining equalities are Int counts, CGFloat, enums, strings, or already
explicitly typed TimeIntervals.
The standalone swiftc harness could not have caught this — plain Swift
infers the same literal as Double, and the harness does not reproduce the
macro's per-operand capture. It now forces the comparison through a
TimeInterval parameter, which at least pins the type.
Detection rides the refresh lifecycle that already runs: it happens where
each successful Claude fetch is captured into the snapshot store, before
the store is written, so it costs no request. Issue #725 names "no new
network polling loops" a hard non-goal, and this adds none.
Three surfaces, because the user can be looking at any of them. A system
notification names the provider, the window and the lead. The Capacity
Dock carries a band saying the same for twelve hours after the reset was
seen, drawn and reserved exactly like the staleness notice, because the
panel's frame is computed rather than fitted. The quota hover card gains
this Mac's own pattern next to the pace captions.
The state before the fetch decides whether the stored baseline can be
compared against it: a provider coming back from a terminal failure, a
fresh bootstrap or no credentials has a baseline from the far side of a
gap, where stale state looks like a jump. A window the API did not report
this time is passed as absent rather than omitted, so a window that comes
and goes is never read as a reset. Disconnect drops the baseline along
with the snapshots, so a reconnect under another account starts clean.
The Settings switch is on by default, and turning it off withholds only
the notification — the dock band still appears, since it is a caption on
data the user is already looking at rather than an interruption.
Vendors sometimes reset a usage window ahead of schedule as a goodwill
gesture. Nothing in the app noticed, so the capacity that was handed back
went unused.
The decision is two pure pieces so it can be tested without a view or a
network: a detector that takes the previous and current reading of one
window and returns an optional event, and a summariser that reads a
window's early-reset pattern out of the snapshots already on disk.
Detection needs both signals to be defensible on their own. A reset time
that jumps to a genuinely new cycle while the stored one still had time
to run is one; usage emptying while the advertised reset time stands
still is the other, for vendors that grant the reset without moving the
time. Both carry the same identity for the cycle they cut short, so the
second cannot re-announce what the first already said.
The thresholds are deliberately conservative, because a false "you have
your capacity back" is worse than silence. Inside a cycle reported usage
only rises, moving down by a point or two of rounding, so the drop signal
asks for a fall of at least 40 points that lands at or under 10% — far
outside rounding, with room for a refresh interval of real use after the
reset. A new cycle cannot start before our last look at the old one, so
its reset time must sit about a window past that look; a reset time that
merely creeps forward is a rolling window, not a reset.
Silence is the answer for a scheduled reset, a plan change, clock or
timestamp skew (the discipline QuotaPace already applies), a window
appearing or disappearing, a window's first observation, a reconnect
after a failure, and a reading that is missing or malformed.
The monitor around it keeps the previous fetch's readings, the plan label
and the reset times it has already announced, and delivers through the
existing UpdateNotifier rather than a second notification stack. The
snapshot store keeps each cycle's highest reading, which is right for
"what did the cycle end at" and wrong for "what did the last fetch say",
so the baseline is kept alongside the announcement record instead.
History is derived from the stored reset times alone: a fixed window
starting at t ends at t + length, so a cycle ending sooner than a full
window after the previous cycle's scheduled end began early by the
difference. No network, no external feed.
The notification delegate ran performFullUpdate() for any notification
tapped. With a second notification kind, tapping "Codex banked a limit
reset" would start a CLI and app update.
Gate the handler on the UpdateChecker identifier prefix, shared as a
constant so the poster and the handler cannot drift.
The Plan tab row #724 added showed a count and an expiry. It now also shows how
many of those credits can be applied right now and when the most recent one
landed, which is what a user deciding whether to push through or throttle back
actually needs. The same sentence appears in the agent-tab quota hover card and
in `codeburn quota`.
One formatter produces it, mirrored line for line in Swift and TypeScript, down
to the compact-age rules — deliberately not RelativeDateTimeFormatter or
Intl.RelativeTimeFormat, because the two surfaces have to print the same
characters, and a locale-aware formatter would not. `codeburn quota` grows a
`notes` channel for provider facts that are not a window; only Codex sets it, so
every other provider's output is byte-identical to before.
The CLI reads the credits off the `rate_limit_reset_credits` block of the usage
response it already fetches and never calls the companion endpoint, so it prints
the count without the grant and expiry captions that only the per-credit list
carries.
Refs #725. No new polling loop and no new data source: this reads fields from a
response already fetched on the existing cadence.
A banked reset is the one capacity signal a user can act on before it is spent,
and it arrives silently. CodeBurn already sees it: the reset-credit inventory
rides every successful Codex quota refresh. Comparing that inventory against the
previous reading is all it takes to notice a grant, so there is no new endpoint,
no new cadence and no new data source here.
The detection is a pure function over an observation and the state it is judged
against, which is what makes the awkward cases testable rather than hopeful:
- The first-ever observation is a baseline. Everything the account already holds
is history, not a burst of notifications.
- A credit that disappears was spent or expired, and is never an event. Its id
is kept for the retention window so a payload that lists it again — a flicker,
not a grant — cannot re-announce it.
- An absent or malformed credits payload is *no opinion*: no event, and no state
change at all. Treating it as an empty account would re-baseline on every
failed fetch and re-announce the whole inventory on reconnect.
Seen ids persist to `codex-banked-resets.json` in the CodeBurn cache directory,
written through SafeFile behind an actor exactly the way SubscriptionSnapshotStore
writes its snapshots — the same mechanism, not a second one — so a relaunch does
not repeat a notice. State is written before the post, not after: a notice we
drop is a smaller failure than one that repeats forever. Disconnecting clears the
file, so a reconnect under another account baselines again.
The notification goes out on the existing UpdateNotifier path, one per new
credit, naming what was granted, when, and how many are usable. Never the credit
id, never a token. Settings gains a toggle, default on; with it off the state is
still recorded, so turning it back on does not release a backlog.
Nothing here ever spends a credit. It is a notice.
The reset-credits decoder kept only `available_count` and the soonest expiry,
and the inline block on the usage payload kept only `available_count`. The
response has always carried more: `id`, `reset_type` and `granted_at` per
credit, and `applicable_available_count` alongside the total. Those are exactly
the fields needed to tell a newly granted credit from one the account was
already holding, so they are decoded now instead of discarded.
Identity comes first: a credit is reduced to a grant only if it carries an `id`
or, failing that, a `granted_at` to be named by. One that carries neither still
counts toward the server's authoritative total but never becomes a grant,
because it could not be told apart from the next one and would re-announce
itself on every refresh. Only `status == "available"` credits become grants, so
a redeemed one is spent history rather than news.
`applicable_available_count` is unknown when absent, never zero. The companion
endpoint does not always send it and the usage payload sometimes does, so the
usage response we are already holding fills it in when — and only when — the
authoritative source said nothing. No extra request either way.
Reserving the pace slot only while connected made the panel relayout on
every refresh: the slot collapsed and the staleness band appeared. The
reservation is a property of the window shape, so it no longer asks the
connection. Windows block on the real Claude shapes, connected vs stale:
91/77 -> 91/91 at one and two windows, 164/136 -> 164/164 at three.
A Claude or Codex summary now reads stale on sample age alone, and the
manual cadence never refreshes, so "Last known usage - refreshing" and
the Settings "Refreshing..." became permanent lies. Both now ask whether
a fetch is actually in flight.
Widening the on-pace band from 2 to 20 points, or flipping the six-hour
ETA suppression from <= to <, left all 528 tests green. Both are the
thresholds the caption is about.
The last rung of the chain is a token the user pastes into Settings, and an
enterprise personal access token pasted there was still sent to
api.github.com. Settings now takes the host beside the token, defaulting to
github.com, and saves both in the same provider Keychain record so the host a
request is addressed to always comes from the entry that carried the token.
A host no endpoint can be derived from is refused at the field instead of
being stored and reported later as a terminal fetch failure, and the crafted
values #1286 guarded against are refused there too. Clearing the token clears
the host with it, so a stale tenant can never be paired with the next token.
The record's decoding moves into an extension and reads every key with
decodeIfPresent: Swift's synthesized init(from:) ignores a property's default
and throws on a missing key, so adding a field would otherwise make every
credential saved before it unreadable — for every provider, not just Copilot.
The dormant connection row no longer promises api.github.com before anything
has been fetched; it names a host only once one has answered.
Tests cover the three new host sources with a hostile value each, the rung
wiring for all five rungs, the gh hosts.yml key scan, and that a record
written before the host field still decodes.
#1286 taught the credential files to carry the GitHub host their token came
from, but the environment and `gh auth token` rungs carried none, so they were
always addressed to api.github.com. A user whose only Copilot credential is a
GitHub Enterprise Cloud login — GH_TOKEN exported alongside GH_HOST, or
`gh auth login --hostname <tenant>.ghe.com` — got a 401 and a terminal failure
with nothing to act on.
The environment rung now reads GH_HOST out of the same environment as the
token. The gh rung resolves the host from gh's own hosts.yml, found via
GH_CONFIG_DIR, then XDG_CONFIG_HOME/gh, then ~/.config/gh, and picked the way
gh picks the host for `gh auth token`: GH_HOST, else the single configured
host, else dotcom. That is a file read rather than a second `gh auth status`
spawn, because gh writes hosts.yml for every login, keyring-backed ones
included.
Both are new untrusted inputs, so they stay raw all the way to
`apiHost(for:)`, where #1286's character validation refuses a crafted value
such as `evil.com?.ghe.com` before any URL exists — that host passes the
`.ghe.com` suffix check but builds a URL whose host is api.evil.com, which
would then receive the Authorization header.
Ports the two parts of #1287 the maintainer asked to keep, expressed
through this branch's `QuotaPacePresentation` rather than through
`QuotaPace.Verdict`:
- Placement: `QuotaDetailRow` in the agent-tab quota hover card becomes
a VStack and draws the pace line under the bar, indented past the
label column, with the three tones the dock already uses. The card is
fitted, so a silent caption collapses there instead of reserving a
slot the way the dock's computed frame must.
- Wording: the long-window caption drops "est. N% at reset" /
"est. out in X" for the plain "Lasts until reset" / "Runs out in X",
and the exhausted state becomes "Limit reached". The projected
percentage it came from stays in the hover text, which has room for
the reasoning. Short windows keep the on-pace / deficit / reserve
stage (#726), and the freshness, connection and duration gates are
untouched.
`QuotaPace.inferredWindowSeconds(label:)` is deliberately NOT ported.
Grok Build chooses its window label from the distance to the reset, so
a monthly cycle passing through the 4-12 day band is labeled "Weekly"
and a label round trip paces a month's budget against seven days. The
adapter's real `windowSeconds` is the only source here, and a new test
pins that shape: 72% used with six days to a monthly reset reads
"Lasts until reset", while the same sample against an inferred 7-day
window overflows and would have printed "Runs out in 9h 20m". The same
test asserts a window with no duration metadata stays silent rather
than falling back to its label.
Verified with a standalone swiftc harness over QuotaPace,
QuotaPacePresentation and QuotaSummary: this host has Command Line
Tools only, so `swift test` cannot load the Testing module.
Closes#1267
The "quota remaining" row selected each provider's headline window, which
is a billing horizon: weekly, else monthly, and only then the busiest
window. That is the right number for the Capacity Dock rail and the wrong
one for a row whose whole job is to name the provider nearest its limit.
On a real machine it discarded Cursor's API window at 100% and Claude's
5-hour window at 18% in favour of Monthly 9.5% and Weekly 5%, so the row
read as if nothing were near a limit — while the flame beside it was
tinting off the worst window across every provider. The comment claiming
the row followed the flame was wrong on both counts.
Each provider now contributes its worst window, per-model rows included,
which is exactly the rule aggregateQuotaStatus tints by, so the row and
the flame agree and README, CHANGELOG and the Settings help become true
as written. A provider in transientFailure keeps its last-known window
instead of dropping out, matching the dock, which shows the same data
dimmed during a backoff: the row no longer vanishes and resizes the
status item for the length of a retry.
Three smaller fixes from the same review: the row is capped at 24
characters — about the widest the single row gets at 13pt, since the
second line renders at 9pt — shortening a long provider name rather than
letting "GitHub Copilot 12% left · 6d 3h" more than double the item's
width; the two-line title gets a VoiceOver label that reads as one phrase
instead of a string with a newline in it; and the snapshot feeding the
row is no longer built on refreshes where the feature is off. With the
setting off the title is byte-for-byte the single-row composition it has
always been.
Closes#1310
Main took render revision 6 for `sessionCountBasis` and changed the
provider-slice session fold from a sum to a per-day max. Both had to
survive the merge, so the resolutions are unions rather than sides:
- `addProviderSlice` keeps main's `Math.max` for `sessions` (per-day
ticks are not distinct identities) and adds the cache-read sum beside
it, with a comment saying why one is additive and the other is not.
- `ProviderSliceTotal`, `ProviderCost`, `providerDetails` and the Swift
`ProviderDetail` carry both `sessionCountBasis` and `cacheReadTokens`.
- The snapshot revision stays 7, which is still the next free number
above main's 6. Its doc comment said v6 was PR1265's per-model counts;
it is main's `sessionCountBasis`, so the comment now says that.
The status-menubar suite hard-codes render revisions. Main's assertion
that a rejected v5 snapshot is rewritten as `:render-6:` auto-merged
cleanly while this branch moved the current revision to 7, which would
have failed silently on the next bump too. Those assertions now derive
the tag from `STATUS_SNAPSHOT_RENDER_VERSION`, so the current and
previous revisions follow the constant; the deliberately historical
`:render-5:` and `:render-2:` plants stay literal.
The resting rail shows one number per provider, and that number was always
the weekly (else monthly) billing window. A user watching Claude's 5-hour
limit had to expand the dock and read the glance card every time, which
defeats the point of an always-visible usage monitor.
Clicking the provider that already rests in the rail now switches its gauge
to the provider's short rolling window and back. The preference is stored per
provider rather than as a Claude-only switch: the dock's collapsed row is
provider-agnostic, and Codex publishes a 5-hour and a weekly window too, so
the question is "which horizon for this provider", stored under
CodeBurnCapacityDockGlanceWindows alongside the other dock preferences.
The two horizons are named rather than labelled, so the policy works for any
adapter: billing reuses QuotaSummary.headlineWindow, the app's single
definition of the weekly/monthly glance, and burst matches the short rolling
rows the adapters emit (5-hour, Hourly, Daily, session) while excluding
anything weekly or monthly, so Claude's per-model "Weekly · Opus" row can
never be read as a short window. CapacityDockGlanceWindow keeps that policy
pure and testable, including the fallbacks: a provider with one window shows
it whichever horizon is stored, and a stored horizon the provider stops
reporting resolves to the one it does report instead of blanking the gauge.
The click cannot fight the hover expansion: this branch only ever pins, never
collapses, and it is reached only when the provider actually has two windows,
so a single-window provider keeps the existing click-to-pin toggle. Escape
and an outside click still unpin. Keyboard and VoiceOver users get the same
switch as a named accessibility action on the provider cell, and the cell's
accessibility value now names the window feeding the ring. The rail's
geometry is untouched.
Closes#1243
CI's swift test on macos-latest measured the two-row title at 21pt where this
machine measures 20pt, so the exact-equality assertion failed 16 times. The
figure is release-dependent: SF Symbol metrics and line rounding move between
macOS versions. Nothing positions the rows off it — AppKit's button cell
centres whatever it measures inside the status item — so 21pt is centred just
as correctly as 20pt, and the requirement was always "fits a 22pt menu bar",
not "measures exactly 20pt". The test now asserts that band, and
twoRowMeasuredHeight is replaced by twoRowMaximumHeight (the bar) and
twoRowMinimumHeight (a floor, so a lost paragraph clamp or a dropped second
line is still caught rather than passing as "fits").
The 1pt delta does expose a real risk the constant was hiding: the first line's
height follows the flame image, and the image size for a point size is the
system's business. A release that renders it taller could push the title past
the bar. The two-row attachment box is now computed by
twoRowAttachmentBounds(imageSize:), which scales the image down to the clamped
line height — never up — so the first line cannot outgrow its clamp. Verified
by composing the real title with the flame forced to 11, 13, 16 and 24pt tall:
every combination still lays out at 20pt here. The single-row path keeps the
raw image size and its -3pt offset, so it stays byte-identical.
The menu bar item could show one figure, so seeing subscription quota and
today's local usage at the same time meant opening the popover. This adds an
optional second line, off by default, configured from the existing
Settings > General > Display section: a "Second row" toggle plus a picker for
what it shows. All four metrics are figures the app already computes for the
popover, so nothing here adds a fetch or a refresh cadence: quota remaining
with its reset countdown for whichever connected provider is nearest its limit
(the same provider the flame already tints for), today's all-provider cost,
today's total tokens, or running sessions.
The rows are one attributed title. A paragraph style clamps text line height
but not an attachment, so the inline flame at the text's own point size dragged
the title to 24pt and AppKit clipped the second line away; the flame is
requested at 8pt and seated back inside the line, which puts every row
combination at exactly 20pt inside the standard 22pt menu bar, centred with 1pt
of slack above and below. Measured with NSAttributedString.boundingRect and
asserted in the tests, since those four numbers only work together.
With the setting off, or whenever the chosen metric has no data yet (no
connected provider with a quota window, no today payload, a CLI that predates
the liveSessions block), the title falls back to the previous single-row
composition unchanged: same font, same attributes, no paragraph style. The row
formatting is a pure type over the row settings and a plain-value payload
snapshot, so it is covered without an AppStore or a status item.
This is a deliberately reduced slice of the request. Not included: the layout
editor, presets, live preview, a third row, multiple items or aligned columns
per row, per-item provider/period/scope, optional labels, decimal precision,
reordering and text size. Keeping the formatter pure leaves room for those
later without touching the rendering path.
Closes#1252
The `.ghe.com` suffix check ran on a normalized string that still allowed
`?`, `#`, `\` and spaces, and that string was interpolated straight into
the request URL. `evil.com?.ghe.com` in a hosts.json key passes the
suffix check but builds a URL whose host is `api.evil.com`, so the
Authorization header lands on a host that was never approved. Verified
on macOS: URL(string:) parses it as host `api.evil.com`, path `/`.
Both readers now require the normalized host to look like a DNS name
before an endpoint is derived; anything else fails as an unsupported
host, with no request sent.
Both Copilot quota readers hardcoded
https://api.github.com/copilot_internal/user and dropped the host their
credential was read from, so an enterprise on a GitHub Enterprise Cloud
data-residency host (<tenant>.ghe.com) could only ever report
"Temporarily unavailable": its token is not a dotcom token, and dotcom is
not an endpoint it may be sent to.
A discovered credential now carries its host. hosts.json is keyed by
GitHub host and newer apps.json files key by "<host>:<app id>", so the
token and the host always come from the same entry; the rungs that carry
no host of their own (an app-name apps.json key, COPILOT_GITHUB_TOKEN /
GH_TOKEN / GITHUB_TOKEN, gh auth token, a pasted token) mean dotcom. The
endpoint follows the host: api.github.com for github.com, and
api.<tenant>.ghe.com for an Enterprise Cloud host. With several hosts
signed in github.com wins, otherwise the first .ghe.com tenant in sorted
order, which keeps the pick stable across reads; with exactly one, that
host is used.
A host neither rule can address, such as a self-hosted GitHub Enterprise
Server install, fails with a message naming that host instead of falling
back to dotcom, and network and HTTP failures name the host that was
tried. macOS Settings now says which host answered in the Copilot
connection row. Tokens are still never logged and sanitizeForUI is
unchanged.
Closes#1286
The background tick skips the whole refresh when local session files are
unchanged, but quota is server-side state, so the dock rings went stale
until the popover was opened. Run the quota half on skipped ticks, still
throttled by the subscription cadence.
Also refresh the dock providers before the Claude/Codex awaits so a slow
provider cannot delay them, anchor lastCodexRefreshAt on attempt like its
siblings, and query display sleep live instead of latching a flag that a
missed wake notification leaves stuck.
Nil-quota Capacity Dock used generic OAuth retry copy. Explicit
Disconnect leaves credentials, so reuse the existing disconnected
Settings detail for Copilot only.
Plan and Settings treated sticky .notBootstrapped after Disconnect as
missing credentials. Keep first-use and real noCredentials sign-in copy;
disconnected state says quota tracking is off, credentials untouched,
and Connect resumes.
The app built PATH by appending its curated directories after the
inherited value, so whichever `node` came first won -- on an nvm machine
that is the default alias, which can be an older major than the one the
CLI was installed under. Every spawn then died on the engine gate
("codeburn requires Node.js >= 22.13.0 (current: v20.20.2)") and the
popover showed "Could not load Today" with nothing pointing at Node.
augmentedPath now takes the resolved CLI path and, when an executable
node sits in the same directory, moves that directory to the front of
PATH -- promoting it if already present, dropping nothing. That
interpreter is the one that installed the CLI and satisfies its engine
range. A bare `codeburn` or a CLI with no sibling node leaves the
inherited order untouched, so #1263's Nix layout is unaffected.
makeProcess resolved the CLI twice, once for PATH and once for argv; it
now resolves once and passes the same value to both.
Three tests cover the reorder, the promote-not-duplicate case, and the
untouched case; removing only the reorder block makes the first two fail
with the reported shape while every pre-existing test still passes.
On a nix-darwin machine node lives only in /etc/profiles/per-user/$USER/bin,
which appears in neither additionalPathEntries nor userNodePaths. The CLI shim
begins '#!/usr/bin/env node', so the spawn dies with
'env: node: No such file or directory' and exit status 127.
GUI apps normally inherit those directories from 'launchctl config user path',
stored in /var/db/com.apple.xpc.launchd/config/user.plist, which a macOS system
update deletes. Re-applying it is not durable, so name the Nix locations in
userNodePaths instead. That feeds both installedArgv() discovery and
augmentedPath() interpreter resolution.
Also adds ~/.local/bin, the bin directory for the common 'prefix = ~/.local'
npm configuration, which installedArgv() could not otherwise discover.
Entries are appended, so existing matches keep their precedence.