A Linux system tray for CodexBar, which
only ships a UI on macOS. It polls codexbar usage --json for the providers you
pick and shows the tightest usage percentage in the panel, favouring short
rate-limit windows over weekly quotas.
Text version of the menu
┌──────────────────────────────────────────────────────────────────────┐
│ Codex · 4% (weekly) · 6d 23h left ▸ │
│ Claude · 55% (5h) · 1h 30m left ▸ │
│ OpenCode Go · 60% (5h) · ⚠ burning too fast · 2h 34m left ▸ │
├──────────────────────────────────────────────────────────────────────┤
│ Refresh now ⟳ │
│ Providers ▸ │
│ Auto refresh: Every 5 minutes ▸ │
│ ✓ Skip credit lookups (faster) │
│ ✓ Pace alerts (colour by burn rate) │
├──────────────────────────────────────────────────────────────────────┤
│ Updated just now │
│ Edit config file… │
│ Quit │
└──────────────────────────────────────────────────────────────────────┘
Each provider row opens a submenu with its rate-limit windows, credits, cost,
pace hints, and an Auth source radio group mapping to codexbar's
--source auto|web|cli|oauth|api.
The tray speaks the freedesktop
StatusNotifierItem
protocol directly over D-Bus via ksni/zbus,
and renders its icon with tiny-skia.
There is no GTK, Qt, Cairo, libdbus or libappindicator in the dependency
tree, at build time or at run time.
$ make build
$ ldd target/x86_64-unknown-linux-musl/release/codexbar-systray
statically linked
$ ls -lh target/x86_64-unknown-linux-musl/release/codexbar-systray
-rwxrwxr-x 1.9M codexbar-systrayCopy that one file anywhere. The only runtime requirements are a session D-Bus
and the codexbar CLI on PATH.
make install # build + install into ~/.local
make enable # start now and at every loginmake install drops the binary in ~/.local/bin, a .desktop entry in
~/.local/share/applications, and a systemd user unit in
~/.config/systemd/user. Nothing is written outside $HOME and no sudo is
needed. Re-running make install upgrades in place and restarts the service if
it was running. Override the location with make install PREFIX=/usr/local.
Managing the service (thin wrappers around systemctl --user):
| Command | Effect |
|---|---|
make enable |
Start now and autostart at login |
make disable |
Stop now and cancel autostart |
make start / make stop / make restart |
One-off control |
make status |
Is it running? |
make logs |
Follow journalctl --user output |
Removal:
make uninstall # binary, .desktop and unit; keeps ~/.config/codexbar-systray
make purge # the above plus the config fileA plain cargo build --release also works if you do not need the static musl
build; it then links only libc, libm and libgcc_s. Without systemd, run the
binary from your session autostart instead — the tray has no service
dependencies of its own.
GNOME Shell has no built-in tray. You need the AppIndicator extension, which Ubuntu ships and enables by default:
gnome-extensions enable ubuntu-appindicators@ubuntu.comKDE Plasma, Xfce, Cinnamon, MATE, Budgie and most Wayland panels (Waybar,
ironbar, …) support StatusNotifierItem natively.
codexbar-systray [options]
-c, --config <path> Config file (default: $XDG_CONFIG_HOME/codexbar-systray/config.json)
-b, --binary <path> codexbar executable to call (overrides the config)
-h, --help Show this help
-V, --version Show version
- Left click — open the menu.
- Middle click — refresh everything.
The provider list and the set of valid --source values are scraped from
codexbar --help at startup, so a codexbar upgrade that adds providers is
picked up automatically. Providers in your config that a newer codexbar no
longer knows about are dropped silently. A configured source that is not in
the scraped list is kept — it is still passed to codexbar, so the menu shows it
(marked unknown) rather than claiming the codexbar default is in use.
codexbar reports per-provider failures inside its JSON payload rather than on
stderr, which is how every web-only provider answers on Linux. Those messages are
surfaced as errors: the row shows ⚠ <message>, the submenu shows the full text,
and the icon dims like any other failure.
Everything is editable from the menu; the file is only there for the extras.
~/.config/codexbar-systray/config.json:
{
"codexbar_binary": "codexbar",
"refresh_interval_secs": 300,
"timeout_secs": 90,
"no_credits": false,
"pace_alerts": true,
"providers": [
{ "provider": "codex", "source": "oauth", "in_summary": true },
{ "provider": "claude", "source": "oauth", "in_summary": true },
{ "provider": "opencodego", "in_summary": true }
]
}| Key | Meaning |
|---|---|
codexbar_binary |
Executable name or absolute path. |
refresh_interval_secs |
Auto refresh period; 0 disables it. Capped at 86400. A value the menu does not offer is honoured and shown as its own entry. |
timeout_secs |
Per-provider subprocess timeout. Web-scraping sources are slow; keep this generous. Capped at 3600. |
no_credits |
Adds --no-credits, skipping the (slow) credit balance lookups. |
pace_alerts |
Escalate the icon colour when a window is burning faster than an even rate. Never changes the number or the bar, but a window escalated to orange can become the one that gets quoted. |
providers[].source |
Omit the key to let codexbar choose (no --source flag). |
providers[].account |
Optional --account <label>. |
providers[].account_index |
Optional --account-index <n>. |
providers[].in_summary |
Whether this provider feeds the tray icon's roll-up number. |
A seven-segment percentage over a usage bar, coloured by severity. The ramp is deliberately conventional — at 22px the hue is the only thing most people actually read, so it has to mean the obvious thing:
| Usage | Colour | Constant |
|---|---|---|
< 50% |
green #2EA043 |
palette::OK |
50–79% |
amber #D49A00 |
palette::WARN |
80–91% |
orange #E0620D |
palette::HIGH |
>= 92% |
red #D1242F |
palette::CRITICAL |
Both the digits and the bar fill show the same number: the actual usage of the
quoted window (see below), taken from whichever provider flagged in_summary is
closest to its limit. They cannot disagree, and neither can the icon and the
provider row — both go through ProviderReport::display_window.
A provider usually reports several windows: Claude a 5-hour lane and a weekly one, plus per-project extras. Only one of them fits in the icon and the provider row, and it is the shortest one, not the busiest.
Longer windows out-accumulate shorter ones almost by construction — a weekly quota at 31% against a 5h quota at 21% is the ordinary state of affairs — so ranking by percentage alone means the weekly lane is all you ever see, while the 5h lane is the one that decides whether the next request goes through.
A longer window takes over once it is genuinely actionable, i.e. once it reaches
orange (>= 80%) or is pushed there by burn rate. Amber is deliberately not
enough: a weekly quota spends most of an active week sitting in amber, and letting
that win would undo the preference on exactly the days it matters. Providers that
report no short window at all — Codex sends primary: null whenever its 5h lane is
untouched — still quote the window they do have. When no window carries a length,
codexbar's slot order (primary → secondary → tertiary) stands in for it.
Every window is always listed in full in the provider's submenu.
Burn rate never changes the number. With pace_alerts enabled, a window
running more than 6 points ahead of an even burn rate bumps the severity one rung
(green → amber), and more than 12 points ahead bumps it two. So 26% used in a
5-hour window can read amber or orange, but the bar still shows 26% and the digits
still say 26. Windows behind pace are never escalated, and pace is ignored
until 3% of a window has elapsed, where a single busy minute would project
nonsense.
Because the escalated band is also what ranks windows, a short window burning fast keeps the row it would otherwise lose to a busier long one — which is the right outcome: it is about to lock you out first.
Providers with nothing left to spend are skipped when picking the icon's number, so one blown weekly quota does not pin the icon at red 100 and hide everything else. If every provider is spent, the icon reads 100 again.
— means no providers are configured and ! means every provider failed. The
icon dims when the data behind it is not trustworthy — a provider errored, or the
numbers have outlived three refresh intervals (age is only a signal while auto
refresh is on; with it off, old data is expected). An in-flight refresh does not
dim it; the data on screen is still the best available. Pixmaps are emitted at
22/32/48/64 px so the host can pick a crisp size on HiDPI.
The upstream menu bar icon is a monochrome template image with two capsule bars (session on top, weekly hairline below) and no number; percentages are drawn as adjacent menu bar text. StatusNotifierItem has no template-image concept and no adjacent text, so this tray uses colour and in-icon digits instead. The 50% and 80% boundaries match the macOS quota-warning defaults (which are expressed as 50% and 20% remaining); the 92% band and the pace escalation are additions. The bar fills with usage rather than remaining headroom, so it agrees with the digits beside it — upstream defaults to the opposite. The pace ladder itself (2/6/12 points of delta) is upstream's own.
Every colour the tray uses lives in src/icon.rs::palette, so re-skinning is a
five-line change.
Preview the renderings without a desktop:
make iconsmake check # fmt + clippy -D warnings + tests
make help # every targetmake check is the whole gate and also what CI runs on pushes and pull requests,
alongside a static musl build that asserts the binary stays statically linked.
Layout:
| File | Responsibility |
|---|---|
src/main.rs |
CLI args, wiring, tray/worker lifetime |
src/config.rs |
JSON config, atomic save |
src/codexbar.rs |
--help capability scraping, subprocess with timeout |
src/model.rs |
Defensive parse of codexbar's per-provider JSON shapes |
src/worker.rs |
Concurrent refresh engine off the D-Bus thread |
src/tray.rs |
Menu tree, labels, formatting |
src/icon.rs |
ARGB32 icon rendering |
src/names.rs |
Provider id → display name |
src/timefmt.rs |
RFC 3339 parsing, relative durations |
codexbar usage --provider X --json prints an array, one entry per account.
Shapes vary a lot across the ~60 providers, so model.rs walks
serde_json::Value rather than deriving a fixed schema. Handled today:
usage.primary|secondary|tertiary, usage.extraRateWindows[],
usage.providerCost, usage.identity, credits.remaining and pace.*.summary.
Pace objects (pace.primary|secondary|tertiary) are read structurally so the
tray can colour by burn rate, not just current usage. Providers that don't supply
pace data get the same treatment from elapsed window time: expectedUsedPercent
is just how far through the window we are, so both paths feed one multiplicative
projection (used * 100 / expected) and cannot disagree.
MIT — see LICENSE. Same license as upstream
CodexBar, which this is an unofficial
companion to; it only shells out to the codexbar CLI and ships none of its code.
