diff --git a/.config/waybar/catppuccin-macchiato/config.jsonc b/.config/waybar/catppuccin-macchiato/config.jsonc
index 2659983..c13ee55 100644
--- a/.config/waybar/catppuccin-macchiato/config.jsonc
+++ b/.config/waybar/catppuccin-macchiato/config.jsonc
@@ -1,4 +1,5 @@
{
+ "include": ["~/.config/waybar/modules/custom-codexbar.json"],
"layer": "bottom",
"position": "top",
"height": 40,
@@ -328,13 +329,4 @@
"on-click-right": "swaync-client -d -sw",
"escape": false,
},
- "custom/codexbar": {
- "exec": "codexbar --icon \"\" | sed 's/ *|//g'",
- "return-type": "json",
- "interval": 300,
- "signal": 12,
- "tooltip": true,
- "on-click": "python3 ~/.config/waybar/scripts/aibar-popup.py",
- "on-click-right": "python3 ~/.config/waybar/scripts/aibar-popup.py --claude",
- },
}
diff --git a/.config/waybar/catppuccin-macchiato/style.css b/.config/waybar/catppuccin-macchiato/style.css
index dc8085f..3286bc4 100644
--- a/.config/waybar/catppuccin-macchiato/style.css
+++ b/.config/waybar/catppuccin-macchiato/style.css
@@ -344,11 +344,9 @@ label:focus {
#custom-codexbar {
padding: 0 10px;
margin: 5px 1px;
- background-color: @surface0;
+ background-color: @pink;
+ color: @mantle;
border-radius: 0.69em;
}
-#custom-codexbar.low { color: @green; }
-#custom-codexbar.mid { color: @yellow; }
-#custom-codexbar.high { color: @peach; }
-#custom-codexbar.critical { color: @red; }
+#custom-codexbar.critical { box-shadow: inset 0 -3px @red; }
diff --git a/.config/waybar/modules/custom-codexbar.json b/.config/waybar/modules/custom-codexbar.json
new file mode 100644
index 0000000..6c80dae
--- /dev/null
+++ b/.config/waybar/modules/custom-codexbar.json
@@ -0,0 +1,20 @@
+// Waybar custom-module definition for codexbar-waybar.
+//
+// install.sh drops this at ~/.config/waybar/modules/custom-codexbar.json.
+// You then reference "custom/codexbar" from a modules-right group in your
+// ~/.config/waybar/config.jsonc — see README.md for the snippet.
+//
+// To force a refresh from elsewhere: pkill -RTMIN+8 waybar
+{
+ "custom/codexbar": {
+ "exec": "$HOME/.config/waybar/scripts/codexbar.sh",
+ "return-type": "json",
+ "format": "{}",
+ "interval": 30,
+ "signal": 8,
+ "on-click": "$HOME/.config/waybar/scripts/codexbar-popup.py",
+ "on-click-right": "bash -c 'notify-send -a CodexBar -t 8000 \"AI usage\" \"$($HOME/.config/waybar/scripts/codexbar.sh | jq -r .tooltip)\"'",
+ "tooltip": true,
+ "max-length": 24
+ }
+}
diff --git a/.config/waybar/scripts/aibar-popup.py b/.config/waybar/scripts/aibar-popup.py
new file mode 100755
index 0000000..8b73000
--- /dev/null
+++ b/.config/waybar/scripts/aibar-popup.py
@@ -0,0 +1,387 @@
+#!/usr/bin/env python3
+"""
+aibar-popup.py — Tabbed AI usage popup for codexbar + claudebar.
+Toggle: clicking when open closes it (uses PID lockfile).
+
+Run with: GDK_BACKEND=x11 python3 ~/.config/waybar/scripts/aibar-popup.py
+Flags: --claude open with Claude tab active
+"""
+
+import gi
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk, GLib
+
+import subprocess
+import json
+import threading
+import sys
+import os
+import signal
+import atexit
+import re
+
+# ── Toggle: kill existing instance if running ─────────────────────────────
+LOCK = "/tmp/aibar-popup.pid"
+
+def _cleanup():
+ try:
+ os.remove(LOCK)
+ except FileNotFoundError:
+ pass
+
+if os.path.exists(LOCK):
+ try:
+ with open(LOCK) as f:
+ pid = int(f.read().strip())
+ os.kill(pid, signal.SIGTERM)
+ os.remove(LOCK)
+ sys.exit(0) # toggled off an existing popup — done
+ except (ProcessLookupError, ValueError, OSError):
+ os.remove(LOCK) # stale lock — clean up and continue opening
+
+with open(LOCK, "w") as f:
+ f.write(str(os.getpid()))
+atexit.register(_cleanup)
+
+# ── Catppuccin Macchiato ──────────────────────────────────────────────────
+CRUST = "#181926"
+MANTLE = "#1e2030"
+BASE = "#24273a"
+S0 = "#363a4f"
+S1 = "#494d64"
+OV0 = "#6e738d"
+OV1 = "#8087a2"
+SUB1 = "#a5adcb"
+TEXT = "#cad3f5"
+GREEN = "#a6da95" # Codex / OpenAI
+PEACH = "#f5a97f" # Claude / Anthropic
+RED = "#ed8796"
+YELLOW = "#eed49f"
+
+FONT = "JetBrainsMono Nerd Font"
+
+CSS = f"""
+window {{
+ background-color: {MANTLE};
+ border-radius: 12px;
+ border: 1px solid {S1};
+}}
+.root-box {{
+ background-color: {MANTLE};
+ border-radius: 12px;
+}}
+
+/* ── Tab bar ── */
+.tab-bar {{
+ background-color: {CRUST};
+ border-radius: 12px 12px 0 0;
+ padding: 0;
+ border-bottom: 1px solid {S0};
+ min-height: 36px;
+}}
+.tab-btn {{
+ background-color: transparent;
+ border: none;
+ border-radius: 0;
+ border-bottom: 3px solid transparent;
+ color: {OV1};
+ padding: 7px 20px 4px 20px;
+ font-family: "{FONT}";
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0;
+ box-shadow: none;
+ letter-spacing: 0.3px;
+}}
+.tab-btn:hover {{
+ color: {TEXT};
+ background-color: {S0};
+ box-shadow: none;
+ border-bottom: 3px solid {S1};
+}}
+.tab-codex.active {{
+ color: {GREEN};
+ border-bottom: 3px solid {GREEN};
+ background-color: {MANTLE};
+}}
+.tab-claude.active {{
+ color: {PEACH};
+ border-bottom: 3px solid {PEACH};
+ background-color: {MANTLE};
+}}
+.close-btn {{
+ background-color: transparent;
+ border: none;
+ color: {OV0};
+ font-size: 11px;
+ padding: 4px 12px 4px 12px;
+ border-radius: 0;
+ border-bottom: 3px solid transparent;
+ box-shadow: none;
+ min-height: 0;
+ min-width: 0;
+}}
+.close-btn:hover {{
+ color: {RED};
+ background-color: {S0};
+ box-shadow: none;
+}}
+
+/* ── Status section ── */
+.status-section {{
+ background-color: {BASE};
+ padding: 12px 18px 10px 18px;
+ border-bottom: 1px solid {S0};
+}}
+.status-label {{
+ font-family: "{FONT}";
+ font-size: 20px;
+ font-weight: bold;
+}}
+
+/* ── Accent stripe ── */
+.accent-codex {{
+ background: linear-gradient(to right, {GREEN}, transparent);
+ min-height: 3px;
+}}
+.accent-claude {{
+ background: linear-gradient(to right, {PEACH}, transparent);
+ min-height: 3px;
+}}
+
+/* ── Content ── */
+.content-box {{
+ background-color: {MANTLE};
+ padding: 14px 18px 16px 18px;
+}}
+.tooltip-label {{
+ font-family: "{FONT}";
+ font-size: 12.5px;
+ color: {TEXT};
+}}
+.loading-label {{
+ color: {OV0};
+ font-family: "{FONT}";
+ font-size: 12px;
+ font-style: italic;
+ padding: 32px 18px;
+}}
+""".encode()
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────
+
+def strip_box(text: str) -> str:
+ """Remove box-drawing decoration (╭─│╰) from Pango-markup tooltip strings."""
+ cleaned = []
+ for line in text.split("\n"):
+ if re.search(r"[╭╰╮]", line):
+ continue
+ if re.search(r"foreground='#5c6370'>[─]", line):
+ continue
+ line = re.sub(r"│\s*", "", line)
+ line = re.sub(r"\s*│", "", line)
+ if line.strip():
+ cleaned.append(line)
+ return "\n".join(cleaned)
+
+
+# ── Data fetching ─────────────────────────────────────────────────────────
+
+def fetch_data(cmd: list[str]) -> dict:
+ try:
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
+ return json.loads(r.stdout.strip())
+ except Exception as e:
+ return {
+ "text": "⚠ error",
+ "tooltip": f'Error: {e}',
+ "class": "",
+ }
+
+
+# ── Content page ─────────────────────────────────────────────────────────
+
+class ContentPage(Gtk.Box):
+ def __init__(self, service: str):
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.service = service
+
+ # Gradient accent stripe
+ accent = Gtk.Box()
+ accent.set_size_request(-1, 3)
+ accent.get_style_context().add_class(
+ "accent-codex" if service == "codex" else "accent-claude"
+ )
+ self.pack_start(accent, False, False, 0)
+
+ # Status section (bar text — large)
+ self.status_section = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ self.status_section.get_style_context().add_class("status-section")
+ self.status_lbl = Gtk.Label(label="—")
+ self.status_lbl.get_style_context().add_class("status-label")
+ self.status_lbl.set_halign(Gtk.Align.START)
+ self.status_section.pack_start(self.status_lbl, True, True, 0)
+ self.pack_start(self.status_section, False, False, 0)
+
+ # Tooltip / main content
+ self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.content_box.get_style_context().add_class("content-box")
+ self.pack_start(self.content_box, True, True, 0)
+
+ self.loading = Gtk.Label(label="fetching…")
+ self.loading.get_style_context().add_class("loading-label")
+ self.loading.set_halign(Gtk.Align.CENTER)
+ self.content_box.pack_start(self.loading, True, True, 0)
+
+ cmd = ["codexbar"] if service == "codex" else ["claudebar"]
+ threading.Thread(target=self._fetch, args=(cmd,), daemon=True).start()
+
+ def _fetch(self, cmd):
+ data = fetch_data(cmd)
+ GLib.idle_add(self._populate, data)
+
+ def _populate(self, data):
+ color = GREEN if self.service == "codex" else PEACH
+ text = data.get("text", "")
+ tip = strip_box(data.get("tooltip", "No data"))
+
+ # Update status bar
+ self.status_lbl.set_markup(
+ f'{text}'
+ )
+
+ # Replace loading with tooltip content
+ self.content_box.remove(self.loading)
+ lbl = Gtk.Label()
+ try:
+ lbl.set_markup(tip)
+ except Exception:
+ lbl.set_text(tip)
+ lbl.get_style_context().add_class("tooltip-label")
+ lbl.set_halign(Gtk.Align.START)
+ lbl.set_valign(Gtk.Align.START)
+ lbl.set_selectable(True)
+ lbl.set_line_wrap(False)
+ self.content_box.pack_start(lbl, True, True, 0)
+ self.content_box.show_all()
+ return False
+
+
+# ── Main window ──────────────────────────────────────────────────────────
+
+class AIBarPopup(Gtk.Window):
+ def __init__(self, default_tab: int = 0):
+ super().__init__(type=Gtk.WindowType.TOPLEVEL)
+ self.set_title("aibar")
+ self.set_decorated(False)
+ self.set_resizable(False)
+ self.set_keep_above(True)
+ self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
+ self.active_tab = default_tab
+
+ provider = Gtk.CssProvider()
+ provider.load_from_data(CSS)
+ Gtk.StyleContext.add_provider_for_screen(
+ Gdk.Screen.get_default(),
+ provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
+ )
+
+ self.connect("key-press-event", self._on_key)
+
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ root.get_style_context().add_class("root-box")
+ self.add(root)
+
+ # ── Tab bar ──────────────────────────────────────────────────────
+ tab_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ tab_bar.get_style_context().add_class("tab-bar")
+
+ self.btn_codex = Gtk.Button(label=" Codex")
+ self.btn_codex.get_style_context().add_class("tab-btn")
+ self.btn_codex.get_style_context().add_class("tab-codex")
+ self.btn_codex.connect("clicked", lambda *_: self._set_tab(0))
+
+ self.btn_claude = Gtk.Button(label=" Claude")
+ self.btn_claude.get_style_context().add_class("tab-btn")
+ self.btn_claude.get_style_context().add_class("tab-claude")
+ self.btn_claude.connect("clicked", lambda *_: self._set_tab(1))
+
+ spacer = Gtk.Box()
+ spacer.set_hexpand(True)
+
+ close_btn = Gtk.Button(label="✕")
+ close_btn.get_style_context().add_class("close-btn")
+ close_btn.connect("clicked", lambda *_: self._quit())
+
+ tab_bar.pack_start(self.btn_codex, False, False, 0)
+ tab_bar.pack_start(self.btn_claude, False, False, 0)
+ tab_bar.pack_start(spacer, True, True, 0)
+ tab_bar.pack_start(close_btn, False, False, 0)
+ root.pack_start(tab_bar, False, False, 0)
+
+ # ── Stack ─────────────────────────────────────────────────────────
+ self.stack = Gtk.Stack()
+ self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
+ self.stack.set_transition_duration(130)
+ # Fixed size: 360px wide fits the codexbar/claudebar cards (~280px) with padding.
+ # Height 410 accommodates the tallest page (Claude has 4 sections); scrolled below.
+ self.stack.set_size_request(360, 410)
+
+ self.page_codex = ContentPage("codex")
+ self.page_claude = ContentPage("claude")
+ self.stack.add_named(self.page_codex, "codex")
+ self.stack.add_named(self.page_claude, "claude")
+ root.pack_start(self.stack, True, True, 0)
+
+ self._set_tab(default_tab, animate=False)
+ self.show_all()
+ self._position()
+
+ def _set_tab(self, idx: int, animate: bool = True):
+ self.active_tab = idx
+ if not animate:
+ self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
+ self.stack.set_visible_child_name("codex" if idx == 0 else "claude")
+ if not animate:
+ self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
+
+ cc = self.btn_codex.get_style_context()
+ ca = self.btn_claude.get_style_context()
+ if idx == 0:
+ cc.add_class("active"); ca.remove_class("active")
+ else:
+ cc.remove_class("active"); ca.add_class("active")
+
+ def _position(self):
+ self.realize()
+ w, _h = self.get_size()
+ display = Gdk.Display.get_default()
+ n = display.get_n_monitors()
+ monitor = display.get_monitor(n - 1)
+ geo = monitor.get_geometry()
+ x = geo.x + geo.width - w - 14
+ y = geo.y + 48
+ self.move(x, y)
+
+ def _on_key(self, _w, event):
+ if event.keyval == Gdk.KEY_Escape:
+ self._quit()
+
+ def _quit(self):
+ _cleanup()
+ Gtk.main_quit()
+
+
+# ── Entry point ──────────────────────────────────────────────────────────
+
+def main():
+ default = 1 if "--claude" in sys.argv else 0
+ win = AIBarPopup(default)
+ win.connect("destroy", lambda *_: (_cleanup(), Gtk.main_quit()))
+ Gtk.main()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.config/waybar/scripts/cert_redirect.so b/.config/waybar/scripts/cert_redirect.so
new file mode 100755
index 0000000..e7e8e82
Binary files /dev/null and b/.config/waybar/scripts/cert_redirect.so differ
diff --git a/.config/waybar/scripts/codexbar-popup.py b/.config/waybar/scripts/codexbar-popup.py
new file mode 100755
index 0000000..8a158d3
--- /dev/null
+++ b/.config/waybar/scripts/codexbar-popup.py
@@ -0,0 +1,1421 @@
+#!/usr/bin/env python3
+"""GTK4 popover for CodexBar Linux CLI.
+
+Mirrors the macOS CodexBar menu popover: a provider tab strip at the top,
+the active provider's usage windows shown as flat sections separated by
+hairline dividers, no card boxes, thin progress bars, light translucent
+background, dark text.
+
+Anchored top-right via gtk4-layer-shell. Reads the cached last.json for
+instant paint, then refetches in the background.
+"""
+
+from __future__ import annotations
+
+import datetime
+import json
+import os
+import signal
+import subprocess
+import sys
+from pathlib import Path
+from threading import Thread
+
+# gtk4-layer-shell must load before libwayland-client; re-exec with LD_PRELOAD.
+# Override the lib location with CODEXBAR_LAYER_SHELL_LIB if needed.
+_LAYER_SHELL_LIB_CANDIDATES = [
+ os.environ.get("CODEXBAR_LAYER_SHELL_LIB", ""),
+ "/usr/lib/libgtk4-layer-shell.so", # Arch
+ "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so", # Debian / Ubuntu
+ "/usr/lib64/libgtk4-layer-shell.so", # Fedora
+ "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so",
+]
+_LAYER_SHELL_LIB = next((p for p in _LAYER_SHELL_LIB_CANDIDATES if p and os.path.exists(p)), "")
+if os.environ.get("CODEXBAR_POPUP_PRELOADED") != "1" and _LAYER_SHELL_LIB:
+ env = dict(os.environ)
+ existing = env.get("LD_PRELOAD", "")
+ env["LD_PRELOAD"] = f"{_LAYER_SHELL_LIB}:{existing}" if existing else _LAYER_SHELL_LIB
+ env["CODEXBAR_POPUP_PRELOADED"] = "1"
+ os.execve(sys.executable, [sys.executable, *sys.argv], env)
+
+import re # noqa: E402
+
+import gi
+
+gi.require_version("Gtk", "4.0")
+gi.require_version("Gtk4LayerShell", "1.0")
+
+from gi.repository import GLib, Gtk, Gtk4LayerShell # noqa: E402
+
+CODEXBAR = os.environ.get("CODEXBAR_BIN", str(Path.home() / ".local/bin/codexbar"))
+CACHE = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "codexbar-waybar"
+LAST_GOOD = CACHE / "last.json"
+SCRIPT_DIR = Path(__file__).resolve().parent
+WRAPPER = SCRIPT_DIR / "codexbar.sh"
+
+PROVIDER_NAMES = {
+ "abacus": "Abacus AI",
+ "alibaba": "Alibaba",
+ "alibabatokenplan": "Alibaba Token Plan",
+ "amp": "Amp",
+ "antigravity": "Antigravity",
+ "augment": "Augment",
+ "azureopenai": "Azure OpenAI",
+ "bedrock": "AWS Bedrock",
+ "chutes": "Chutes",
+ "clawrouter": "ClawRouter",
+ "codebuff": "Codebuff",
+ "codex": "Codex",
+ "claude": "Claude",
+ "commandcode": "Command Code",
+ "copilot": "Copilot",
+ "crof": "Crof",
+ "crossmodel": "CrossModel",
+ "cursor": "Cursor",
+ "deepgram": "Deepgram",
+ "deepseek": "DeepSeek",
+ "devin": "Devin",
+ "doubao": "Doubao",
+ "elevenlabs": "ElevenLabs",
+ "factory": "Droid",
+ "gemini": "Gemini",
+ "grok": "Grok",
+ "groq": "Groq",
+ "jetbrains": "JetBrains AI",
+ "kilo": "Kilo",
+ "kimi": "Kimi",
+ "kimik2": "Kimi K2",
+ "kiro": "Kiro",
+ "litellm": "LiteLLM",
+ "llmproxy": "LLM Proxy",
+ "manus": "Manus",
+ "mimo": "Xiaomi MiMo",
+ "minimax": "MiniMax",
+ "mistral": "Mistral",
+ "moonshot": "Moonshot / Kimi API",
+ "ollama": "Ollama",
+ "openai": "OpenAI",
+ "opencode": "OpenCode",
+ "opencodego": "OpenCode Go",
+ "openrouter": "OpenRouter",
+ "perplexity": "Perplexity",
+ "poe": "Poe",
+ "qoder": "Qoder",
+ "sakana": "Sakana AI",
+ "stepfun": "StepFun",
+ "synthetic": "Synthetic",
+ "t3chat": "T3 Chat",
+ "venice": "Venice",
+ "vertexai": "Vertex AI",
+ "warp": "Warp",
+ "windsurf": "Windsurf",
+ "zai": "z.ai",
+ "zed": "Zed",
+}
+
+WINDOW_LABELS = {
+ "primary": "Session",
+ "secondary": "Weekly",
+ "tertiary": "Monthly",
+}
+
+# Provider id → icon filename (without the "ProviderIcon-" prefix and ".svg").
+# Most providers map to their own id; a few share an icon upstream.
+PROVIDER_ICON_ALIAS = {
+ "openai": "codex",
+ "azureopenai": "codex",
+ "alibabatokenplan": "alibaba",
+ "moonshot": "kimi",
+ "kimik2": "kimi",
+}
+
+CONFIG_PATH = Path.home() / ".codexbar" / "config.json"
+STATE_PATH = Path(
+ os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
+) / "codexbar-waybar" / "state.json"
+ICONS_DIR = Path(
+ os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))
+) / "codexbar-waybar" / "icons"
+
+# CSS mirrors the macOS menu popover: light translucent panel, dark text,
+# thin hairline dividers, no card boxes, restrained accent only on the
+# active provider tab.
+CSS = b"""
+/* The window itself stays transparent so the root box can paint rounded corners. */
+window.codexbar-popup {
+ background-color: transparent;
+ background-image: none;
+}
+
+.codexbar-root {
+ background-color: #ffffff;
+ background-image: none;
+ color: #111111;
+ border-radius: 14px;
+ border: 1px solid #d0d0d0;
+ padding: 0;
+ min-width: 360px;
+}
+
+/* Force every child of the root to inherit the white panel (Adwaita ships a lot
+ of toolbar/headerbar styling that paints over our background). */
+.codexbar-root > * {
+ background-color: #ffffff;
+ background-image: none;
+}
+
+/* --- Tab strip --- */
+.codexbar-tabbar {
+ background-color: #ffffff;
+ padding: 8px 10px 6px 10px;
+ border-bottom: 1px solid #e5e5e5;
+ border-top-left-radius: 14px;
+ border-top-right-radius: 14px;
+}
+/* Tabs are clickable Boxes (not Gtk.Button) so the GTK theme can't impose
+ its own button background. Labels inside inherit the box's colour. */
+.codexbar-tab {
+ padding: 5px 12px;
+ border-radius: 8px;
+ color: #6b6b6b;
+ font-size: 12px;
+ font-weight: 600;
+ background-color: transparent;
+}
+.codexbar-tab:hover {
+ background-color: #ececec;
+ color: #111111;
+}
+.codexbar-tab.active,
+.codexbar-tab.active:hover {
+ background-color: #0a84ff;
+ color: #ffffff;
+}
+.codexbar-tab label { color: inherit; font-size: 12px; font-weight: 600; }
+
+.codexbar-iconbtn {
+ padding: 5px 9px;
+ border-radius: 8px;
+ color: #6b6b6b;
+ font-size: 13px;
+ background-color: transparent;
+}
+.codexbar-iconbtn:hover {
+ background-color: #ececec;
+ color: #111111;
+}
+.codexbar-iconbtn label { color: inherit; font-size: 13px; }
+
+/* --- Body --- */
+.codexbar-body {
+ background-color: #ffffff;
+ padding: 14px 18px 6px 18px;
+}
+
+.codexbar-provider-title {
+ font-size: 18px;
+ font-weight: 700;
+ color: #111111;
+}
+.codexbar-plan {
+ font-size: 11px;
+ font-weight: 600;
+ color: #6b6b6b;
+}
+.codexbar-subtitle {
+ font-size: 11px;
+ color: #6b6b6b;
+}
+.codexbar-divider {
+ background-color: #e5e5e5;
+ min-height: 1px;
+ margin: 12px 0;
+}
+.codexbar-section-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #111111;
+ margin-bottom: 6px;
+}
+.codexbar-section-detail-left {
+ font-size: 11px;
+ color: #2b2b2b;
+ font-feature-settings: "tnum";
+}
+.codexbar-section-detail-right {
+ font-size: 11px;
+ color: #6b6b6b;
+}
+.codexbar-credits {
+ font-size: 13px;
+ color: #111111;
+ font-feature-settings: "tnum";
+ font-weight: 600;
+}
+.codexbar-credits-label {
+ font-size: 11px;
+ color: #6b6b6b;
+}
+.codexbar-error {
+ font-size: 12px;
+ color: #c53030;
+}
+
+/* --- Footer --- */
+.codexbar-footer {
+ background-color: #ffffff;
+ padding: 7px 10px 9px 10px;
+ border-top: 1px solid #e5e5e5;
+ border-bottom-left-radius: 14px;
+ border-bottom-right-radius: 14px;
+}
+.codexbar-footer-btn {
+ padding: 4px 10px;
+ border-radius: 6px;
+ color: #2b2b2b;
+ font-size: 12px;
+ background-color: transparent;
+}
+.codexbar-footer-btn:hover {
+ background-color: #ececec;
+ color: #111111;
+}
+.codexbar-footer-btn label { color: inherit; font-size: 12px; }
+
+/* --- Settings view --- */
+.codexbar-settings-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #111111;
+}
+.codexbar-bar-picker {
+ background-color: #ffffff;
+ padding: 4px 0 8px 0;
+}
+.codexbar-provider-icon {
+ -gtk-icon-size: 18px;
+ margin: 0 2px;
+}
+.codexbar-settings-list {
+ background-color: #ffffff;
+}
+.codexbar-settings-row {
+ padding: 8px 0;
+ border-bottom: 1px solid #f0f0f0;
+}
+.codexbar-settings-row.disabled .codexbar-settings-name {
+ color: #9a9a9a;
+}
+.codexbar-settings-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: #111111;
+}
+.codexbar-settings-hint {
+ font-size: 11px;
+ color: #9a9a9a;
+}
+.codexbar-settings-group {
+ font-size: 11px;
+ font-weight: 600;
+ color: #6b6b6b;
+ padding: 14px 0 4px 0;
+}
+
+/* --- Progress bar: thin pill, gray track, system-blue fill --- */
+levelbar.codex-usage {
+ background-color: transparent;
+}
+levelbar.codex-usage trough {
+ background-color: transparent;
+ background-image: none;
+ padding: 0;
+ min-height: 4px;
+ border: none;
+}
+levelbar.codex-usage block.filled {
+ background-color: #0a84ff;
+ background-image: none;
+ min-height: 4px;
+ border-radius: 2px;
+ border: none;
+}
+levelbar.codex-usage.warning block.filled { background-color: #ff9f0a; }
+levelbar.codex-usage.critical block.filled { background-color: #ff453a; }
+levelbar.codex-usage block.empty {
+ background-color: #e5e5e5;
+ background-image: none;
+ min-height: 4px;
+ border-radius: 2px;
+ border: none;
+}
+"""
+
+
+def load_cached() -> list:
+ if LAST_GOOD.exists():
+ try:
+ return json.loads(LAST_GOOD.read_text())
+ except json.JSONDecodeError:
+ return []
+ return []
+
+
+def load_state() -> dict:
+ if STATE_PATH.exists():
+ try:
+ return json.loads(STATE_PATH.read_text())
+ except json.JSONDecodeError:
+ return {}
+ return {}
+
+
+def save_state(state: dict) -> None:
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ STATE_PATH.write_text(json.dumps(state, indent=2) + "\n")
+
+
+_ICON_CACHE: dict[str, Path] = {}
+
+
+def resolve_icon_path(pid: str) -> Path | None:
+ """Return a recoloured copy of the provider SVG (dark text colour) so it
+ renders against the popup's light background. Upstream SVGs use
+ `fill=\"white\"`; we substitute that with our theme dark and cache."""
+ name = PROVIDER_ICON_ALIAS.get(pid, pid)
+ if name in _ICON_CACHE:
+ return _ICON_CACHE[name]
+ src = ICONS_DIR / f"ProviderIcon-{name}.svg"
+ if not src.exists():
+ return None
+ out_dir = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "codexbar-waybar" / "icons"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out = out_dir / f"{name}.svg"
+ try:
+ svg = src.read_text()
+ # Recolour mask-style SVGs (single white path) to dark theme text.
+ recoloured = svg.replace('fill="white"', 'fill="#1c1c1e"') \
+ .replace("fill='white'", "fill='#1c1c1e'") \
+ .replace('fill="#ffffff"', 'fill="#1c1c1e"') \
+ .replace('fill="#FFFFFF"', 'fill="#1c1c1e"')
+ out.write_text(recoloured)
+ _ICON_CACHE[name] = out
+ return out
+ except OSError:
+ return None
+
+
+def make_icon(pid: str, size: int = 18) -> Gtk.Widget | None:
+ path = resolve_icon_path(pid)
+ if path is None:
+ return None
+ img = Gtk.Image.new_from_file(str(path))
+ img.set_pixel_size(size)
+ img.add_css_class("codexbar-provider-icon")
+ return img
+
+
+_RESET_SPACE_AFTER = re.compile(r"^([Rr]esets)(?=\S)")
+_RESET_SPACE_BEFORE_PAREN = re.compile(r"(?<=\S)\(")
+_RESET_SPACE_AFTER_COMMA = re.compile(r",(?=\S)")
+_RESET_SPACE_BEFORE_AMPM = re.compile(r"(?<=\d)(?=[AaPp][Mm]\b)")
+_RESET_STARTS_WITH_RESETS = re.compile(r"^[Rr]esets")
+_RESET_RELATIVE = re.compile(r"^[Rr]esets in ")
+
+RESET_FORMATS = ("provider", "local", "utc")
+
+
+def normalize_reset_description(text: str) -> str:
+ """Mirror codexbar.sh's reset normalisation. Handles both Claude OAuth
+ (\"May 17 at 6:20AM\") and Claude CLI (\"Resets6:20am(Europe/Paris)\")
+ by inserting the spaces the providers omit."""
+ if not text:
+ return text
+ text = _RESET_SPACE_AFTER.sub(r"\1 ", text)
+ text = _RESET_SPACE_BEFORE_PAREN.sub(" (", text)
+ text = _RESET_SPACE_AFTER_COMMA.sub(", ", text)
+ text = _RESET_SPACE_BEFORE_AMPM.sub(" ", text)
+ return text
+
+
+def current_reset_format(state: dict | None = None) -> str:
+ """Resolve the active reset time format. Env var overrides state.json;
+ unknown values fall back to `provider` (current behavior)."""
+ env = os.environ.get("CODEXBAR_RESET_TIME_FORMAT")
+ if env in RESET_FORMATS:
+ return env
+ if state is None:
+ state = load_state()
+ value = state.get("resetTimeFormat")
+ return value if value in RESET_FORMATS else "provider"
+
+
+def _from_description(desc: str) -> str:
+ if not desc:
+ return ""
+ return desc if _RESET_STARTS_WITH_RESETS.match(desc) else f"Resets {desc}"
+
+
+def format_reset_label(window: dict, mode: str) -> str:
+ """Render the reset label for a usage window in the chosen format.
+
+ Mirrors `reset_phrase` in codexbar.sh so the popover and tooltip never
+ drift. Returns a string like "Resets 6:12 PM CDT" or "" for no info.
+ Relative phrases ("Resets in 2 hours") are preserved even in absolute
+ modes, since "in 2 hours" is more useful than a wall-clock time.
+ """
+ clean = normalize_reset_description(window.get("resetDescription") or "")
+ from_desc = _from_description(clean)
+ if mode == "provider":
+ return from_desc
+ if _RESET_RELATIVE.match(clean):
+ return from_desc
+ resets_at = window.get("resetsAt")
+ if not resets_at:
+ return from_desc
+ try:
+ ts = datetime.datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
+ except (TypeError, ValueError):
+ return from_desc
+ if mode == "utc":
+ ts = ts.astimezone(datetime.timezone.utc)
+ tz_suffix = "UTC"
+ else:
+ ts = ts.astimezone()
+ tz_suffix = ts.tzname() or ""
+ now = datetime.datetime.now(ts.tzinfo)
+ if ts.date() == now.date():
+ body = ts.strftime("%-I:%M %p")
+ elif ts.year == now.year:
+ body = ts.strftime("%b %-d at %-I:%M %p")
+ else:
+ body = ts.strftime("%b %-d %Y at %-I:%M %p")
+ return f"Resets {body} {tz_suffix}".rstrip()
+
+
+def fetch_fresh() -> list:
+ try:
+ subprocess.run([str(WRAPPER)], check=False, capture_output=True, timeout=30)
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+ return load_cached()
+
+
+def max_pct(entry: dict) -> int:
+ if entry.get("error"):
+ return 0
+ usage = entry.get("usage") or {}
+ pcts = [
+ (usage.get(k) or {}).get("usedPercent")
+ for k in ("primary", "secondary", "tertiary")
+ ]
+ pcts = [p for p in pcts if isinstance(p, (int, float))]
+ return int(max(pcts)) if pcts else 0
+
+
+def provider_label(pid: str) -> str:
+ return PROVIDER_NAMES.get(pid, pid.replace("-", " ").title())
+
+
+def entry_key(entry: dict) -> str:
+ provider = entry.get("provider") or "unknown"
+ account = entry.get("account")
+ if account:
+ return f"{provider}\0{account}"
+ identity = ((entry.get("usage") or {}).get("identity") or {})
+ identity_account = identity.get("accountEmail") or identity.get("accountOrganization")
+ if identity_account:
+ return f"{provider}\0{identity_account}"
+ return str(provider)
+
+
+def entry_label(entry: dict, all_entries: list | None = None) -> str:
+ pid = str(entry.get("provider") or "unknown")
+ label = provider_label(pid)
+ if all_entries is not None:
+ duplicates = sum(1 for other in all_entries if other.get("provider") == pid)
+ if duplicates <= 1:
+ return label
+ account = entry.get("account")
+ identity = ((entry.get("usage") or {}).get("identity") or {})
+ account = account or identity.get("accountEmail") or identity.get("accountOrganization")
+ return f"{label} · {account}" if account else label
+
+
+def money(value: float, currency: str = "USD") -> str:
+ symbol = "$" if currency.upper() == "USD" else f"{currency.upper()} "
+ return f"{symbol}{value:,.2f}"
+
+
+def compact_number(value: int | float) -> str:
+ if isinstance(value, int) or float(value).is_integer():
+ return f"{int(value):,}"
+ return f"{float(value):,.2f}"
+
+
+def parse_iso_datetime(value: str | None) -> datetime.datetime | None:
+ if not value:
+ return None
+ try:
+ return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except (TypeError, ValueError):
+ return None
+
+
+def format_datetime_label(value: str | None) -> str:
+ ts = parse_iso_datetime(value)
+ if ts is None:
+ return ""
+ ts = ts.astimezone()
+ now = datetime.datetime.now(ts.tzinfo)
+ if ts.date() == now.date():
+ return ts.strftime("%-I:%M %p %Z")
+ if ts.year == now.year:
+ return ts.strftime("%b %-d at %-I:%M %p %Z")
+ return ts.strftime("%b %-d %Y at %-I:%M %p %Z")
+
+
+def summarize_status(entry: dict) -> str | None:
+ status = entry.get("status")
+ if not isinstance(status, dict):
+ return None
+ indicator = status.get("indicator")
+ description = status.get("description")
+ if not indicator and not description:
+ return None
+ label = str(indicator or "unknown").replace("_", " ").title()
+ return f"{label}: {description}" if description else label
+
+
+def summarize_pace(entry: dict) -> list[str]:
+ pace = entry.get("pace")
+ if not isinstance(pace, dict):
+ return []
+ lines: list[str] = []
+ for key, title in (("primary", "Session pace"), ("secondary", "Weekly pace")):
+ data = pace.get(key)
+ if isinstance(data, dict) and data.get("summary"):
+ lines.append(f"{title}: {data['summary']}")
+ return lines
+
+
+def summarize_provider_cost(usage: dict) -> str | None:
+ cost = usage.get("providerCost")
+ if not isinstance(cost, dict):
+ return None
+ used = cost.get("used")
+ limit = cost.get("limit")
+ currency = str(cost.get("currencyCode") or "USD")
+ period = cost.get("period") or "Budget"
+ if isinstance(used, (int, float)) and isinstance(limit, (int, float)) and limit > 0:
+ return f"{period}: {money(float(used), currency)} / {money(float(limit), currency)}"
+ if isinstance(used, (int, float)):
+ return f"{period}: {money(float(used), currency)} used"
+ return None
+
+
+def summarize_reset_credits(usage: dict) -> str | None:
+ snapshot = usage.get("codexResetCredits")
+ if not isinstance(snapshot, dict):
+ return None
+ credits = [
+ c for c in snapshot.get("credits", [])
+ if isinstance(c, dict) and c.get("status") == "available"
+ ]
+ available = snapshot.get("availableCount")
+ count = int(available) if isinstance(available, int) else len(credits)
+ expiring = sorted(
+ (c for c in credits if c.get("expires_at")),
+ key=lambda c: c.get("expires_at") or "")
+ if expiring:
+ expiry = format_datetime_label(expiring[0].get("expires_at"))
+ if expiry:
+ return f"{count} available; next expires {expiry}"
+ if count:
+ return f"{count} available; no expiry"
+ return "None available"
+
+
+def summarize_credit_limit(limit: dict | None) -> str | None:
+ if not isinstance(limit, dict):
+ return None
+ title = limit.get("title") or "Monthly credit limit"
+ used = limit.get("used")
+ cap = limit.get("limit")
+ remaining = limit.get("remaining")
+ if isinstance(used, (int, float)) and isinstance(cap, (int, float)) and cap > 0:
+ return f"{title}: {money(float(used))} / {money(float(cap))}"
+ if isinstance(remaining, (int, float)):
+ return f"{title}: {money(float(remaining))} remaining"
+ return None
+
+
+def summarize_openai_dashboard(entry: dict) -> list[str]:
+ dashboard = entry.get("openaiDashboard")
+ if not isinstance(dashboard, dict):
+ return []
+ lines: list[str] = []
+ account_plan = dashboard.get("accountPlan")
+ if account_plan:
+ lines.append(f"Plan: {account_plan}")
+ credits = dashboard.get("creditsRemaining")
+ if isinstance(credits, (int, float)):
+ lines.append(f"Dashboard credits: {money(float(credits))}")
+ limit = summarize_credit_limit(dashboard.get("codexCreditLimit"))
+ if limit:
+ lines.append(limit)
+ breakdown = dashboard.get("usageBreakdown") or dashboard.get("dailyBreakdown") or []
+ if isinstance(breakdown, list) and breakdown:
+ recent = breakdown[:7]
+ total = sum(
+ float(day.get("totalCreditsUsed", 0))
+ for day in recent
+ if isinstance(day, dict) and isinstance(day.get("totalCreditsUsed"), (int, float))
+ )
+ if total > 0:
+ lines.append(f"Recent dashboard spend: {money(total)} over {len(recent)} days")
+ return lines
+
+
+def summarize_usage_details(usage: dict) -> list[str]:
+ lines: list[str] = []
+ cost = summarize_provider_cost(usage)
+ if cost:
+ lines.append(cost)
+
+ openrouter = usage.get("openRouterUsage")
+ if isinstance(openrouter, dict):
+ balance = openrouter.get("balance")
+ total_usage = openrouter.get("totalUsage")
+ if isinstance(balance, (int, float)):
+ text = f"OpenRouter balance: {money(float(balance))}"
+ if isinstance(total_usage, (int, float)):
+ text += f" ({money(float(total_usage))} used)"
+ lines.append(text)
+
+ sakana = usage.get("sakanaPayAsYouGo")
+ if isinstance(sakana, dict):
+ balance = sakana.get("creditBalance")
+ total = sakana.get("periodUsageTotal")
+ if isinstance(balance, (int, float)):
+ text = f"Pay-as-you-go balance: {money(float(balance))}"
+ if isinstance(total, (int, float)):
+ text += f"; {money(float(total))} used"
+ lines.append(text)
+
+ openai_api = usage.get("openAIAPIUsage")
+ if isinstance(openai_api, dict):
+ daily = openai_api.get("daily")
+ if isinstance(daily, list) and daily:
+ total_cost = sum(
+ float(day.get("costUSD", 0))
+ for day in daily
+ if isinstance(day, dict) and isinstance(day.get("costUSD"), (int, float))
+ )
+ requests = sum(
+ int(day.get("requests", 0))
+ for day in daily
+ if isinstance(day, dict) and isinstance(day.get("requests"), int)
+ )
+ lines.append(f"API history: {money(total_cost)} across {requests:,} requests")
+
+ reset_credits = summarize_reset_credits(usage)
+ if reset_credits:
+ lines.append(f"Reset credits: {reset_credits}")
+
+ if usage.get("commandCodeSubscriptionEnrichmentUnavailable"):
+ lines.append("Subscription lookup unavailable")
+ if usage.get("commandCodeMonthlyGrantDepleted"):
+ lines.append("Monthly grant depleted")
+
+ expires = format_datetime_label(usage.get("subscriptionExpiresAt"))
+ renews = format_datetime_label(usage.get("subscriptionRenewsAt"))
+ if renews:
+ lines.append(f"Subscription renews {renews}")
+ elif expires:
+ lines.append(f"Subscription expires {expires}")
+ return lines
+
+
+def default_provider(data: list, state: dict | None = None) -> str | None:
+ """Pick the configured popup provider, or the highest used% as fallback."""
+ if not data:
+ return None
+ if state is None:
+ state = load_state()
+ preferred = state.get("popupProvider")
+ if isinstance(preferred, str) and preferred:
+ matches = [entry for entry in data if entry.get("provider") == preferred]
+ if matches:
+ healthy_matches = [entry for entry in matches if not entry.get("error")]
+ return entry_key((healthy_matches or matches)[0])
+ healthy = [entry for entry in data if not entry.get("error")]
+ pool = healthy or data
+ return entry_key(max(pool, key=max_pct))
+
+def load_full_config() -> dict:
+ """Returns the canonical config (every provider known to the CLI, with the
+ current enabled flag merged in). Uses `codexbar config dump` so the schema
+ stays in sync with the CLI version that's actually installed."""
+ try:
+ result = subprocess.run(
+ [CODEXBAR, "config", "dump"],
+ capture_output=True, text=True, timeout=5)
+ if result.returncode == 0 and result.stdout.strip():
+ return json.loads(result.stdout)
+ except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
+ pass
+ # Fallback: read whatever's on disk.
+ if CONFIG_PATH.exists():
+ try:
+ return json.loads(CONFIG_PATH.read_text())
+ except json.JSONDecodeError:
+ pass
+ return {"providers": [], "version": 1}
+
+
+def save_config(enabled: dict[str, bool]) -> None:
+ """Write only the providers we want enabled. The CLI fills in defaults for
+ any provider missing from the file, so we don't need to list disabled ones."""
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "providers": [{"id": pid, "enabled": True} for pid, on in enabled.items() if on],
+ "version": 1,
+ }
+ CONFIG_PATH.write_text(json.dumps(payload, indent=2) + "\n")
+
+
+def open_text_file(path: str) -> None:
+ """Open a file in a real text editor.
+
+ Resolution order (first hit wins):
+ 1. $CODEXBAR_EDITOR — explicit override (graphical command line).
+ 2. $VISUAL / $EDITOR — terminal editor, opened in a detected terminal.
+ 3. Common GUI editors discovered on PATH.
+ 4. xdg-open as a last resort (which is what was wrong before — it sends
+ JSON to the browser on most setups).
+ """
+ explicit = os.environ.get("CODEXBAR_EDITOR")
+ if explicit:
+ subprocess.Popen([*explicit.split(), path])
+ return
+
+ gui_editors = [
+ "code", "codium", "code-oss",
+ "zed",
+ "gnome-text-editor", "gedit", "kate", "mousepad", "xed", "leafpad",
+ "sublime_text", "subl",
+ ]
+ for editor in gui_editors:
+ which = subprocess.run(["which", editor], capture_output=True, text=True)
+ if which.returncode == 0 and which.stdout.strip():
+ subprocess.Popen([editor, path])
+ return
+
+ terminal_editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
+ if terminal_editor:
+ terminals = [
+ ("kitty", ["kitty", "-e"]),
+ ("alacritty", ["alacritty", "-e"]),
+ ("foot", ["foot"]),
+ ("wezterm", ["wezterm", "start", "--"]),
+ ("gnome-terminal", ["gnome-terminal", "--"]),
+ ("konsole", ["konsole", "-e"]),
+ ("xterm", ["xterm", "-e"]),
+ ]
+ for term, cmd in terminals:
+ which = subprocess.run(["which", term], capture_output=True, text=True)
+ if which.returncode == 0:
+ subprocess.Popen([*cmd, *terminal_editor.split(), path])
+ return
+
+ # Last resort. Usually opens the browser for .json — which is exactly what
+ # we were trying to avoid — but better than silently failing.
+ subprocess.Popen(["xdg-open", path])
+
+
+class CodexBarPopup(Gtk.Application):
+ def __init__(self):
+ super().__init__(application_id="dev.codexbar.linux.popup")
+ self.window: Gtk.Window | None = None
+ self.data: list = []
+ self.active_pid: str | None = None
+ self.tab_buttons: dict[str, Gtk.Button] = {}
+ self.view: str = "usage" # "usage" | "settings"
+ self.settings_switches: dict[str, Gtk.Switch] = {}
+
+ def do_activate(self): # noqa: N802
+ if self.window is None:
+ self.window = self.build_window()
+ self.window.present()
+
+ def _make_pill(self, label: str, css_classes: list[str], on_click,
+ *, icon_pid: str | None = None) -> Gtk.Widget:
+ """A clickable pill made from Gtk.Box + Gtk.Label so we bypass
+ Gtk.Button styling. Optionally prefixes a provider SVG icon."""
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ box.set_css_classes(css_classes)
+ if icon_pid:
+ icon = make_icon(icon_pid, size=14)
+ if icon is not None:
+ box.append(icon)
+ lbl = Gtk.Label(label=label)
+ box.append(lbl)
+ gesture = Gtk.GestureClick()
+ gesture.connect("released", lambda _g, _n, _x, _y: on_click())
+ box.add_controller(gesture)
+ return box
+
+ def build_window(self) -> Gtk.Window:
+ provider = Gtk.CssProvider()
+ provider.load_from_data(CSS)
+ Gtk.StyleContext.add_provider_for_display(
+ Gtk.Window().get_display(),
+ provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
+ )
+
+ win = Gtk.Window(application=self)
+ win.add_css_class("codexbar-popup")
+ win.set_decorated(False)
+ win.set_resizable(False)
+
+ Gtk4LayerShell.init_for_window(win)
+ Gtk4LayerShell.set_layer(win, Gtk4LayerShell.Layer.OVERLAY)
+ Gtk4LayerShell.set_anchor(win, Gtk4LayerShell.Edge.TOP, True)
+ Gtk4LayerShell.set_anchor(win, Gtk4LayerShell.Edge.RIGHT, True)
+ Gtk4LayerShell.set_margin(win, Gtk4LayerShell.Edge.TOP, 6)
+ Gtk4LayerShell.set_margin(win, Gtk4LayerShell.Edge.RIGHT, 8)
+ Gtk4LayerShell.set_keyboard_mode(win, Gtk4LayerShell.KeyboardMode.ON_DEMAND)
+
+ ctrl = Gtk.EventControllerKey()
+ ctrl.connect("key-pressed", self._on_key)
+ win.add_controller(ctrl)
+
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ root.add_css_class("codexbar-root")
+ win.set_child(root)
+
+ self.tabbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
+ self.tabbar.add_css_class("codexbar-tabbar")
+ root.append(self.tabbar)
+
+ self.body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.body.add_css_class("codexbar-body")
+ root.append(self.body)
+
+ footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
+ footer.add_css_class("codexbar-footer")
+ footer.append(self._make_pill("Settings…", ["codexbar-footer-btn"], self._on_settings_call))
+ footer.append(Gtk.Box(hexpand=True))
+ footer.append(self._make_pill("About", ["codexbar-footer-btn"], self._on_about_call))
+ footer.append(self._make_pill("Quit", ["codexbar-footer-btn"], self.quit))
+ root.append(footer)
+
+ self.data = load_cached()
+ self.active_pid = default_provider(self.data)
+ if os.environ.get("CODEXBAR_INITIAL_VIEW") == "settings":
+ self.view = "settings"
+ self.render()
+ self.refresh(background=True)
+ return win
+
+ def _on_key(self, _ctl, keyval, _kc, _state):
+ if keyval == 0xff1b: # Escape
+ self.quit()
+ return True
+ return False
+
+ def _on_settings_call(self):
+ self.view = "settings"
+ self.render()
+
+ def _on_about_call(self):
+ subprocess.Popen(["xdg-open", "https://codexbar.app"])
+
+ def _on_settings_back(self):
+ self.view = "usage"
+ self.render()
+
+ def _on_settings_save(self):
+ enabled = {pid: sw.get_active() for pid, sw in self.settings_switches.items()}
+ save_config(enabled)
+ self.view = "usage"
+ self.render()
+ self.refresh(background=True)
+ # Nudge waybar so the bar reflects the new provider list without
+ # waiting for the next interval. The signal is wired up in codexbar.jsonc.
+ subprocess.Popen(["pkill", "-RTMIN+8", "waybar"])
+
+ def refresh(self, *, background: bool):
+ def worker():
+ new_data = fetch_fresh()
+ GLib.idle_add(self._apply_refresh, new_data)
+ if background:
+ Thread(target=worker, daemon=True).start()
+ else:
+ self._apply_refresh(fetch_fresh())
+
+ def _apply_refresh(self, new_data: list) -> bool:
+ self.data = new_data
+ if self.active_pid is None or not any(entry_key(e) == self.active_pid for e in new_data):
+ self.active_pid = default_provider(new_data)
+ self.render()
+ return False
+
+ def render(self):
+ self._clear(self.tabbar)
+ self._clear(self.body)
+ if self.view == "settings":
+ self._render_settings_header()
+ self._render_settings_body()
+ return
+ self._render_usage_header()
+ self._render_usage_body()
+
+ def _render_usage_header(self):
+ if not self.data:
+ self.tabbar.append(Gtk.Label(label="Loading…"))
+ return
+ self.tab_buttons.clear()
+ for entry in self.data:
+ pid = entry.get("provider", "")
+ key = entry_key(entry)
+ classes = ["codexbar-tab"]
+ if key == self.active_pid:
+ classes.append("active")
+ pill = self._make_pill(
+ entry_label(entry, self.data),
+ classes,
+ lambda k=key: self._select(k),
+ icon_pid=pid)
+ self.tabbar.append(pill)
+ self.tab_buttons[key] = pill
+ self.tabbar.append(Gtk.Box(hexpand=True))
+ self.tabbar.append(self._make_pill(
+ "↻", ["codexbar-iconbtn"], lambda: self.refresh(background=True)))
+ self.tabbar.append(self._make_pill(
+ "✕", ["codexbar-iconbtn"], self.quit))
+
+ def _render_usage_body(self):
+ if not self.data:
+ return
+ active = next((e for e in self.data if entry_key(e) == self.active_pid), None)
+ if active is None:
+ return
+ self._render_provider(active)
+
+ def _render_settings_header(self):
+ back = self._make_pill("← Back", ["codexbar-tab"], self._on_settings_back)
+ self.tabbar.append(back)
+ title = Gtk.Label(label="Settings", xalign=0.0, hexpand=True)
+ title.add_css_class("codexbar-settings-title")
+ self.tabbar.append(title)
+ save = self._make_pill("Save", ["codexbar-tab", "active"], self._on_settings_save)
+ self.tabbar.append(save)
+
+ def _render_settings_body(self):
+ self.settings_switches.clear()
+ cfg = load_full_config()
+ existing = {p.get("id"): bool(p.get("enabled")) for p in cfg.get("providers", [])}
+
+ # --- Section: which provider shows in the bar ---
+ bar_title = Gtk.Label(label="Show in bar", xalign=0.0)
+ bar_title.add_css_class("codexbar-section-title")
+ self.body.append(bar_title)
+ bar_hint = Gtk.Label(
+ label="Pick a provider to pin to the bar (session • weekly), or leave on Highest.",
+ xalign=0.0, wrap=True, max_width_chars=44)
+ bar_hint.add_css_class("codexbar-subtitle")
+ self.body.append(bar_hint)
+ self.body.append(self._build_bar_provider_picker(existing))
+ self.body.append(self._divider())
+
+ # --- Section: which provider is selected when the popup opens ---
+ popup_title = Gtk.Label(label="Open popup on", xalign=0.0)
+ popup_title.add_css_class("codexbar-section-title")
+ self.body.append(popup_title)
+ popup_hint = Gtk.Label(
+ label="Choose the provider tab selected when the popup opens, or use Highest.",
+ xalign=0.0, wrap=True, max_width_chars=44)
+ popup_hint.add_css_class("codexbar-subtitle")
+ self.body.append(popup_hint)
+ self.body.append(self._build_popup_provider_picker(existing))
+
+ # Divider between sections.
+ self.body.append(self._divider())
+
+ # --- Section: reset time format ---
+ reset_title = Gtk.Label(label="Reset times", xalign=0.0)
+ reset_title.add_css_class("codexbar-section-title")
+ self.body.append(reset_title)
+ reset_hint = Gtk.Label(
+ label="How to render the “Resets …” label. Provider keeps the raw "
+ "string each backend emits; Local/UTC reformat the reset "
+ "timestamp with an explicit timezone.",
+ xalign=0.0, wrap=True, max_width_chars=44)
+ reset_hint.add_css_class("codexbar-subtitle")
+ self.body.append(reset_hint)
+ self.body.append(self._build_reset_format_picker())
+
+ # Divider between sections.
+ self.body.append(self._divider())
+
+ # --- Section: enabled providers ---
+ section_title = Gtk.Label(label="Providers", xalign=0.0)
+ section_title.add_css_class("codexbar-section-title")
+ self.body.append(section_title)
+ section_hint = Gtk.Label(
+ label="Toggle which providers feed the bar and the popup.",
+ xalign=0.0, wrap=True)
+ section_hint.add_css_class("codexbar-subtitle")
+ self.body.append(section_hint)
+
+ # Scrollable list.
+ scroller = Gtk.ScrolledWindow()
+ scroller.set_min_content_height(280)
+ scroller.set_propagate_natural_width(True)
+ scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ list_box.add_css_class("codexbar-settings-list")
+ scroller.set_child(list_box)
+ self.body.append(scroller)
+
+ # The Linux CLI's config dump is authoritative. If upstream exposes a
+ # provider there, keep it selectable here.
+ provider_ids = [p.get("id") for p in cfg.get("providers", [])]
+ supported = sorted(p for p in provider_ids if isinstance(p, str))
+
+ for pid in supported:
+ list_box.append(self._settings_row(pid, existing.get(pid, False), enabled_ui=True))
+
+ # Footer note.
+ note = Gtk.Label(
+ label=f"Config: {CONFIG_PATH}",
+ xalign=0.0, wrap=True)
+ note.add_css_class("codexbar-subtitle")
+ self.body.append(note)
+
+ def _build_provider_picker(self, existing: dict[str, bool], current: str | None,
+ on_change) -> Gtk.Widget:
+ wrap = Gtk.FlowBox()
+ wrap.add_css_class("codexbar-bar-picker")
+ wrap.set_selection_mode(Gtk.SelectionMode.NONE)
+ wrap.set_homogeneous(False)
+ wrap.set_max_children_per_line(8)
+
+ def make_chip(pid: str | None, label: str):
+ classes = ["codexbar-tab"]
+ if pid == current or (pid is None and not current):
+ classes.append("active")
+ return self._make_pill(
+ label, classes,
+ lambda p=pid: on_change(p),
+ icon_pid=pid)
+
+ wrap.append(make_chip(None, "Highest"))
+ enabled_pids = [pid for pid, enabled in existing.items() if enabled]
+ for pid in enabled_pids:
+ wrap.append(make_chip(pid, provider_label(pid)))
+ return wrap
+
+ def _build_bar_provider_picker(self, existing: dict[str, bool]) -> Gtk.Widget:
+ current = load_state().get("barProvider")
+ return self._build_provider_picker(existing, current, self._on_bar_provider_change)
+
+ def _build_popup_provider_picker(self, existing: dict[str, bool]) -> Gtk.Widget:
+ enabled_pids = [pid for pid, enabled in existing.items() if enabled]
+ current = load_state().get("popupProvider")
+ if current not in enabled_pids:
+ current = None
+ return self._build_provider_picker(existing, current, self._on_popup_provider_change)
+
+ def _on_bar_provider_change(self, pid: str | None):
+ state = load_state()
+ if pid is None:
+ state.pop("barProvider", None)
+ else:
+ state["barProvider"] = pid
+ save_state(state)
+ # Re-render so the active chip highlight tracks the click.
+ self.render()
+ # Nudge waybar so the bar text updates immediately.
+ subprocess.Popen(["pkill", "-RTMIN+8", "waybar"])
+
+ def _on_popup_provider_change(self, pid: str | None):
+ state = load_state()
+ if pid is None:
+ state.pop("popupProvider", None)
+ else:
+ state["popupProvider"] = pid
+ save_state(state)
+ # Re-render so the active chip highlight tracks the click.
+ self.render()
+
+ def _build_reset_format_picker(self) -> Gtk.Widget:
+ wrap = Gtk.FlowBox()
+ wrap.add_css_class("codexbar-bar-picker")
+ wrap.set_selection_mode(Gtk.SelectionMode.NONE)
+ wrap.set_homogeneous(False)
+ wrap.set_max_children_per_line(8)
+ current = current_reset_format()
+ labels = (("provider", "Provider"), ("local", "Local"), ("utc", "UTC"))
+ for value, label in labels:
+ classes = ["codexbar-tab"]
+ if value == current:
+ classes.append("active")
+ wrap.append(self._make_pill(
+ label, classes,
+ lambda v=value: self._on_reset_format_change(v)))
+ return wrap
+
+ def _on_reset_format_change(self, value: str):
+ state = load_state()
+ if value == "provider":
+ state.pop("resetTimeFormat", None)
+ else:
+ state["resetTimeFormat"] = value
+ save_state(state)
+ # Re-render the Settings view so the chip highlight tracks the click,
+ # and signal waybar so the tooltip picks up the new format on its
+ # next refresh.
+ self.render()
+ subprocess.Popen(["pkill", "-RTMIN+8", "waybar"])
+
+ def _settings_row(self, pid: str, enabled: bool, *, enabled_ui: bool) -> Gtk.Widget:
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ row.add_css_class("codexbar-settings-row")
+ if not enabled_ui:
+ row.add_css_class("disabled")
+
+ icon = make_icon(pid, size=18)
+ if icon is not None:
+ row.append(icon)
+
+ name = Gtk.Label(label=provider_label(pid), xalign=0.0, hexpand=True)
+ name.add_css_class("codexbar-settings-name")
+ row.append(name)
+
+ if not enabled_ui:
+ hint = Gtk.Label(label="macOS only", xalign=1.0)
+ hint.add_css_class("codexbar-settings-hint")
+ row.append(hint)
+
+ switch = Gtk.Switch()
+ switch.set_active(enabled)
+ switch.set_sensitive(enabled_ui)
+ switch.set_valign(Gtk.Align.CENTER)
+ row.append(switch)
+ self.settings_switches[pid] = switch
+ return row
+
+ def _select(self, key: str):
+ if key == self.active_pid:
+ return
+ self.active_pid = key
+ self.render()
+
+ def _render_provider(self, entry: dict):
+ pid = entry.get("provider", "?")
+ usage = entry.get("usage") or {}
+ identity = usage.get("identity") or {}
+ email = usage.get("accountEmail") or identity.get("accountEmail")
+ login_method = identity.get("loginMethod") or usage.get("loginMethod")
+
+ # Header row.
+ header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ title = Gtk.Label(label=provider_label(pid), xalign=0.0, hexpand=True)
+ title.add_css_class("codexbar-provider-title")
+ header.append(title)
+ if login_method:
+ plan = Gtk.Label(label=str(login_method).title(), xalign=1.0)
+ plan.add_css_class("codexbar-plan")
+ header.append(plan)
+ self.body.append(header)
+
+ # Subtitle line (status / updated / stale).
+ sub_text = "Updated just now"
+ if entry.get("stale"):
+ sub_text = "Cached — last refresh failed"
+ elif entry.get("error"):
+ sub_text = "Refresh failed"
+ else:
+ status_text = summarize_status(entry)
+ if status_text:
+ sub_text = status_text
+ sub_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ sub = Gtk.Label(label=sub_text, xalign=0.0, hexpand=True)
+ sub.add_css_class("codexbar-subtitle")
+ sub_row.append(sub)
+ if email:
+ email_label = Gtk.Label(label=email, xalign=1.0)
+ email_label.add_css_class("codexbar-subtitle")
+ sub_row.append(email_label)
+ self.body.append(sub_row)
+
+ if entry.get("error"):
+ self.body.append(self._divider())
+ err = Gtk.Label(
+ label=entry["error"].get("message", "Unknown error"),
+ xalign=0.0,
+ wrap=True,
+ max_width_chars=44)
+ err.add_css_class("codexbar-error")
+ self.body.append(err)
+ return
+
+ # Usage windows.
+ rendered_any = False
+ for key in ("primary", "secondary", "tertiary"):
+ window = usage.get(key)
+ if not window:
+ continue
+ self.body.append(self._divider())
+ self.body.append(self._section(WINDOW_LABELS.get(key, key.title()), window))
+ rendered_any = True
+
+ for item in usage.get("extraRateWindows") or []:
+ if not isinstance(item, dict):
+ continue
+ window = item.get("window")
+ if not isinstance(window, dict):
+ continue
+ self.body.append(self._divider())
+ title = item.get("title") or item.get("id") or "Extra quota"
+ self.body.append(self._section(str(title), window, usage_known=item.get("usageKnown", True)))
+ rendered_any = True
+
+ # Credits (when provider exposes it).
+ credits = entry.get("credits") or {}
+ remaining = credits.get("remaining")
+ credit_lines = []
+ if isinstance(remaining, (int, float)):
+ credit_lines.append((money(float(remaining)), "remaining"))
+ limit_line = summarize_credit_limit(credits.get("codexCreditLimit"))
+ if limit_line:
+ credit_lines.append((limit_line, ""))
+ if credit_lines:
+ self.body.append(self._divider())
+ credit_title = Gtk.Label(label="Credits", xalign=0.0)
+ credit_title.add_css_class("codexbar-section-title")
+ self.body.append(credit_title)
+ for value, label in credit_lines:
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ val = Gtk.Label(label=value, xalign=0.0, hexpand=True)
+ val.add_css_class("codexbar-credits")
+ row.append(val)
+ if label:
+ lbl = Gtk.Label(label=label, xalign=1.0)
+ lbl.add_css_class("codexbar-credits-label")
+ row.append(lbl)
+ self.body.append(row)
+ rendered_any = True
+
+ detail_lines = [
+ *summarize_pace(entry),
+ *summarize_openai_dashboard(entry),
+ *summarize_usage_details(usage),
+ ]
+ plan_info = entry.get("antigravityPlanInfo")
+ if isinstance(plan_info, dict):
+ plan = (
+ plan_info.get("planDisplayName")
+ or plan_info.get("displayName")
+ or plan_info.get("planShortName")
+ or plan_info.get("planName")
+ )
+ if plan:
+ detail_lines.append(f"Plan: {plan}")
+ if detail_lines:
+ self.body.append(self._divider())
+ details_title = Gtk.Label(label="Details", xalign=0.0)
+ details_title.add_css_class("codexbar-section-title")
+ self.body.append(details_title)
+ for line in detail_lines:
+ detail = Gtk.Label(label=line, xalign=0.0, wrap=True, max_width_chars=48)
+ detail.add_css_class("codexbar-subtitle")
+ self.body.append(detail)
+ rendered_any = True
+
+ if not rendered_any:
+ self.body.append(self._divider())
+ empty = Gtk.Label(label="No usage data for this provider.", xalign=0.0)
+ empty.add_css_class("codexbar-subtitle")
+ self.body.append(empty)
+
+ def _divider(self) -> Gtk.Widget:
+ d = Gtk.Box()
+ d.add_css_class("codexbar-divider")
+ return d
+
+ def _section(self, title: str, window: dict, *, usage_known: bool = True) -> Gtk.Widget:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ t = Gtk.Label(label=title, xalign=0.0)
+ t.add_css_class("codexbar-section-title")
+ box.append(t)
+
+ pct = window.get("usedPercent")
+ bar = Gtk.LevelBar()
+ bar.add_css_class("codex-usage")
+ bar.set_min_value(0)
+ bar.set_max_value(100)
+ bar.set_value(float(pct) if isinstance(pct, (int, float)) else 0)
+ if isinstance(pct, (int, float)):
+ if pct >= 90:
+ bar.add_css_class("critical")
+ elif pct >= 70:
+ bar.add_css_class("warning")
+ box.append(bar)
+
+ details = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ if not usage_known:
+ left_text = "Reset tracked"
+ elif isinstance(pct, (int, float)):
+ regen = window.get("nextRegenPercent")
+ left_text = f"{int(pct)}% used"
+ if isinstance(regen, (int, float)) and regen > 0:
+ left_text += f" · +{compact_number(regen)}% next regen"
+ else:
+ left_text = "—"
+ left = Gtk.Label(label=left_text, xalign=0.0, hexpand=True)
+ left.add_css_class("codexbar-section-detail-left")
+ details.append(left)
+
+ reset_text = format_reset_label(window, current_reset_format())
+ if reset_text:
+ r = Gtk.Label(label=reset_text, xalign=1.0)
+ r.add_css_class("codexbar-section-detail-right")
+ details.append(r)
+ box.append(details)
+ return box
+
+ def _clear(self, container: Gtk.Box):
+ child = container.get_first_child()
+ while child is not None:
+ nxt = child.get_next_sibling()
+ container.remove(child)
+ child = nxt
+
+
+def main():
+ pidfile = CACHE / "popup.pid"
+ if pidfile.exists():
+ try:
+ pid = int(pidfile.read_text().strip())
+ os.kill(pid, signal.SIGTERM)
+ pidfile.unlink(missing_ok=True)
+ return 0
+ except (ValueError, ProcessLookupError, PermissionError):
+ pidfile.unlink(missing_ok=True)
+
+ CACHE.mkdir(parents=True, exist_ok=True)
+ pidfile.write_text(str(os.getpid()))
+ try:
+ app = CodexBarPopup()
+ return app.run([])
+ finally:
+ pidfile.unlink(missing_ok=True)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.config/waybar/scripts/codexbar.sh b/.config/waybar/scripts/codexbar.sh
new file mode 100755
index 0000000..bf93de7
--- /dev/null
+++ b/.config/waybar/scripts/codexbar.sh
@@ -0,0 +1,542 @@
+#!/usr/bin/env bash
+# Waybar custom module for CodexBar (Linux CLI).
+# Fetches usage from configured providers sequentially (with a small stagger
+# to avoid per-provider rate limits) and emits Waybar JSON
+# ({"text","tooltip","class","percentage"}) keyed on the most-constrained window.
+# Last successful per-provider snapshot is cached, so a transient 429 is masked
+# until the next refresh recovers it.
+#
+# Linux-only providers — cookies/web providers are macOS-only. The list is
+# read from ~/.codexbar/config.json (use the popover's Settings view to
+# manage it); falls back to codex+claude+gemini if the file is missing.
+
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+CODEXBAR="${CODEXBAR_BIN:-codexbar}"
+CONFIG_PATH="${HOME}/.codexbar/config.json"
+STATE_PATH="${XDG_CONFIG_HOME:-${HOME}/.config}/codexbar-waybar/state.json"
+CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/codexbar-waybar"
+mkdir -p "$CACHE_DIR"
+
+# Per-instance bar provider selection (written by the popup's Settings view).
+# `null` (or unset) means "show the highest used% across providers".
+BAR_PROVIDER=""
+# How to render reset times in the tooltip / popover:
+# provider — use the provider's `resetDescription` as-is (default; backward
+# compatible — Claude OAuth gives "May 17 at 6:20AM" with no TZ,
+# Codex gives "7:10 AM" in UTC with no TZ marker, etc.)
+# local — format `resetsAt` (ISO 8601 UTC) in the system local timezone
+# with a today / this-year / future-year tier and a TZ suffix
+# utc — same tiering, formatted in UTC with a literal "UTC" suffix
+RESET_TIME_FORMAT="provider"
+if [[ -f "$STATE_PATH" ]] && command -v jq >/dev/null 2>&1; then
+ BAR_PROVIDER="$(jq -r '.barProvider // empty' "$STATE_PATH" 2>/dev/null)"
+ rf="$(jq -r '.resetTimeFormat // empty' "$STATE_PATH" 2>/dev/null)"
+ [[ -n "$rf" ]] && RESET_TIME_FORMAT="$rf"
+fi
+[[ -n "${CODEXBAR_BAR_PROVIDER:-}" ]] && BAR_PROVIDER="$CODEXBAR_BAR_PROVIDER"
+[[ -n "${CODEXBAR_RESET_TIME_FORMAT:-}" ]] && RESET_TIME_FORMAT="$CODEXBAR_RESET_TIME_FORMAT"
+case "$RESET_TIME_FORMAT" in
+ provider|local|utc) ;;
+ *) RESET_TIME_FORMAT="provider" ;;
+esac
+
+# Read enabled providers from the codexbar CLI's config, fall back to a
+# sensible default if it's missing or unreadable. Override with the
+# CODEXBAR_PROVIDERS env var (space-separated list) when you want to bypass
+# config.json for a specific waybar instance.
+if [[ -n "${CODEXBAR_PROVIDERS:-}" ]]; then
+ # shellcheck disable=SC2206
+ PROVIDERS=( ${CODEXBAR_PROVIDERS} )
+elif [[ -f "$CONFIG_PATH" ]] && command -v jq >/dev/null 2>&1; then
+ readarray -t PROVIDERS < <(jq -r '[.providers[]? | select(.enabled == true) | .id] | .[]' "$CONFIG_PATH" 2>/dev/null)
+ [[ ${#PROVIDERS[@]} -eq 0 ]] && PROVIDERS=(codex claude gemini)
+else
+ PROVIDERS=(codex claude gemini)
+fi
+
+# Setup Antigravity SSL if it is one of the enabled providers.
+setup_antigravity_ssl() {
+ if ! command -v openssl >/dev/null 2>&1 || ! command -v lsof >/dev/null 2>&1 || ! command -v pgrep >/dev/null 2>&1; then
+ return 1
+ fi
+
+ local pid
+ pid=$(pgrep -f "Antigravity.*/language_server" | head -n 1)
+ if [[ -z "$pid" ]]; then
+ return 1
+ fi
+
+ local ports
+ ports=$(lsof -a -p "$pid" -iTCP -sTCP:LISTEN -F n 2>/dev/null | grep -oE '[0-9]+$')
+ if [[ -z "$ports" ]]; then
+ return 1
+ fi
+
+ local cert=""
+ # shellcheck disable=SC2086
+ for port in $ports; do
+ local out
+ out=$(openssl s_client -showcerts -connect 127.0.0.1:"$port" < /dev/null 2>/dev/null)
+ if [[ "$out" == *"-----BEGIN CERTIFICATE-----"* ]]; then
+ cert=$(echo "$out" | openssl x509 -outform PEM 2>/dev/null)
+ if [[ -n "$cert" ]]; then
+ break
+ fi
+ fi
+ done
+
+ if [[ -z "$cert" ]]; then
+ return 1
+ fi
+
+ local scratch_dir="${CACHE_DIR}/scratch"
+ mkdir -p "$scratch_dir"
+ local cert_file="${scratch_dir}/localhost.crt"
+ echo "$cert" > "$cert_file"
+
+ local sys_ca=""
+ for path in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/ca-bundle.pem /var/lib/ca-certificates/ca-bundle.pem; do
+ if [[ -f "$path" ]]; then
+ sys_ca="$path"
+ break
+ fi
+ done
+
+ local bundle_file="${scratch_dir}/custom-ca-bundle.crt"
+ if [[ -n "$sys_ca" ]]; then
+ cat "$sys_ca" "$cert_file" > "$bundle_file"
+ else
+ cat "$cert_file" > "$bundle_file"
+ fi
+
+ ANTIGRAVITY_CUSTOM_CA_BUNDLE="$bundle_file"
+ ANTIGRAVITY_LD_PRELOAD="${SCRIPT_DIR}/cert_redirect.so"
+ return 0
+}
+
+if [[ " ${PROVIDERS[*]} " == *" antigravity "* ]]; then
+ setup_antigravity_ssl
+fi
+
+# Per-provider --source override. Codex/Claude need explicit oauth on Linux
+# (their `auto` falls back to web first, which is macOS-only).
+declare -A SOURCE_OVERRIDES=(
+ [codex]=oauth
+ [claude]=oauth
+)
+
+# If the primary source returns a provider-level error (e.g. Claude OAuth
+# hitting HTTP 429), fall back to this source. Mostly useful for Claude
+# where the local CLI logs have the same windowing data.
+declare -A FALLBACK_SOURCES=(
+ [claude]=cli
+)
+
+# Antigravity has no Linux login flow in the CLI: it expects Google OAuth
+# credentials at ~/.codexbar/antigravity/oauth_creds.json (written by the macOS
+# app), or passed inline via $ANTIGRAVITY_OAUTH_CREDENTIALS_JSON. On Linux the
+# Antigravity CLI (`agy`) logs in through the shared Gemini flow and drops the
+# same Google creds at ~/.gemini/oauth_creds.json. Bridge that file into the
+# env var so an `agy` login satisfies codexbar without a second login.
+# Override the source path with $CODEXBAR_ANTIGRAVITY_CREDS.
+ANTIGRAVITY_CREDS="${CODEXBAR_ANTIGRAVITY_CREDS:-${HOME}/.gemini/oauth_creds.json}"
+
+fetch_one() {
+ local p="$1" src="$2"
+ local args=(usage --provider "$p" --format json --no-color)
+ [[ -n "$src" ]] && args+=(--source "$src")
+ local runner=()
+ if [[ "${PROVIDER_TIMEOUT_SECS:-20}" != "0" ]] && command -v timeout >/dev/null 2>&1; then
+ runner=(timeout "${PROVIDER_TIMEOUT_SECS:-20}")
+ fi
+ if [[ "$p" == "antigravity" ]]; then
+ if [[ -z "${ANTIGRAVITY_OAUTH_CREDENTIALS_JSON:-}" && -f "$ANTIGRAVITY_CREDS" ]]; then
+ "${runner[@]}" env \
+ CUSTOM_CA_BUNDLE="${ANTIGRAVITY_CUSTOM_CA_BUNDLE:-}" \
+ LD_PRELOAD="${ANTIGRAVITY_LD_PRELOAD:-}" \
+ ANTIGRAVITY_OAUTH_CREDENTIALS_JSON="$(cat "$ANTIGRAVITY_CREDS")" \
+ "$CODEXBAR" "${args[@]}" 2>/dev/null
+ else
+ "${runner[@]}" env \
+ CUSTOM_CA_BUNDLE="${ANTIGRAVITY_CUSTOM_CA_BUNDLE:-}" \
+ LD_PRELOAD="${ANTIGRAVITY_LD_PRELOAD:-}" \
+ "$CODEXBAR" "${args[@]}" 2>/dev/null
+ fi
+ return
+ fi
+ "${runner[@]}" "$CODEXBAR" "${args[@]}" 2>/dev/null
+}
+
+fetch_provider() {
+ local p="$1"
+ local primary="${SOURCE_OVERRIDES[$p]:-}"
+ local fallback="${FALLBACK_SOURCES[$p]:-}"
+
+ local body fallback_body
+ body="$(fetch_one "$p" "$primary")"
+
+ # Only retry when the response is a valid array with a provider-level
+ # error — network failures and rate limits land here. Auth misconfig
+ # surfaces the same way; the fallback may still error, which is fine
+ # because the cache layer will mask it.
+ if [[ -n "$fallback" ]] \
+ && echo "$body" | jq -e 'type == "array" and (.[0].error // null) != null' >/dev/null 2>&1; then
+ fallback_body="$(fetch_one "$p" "$fallback")"
+ if echo "$fallback_body" | jq -e 'type == "array"' >/dev/null 2>&1; then
+ body="$fallback_body"
+ fi
+ fi
+
+ echo "$body"
+}
+
+# Sequential fetch with a small stagger between providers (avoids 429s).
+STAGGER_SECS="${CODEXBAR_STAGGER:-0.5}"
+PROVIDER_TIMEOUT_SECS="${CODEXBAR_PROVIDER_TIMEOUT:-20}"
+LAST_GOOD="$CACHE_DIR/last.json"
+
+tmpdir="$(mktemp -d)"
+trap 'rm -rf "$tmpdir"' EXIT
+
+i=0
+for p in "${PROVIDERS[@]}"; do
+ (( i > 0 )) && sleep "$STAGGER_SECS"
+ fetch_provider "$p" > "$tmpdir/$p.json"
+ i=$((i + 1))
+done
+
+append_merged_entry() {
+ local entry="$1"
+ [[ -z "$entry" ]] && return
+ if (( first )); then
+ merged+="$entry"
+ first=0
+ else
+ merged+=",$entry"
+ fi
+}
+
+merged="["
+first=1
+for p in "${PROVIDERS[@]}"; do
+ body="$(cat "$tmpdir/$p.json")"
+ # CLI returns a JSON array; unwrap and append elements.
+ if [[ -z "$body" ]]; then
+ continue
+ fi
+ if ! echo "$body" | jq -e 'type == "array"' >/dev/null 2>&1; then
+ append_merged_entry "$(jq -cn \
+ --arg provider "$p" \
+ --arg message "invalid JSON from provider" \
+ '{provider: $provider, error: {message: $message}}')"
+ continue
+ fi
+ inner="$(echo "$body" | jq -c '.[]')"
+ while IFS= read -r entry; do
+ append_merged_entry "$entry"
+ done <<< "$inner"
+done
+merged+="]"
+
+last_good_json=""
+if [[ -f "$LAST_GOOD" ]]; then
+ last_good_json="$(jq -c 'select(type == "array")' "$LAST_GOOD" 2>/dev/null || true)"
+fi
+
+# Persist fresh successful provider snapshots without dropping older successful
+# entries for providers that failed this refresh.
+if [[ "$merged" != "[]" ]] && echo "$merged" | jq -e 'any(.error | not)' >/dev/null 2>&1; then
+ if [[ -n "$last_good_json" ]]; then
+ merged_for_cache="$(jq -c --argjson prev "$last_good_json" '
+ def cache_key: (.provider // "") + "\u0000" + (.account // "");
+ ([$prev[]? | select((.error | not) and (.stale != true))
+ | {key: cache_key, value: .}] | from_entries) as $ok_prev
+ | ([.[]? | select(.error | not)
+ | {key: cache_key, value: (del(.stale))}] | from_entries) as $ok_fresh
+ | ($ok_prev + $ok_fresh) | [.[]]
+ ' <<< "$merged")"
+ [[ -n "$merged_for_cache" ]] && echo "$merged_for_cache" > "$LAST_GOOD"
+ else
+ echo "$merged" | jq -c 'map(select(.error | not) | del(.stale))' > "$LAST_GOOD"
+ fi
+ last_good_json="$(jq -c 'select(type == "array")' "$LAST_GOOD" 2>/dev/null || true)"
+fi
+
+# Fill in errored or missing providers with the last successful snapshot for
+# display (marks them stale). Missing happens when a provider returns empty
+# output instead of a JSON array.
+if [[ -n "$last_good_json" ]]; then
+ providers_json="$(printf '%s\n' "${PROVIDERS[@]}" | jq -R . | jq -s .)"
+ merged="$(jq -c \
+ --argjson prev "$last_good_json" \
+ --argjson requested "$providers_json" '
+ def cache_key: (.provider // "") + "\u0000" + (.account // "");
+ ([$prev[]? | select(.error | not) | {key: cache_key, value: .}] | from_entries) as $ok_prev
+ | ([$prev[]? | select(.error | not)] | group_by(.provider) | map({key: .[0].provider, value: .}) | from_entries) as $prev_by_provider
+ | (map(.provider) | unique) as $seen
+ | map(if .error and $ok_prev[cache_key]
+ then $ok_prev[cache_key] + {stale: true}
+ else . end) as $direct_current
+ | ($direct_current
+ | map(select(.error and ($ok_prev[cache_key] | not)) as $err
+ | ($prev_by_provider[$err.provider] // [])
+ | map(. + {stale: true}))
+ | add // []) as $provider_replacements
+ | ($direct_current | map(select((.error and ($prev_by_provider[.provider] // [] | length > 0)) | not))) as $current
+ | ($requested
+ | map(. as $pid | select(($seen | index($pid)) | not))
+ | map(($prev_by_provider[.] // []) | map(. + {stale: true}))
+ | add // []) as $missing
+ | $current + $provider_replacements + $missing
+ ' <<< "$merged")"
+fi
+
+if [[ "$merged" == "[]" ]]; then
+ printf '{"text":"","tooltip":"CodexBar: no provider data","class":"stale","percentage":0}\n'
+ exit 0
+fi
+
+echo "$merged" | jq -c \
+ --arg now "$(date -u +%FT%TZ)" \
+ --arg bar_provider "$BAR_PROVIDER" \
+ --arg reset_format "$RESET_TIME_FORMAT" '
+ # Collect all usage windows across providers
+ def provider_name(p):
+ if p == null then "Unknown"
+ else {
+ abacus:"Abacus AI", alibaba:"Alibaba", alibabatokenplan:"Alibaba Token Plan",
+ amp:"Amp", antigravity:"Antigravity", augment:"Augment",
+ azureopenai:"Azure OpenAI", bedrock:"AWS Bedrock", chutes:"Chutes",
+ claude:"Claude", clawrouter:"ClawRouter", codebuff:"Codebuff",
+ codex:"Codex", commandcode:"Command Code", copilot:"Copilot",
+ crof:"Crof", crossmodel:"CrossModel", cursor:"Cursor",
+ deepgram:"Deepgram", deepseek:"DeepSeek", devin:"Devin",
+ doubao:"Doubao", elevenlabs:"ElevenLabs", factory:"Droid",
+ gemini:"Gemini", grok:"Grok", groq:"Groq",
+ jetbrains:"JetBrains AI", kilo:"Kilo", kimi:"Kimi",
+ kimik2:"Kimi K2", kiro:"Kiro", litellm:"LiteLLM",
+ llmproxy:"LLM Proxy", manus:"Manus", mimo:"Xiaomi MiMo",
+ minimax:"MiniMax", mistral:"Mistral", moonshot:"Moonshot / Kimi API",
+ ollama:"Ollama", openai:"OpenAI", opencode:"OpenCode",
+ opencodego:"OpenCode Go", openrouter:"OpenRouter", perplexity:"Perplexity",
+ poe:"Poe", qoder:"Qoder", sakana:"Sakana AI",
+ stepfun:"StepFun", synthetic:"Synthetic", t3chat:"T3 Chat",
+ venice:"Venice", vertexai:"Vertex AI", warp:"Warp",
+ windsurf:"Windsurf", zai:"z.ai", zed:"Zed"
+ }[p] // (p | gsub("-"; " ") | ascii_upcase)
+ end;
+
+ def entry_name(entry):
+ entry as $entry
+ | provider_name($entry.provider)
+ + (if (($entry.account // "") != "") then " (\($entry.account))" else "" end);
+
+ # Insert spaces the providers omit. Claude OAuth gives "May 17 at 6:20AM"
+ # (no space before AM/PM); Claude CLI gives "Resets6:20am(Europe/Paris)"
+ # (no space anywhere). Normalise both to a single, consistent style.
+ def normalize_reset:
+ sub("^Resets(?=\\S)"; "Resets ")
+ | sub("^resets(?=\\S)"; "resets ")
+ | gsub("(?<=\\S)\\("; " (")
+ | gsub(",(?=\\S)"; ", ")
+ | gsub("(?<=\\d)(?=[AaPp][Mm]\\b)"; " ");
+
+ # Format a UNIX timestamp in the system local timezone, tiered by how far
+ # away the reset is. Today drops the date; this year drops the year.
+ def fmt_local_ts:
+ . as $ts
+ | ($ts | strflocaltime("%Y-%m-%d")) as $rd
+ | (now | strflocaltime("%Y-%m-%d")) as $td
+ | ($ts | strflocaltime("%Y")) as $ry
+ | (now | strflocaltime("%Y")) as $cy
+ | if $rd == $td then ($ts | strflocaltime("%-I:%M %p %Z"))
+ elif $ry == $cy then ($ts | strflocaltime("%b %-d at %-I:%M %p %Z"))
+ else ($ts | strflocaltime("%b %-d %Y at %-I:%M %p %Z")) end;
+
+ # Same tiering, formatted in UTC. We hardcode the "UTC" suffix because
+ # `strftime("%Z")` after `gmtime` is empty on some libc builds.
+ def fmt_utc_ts:
+ . as $ts
+ | ($ts | gmtime | strftime("%Y-%m-%d")) as $rd
+ | (now | gmtime | strftime("%Y-%m-%d")) as $td
+ | ($ts | gmtime | strftime("%Y")) as $ry
+ | (now | gmtime | strftime("%Y")) as $cy
+ | if $rd == $td then ($ts | gmtime | strftime("%-I:%M %p UTC"))
+ elif $ry == $cy then ($ts | gmtime | strftime("%b %-d at %-I:%M %p UTC"))
+ else ($ts | gmtime | strftime("%b %-d %Y at %-I:%M %p UTC")) end;
+
+ # Build the trailing " — resets …" fragment for a usage window. Mode is
+ # one of "provider" (preserve the provider string), "local", or "utc".
+ # Relative phrases like "Resets in 2 hours" are kept verbatim even in
+ # absolute modes — "in 2 hours" is more useful than a wall-clock time.
+ def reset_phrase(w):
+ if w == null then ""
+ else (w.resetDescription // "" | normalize_reset) as $clean
+ | (if $clean == "" then ""
+ elif ($clean | test("^[Rr]esets")) then " — \($clean)"
+ else " — resets \($clean)" end) as $from_desc
+ | if $reset_format == "provider" then $from_desc
+ elif ($clean | test("^[Rr]esets in ")) then $from_desc
+ elif w.resetsAt != null then
+ (try (w.resetsAt | fromdateiso8601) catch null) as $ts
+ | if $ts == null then $from_desc
+ elif $reset_format == "utc" then " — resets \($ts | fmt_utc_ts)"
+ else " — resets \($ts | fmt_local_ts)" end
+ else $from_desc
+ end
+ end;
+
+ def round_pct:
+ ((((. * 100) + 0.5) | floor) / 100);
+
+ def fmt_pct:
+ (round_pct | tostring);
+
+ def fmt_window(w; name):
+ if w == null or w.usedPercent == null then empty
+ else "\(name): \(w.usedPercent | fmt_pct)%" + reset_phrase(w)
+ end;
+
+ def fmt_named_window(item; provider):
+ if item == null or item.window == null then empty
+ else (item.title // item.id // "Extra quota") as $title
+ | if (item | has("usageKnown")) and item.usageKnown == false then
+ "\(provider) \($title): reset tracked" + reset_phrase(item.window)
+ else fmt_window(item.window; "\(provider) \($title)") end
+ end;
+
+ def money(v; currency):
+ if currency == null or currency == "USD" then "$\((v * 100 | round / 100))"
+ else "\(currency) \((v * 100 | round / 100))" end;
+
+ def status_line(entry):
+ entry as $entry
+ | if $entry.status == null then empty
+ else ($entry.status.indicator // "unknown" | gsub("_"; " ") | ascii_upcase) as $indicator
+ | if ($entry.status.description // "") == "" then
+ "\(entry_name($entry)) status: \($indicator)"
+ else
+ "\(entry_name($entry)) status: \($indicator) — \($entry.status.description)"
+ end
+ end;
+
+ def pace_lines(entry):
+ entry as $entry
+ |
+ [
+ ($entry.pace.primary.summary? | select(. != null) | "\(entry_name($entry)) session pace: \(.)"),
+ ($entry.pace.secondary.summary? | select(. != null) | "\(entry_name($entry)) weekly pace: \(.)")
+ ];
+
+ def credits_lines(entry):
+ entry as $entry
+ |
+ [
+ ($entry.credits.remaining? | select(type == "number")
+ | "\(entry_name($entry)) credits: \(money(.; "USD")) remaining"),
+ ($entry.credits.codexCreditLimit? | select(.limit? > 0)
+ | "\(entry_name($entry)) \(.title // "Monthly credit limit"): \(money(.used; "USD")) / \(money(.limit; "USD"))"),
+ ($entry.openaiDashboard.creditsRemaining? | select(type == "number")
+ | "\(entry_name($entry)) dashboard credits: \(money(.; "USD")) remaining"),
+ ($entry.openaiDashboard.codexCreditLimit? | select(.limit? > 0)
+ | "\(entry_name($entry)) \(.title // "Monthly credit limit"): \(money(.used; "USD")) / \(money(.limit; "USD"))")
+ ];
+
+ def reset_credit_line(entry):
+ entry as $entry
+ | $entry.usage.codexResetCredits as $rc
+ | if $rc == null then empty
+ else ($rc.availableCount // ([$rc.credits[]? | select(.status == "available")] | length)) as $count
+ | ([$rc.credits[]? | select(.status == "available" and .expires_at != null)] | sort_by(.expires_at) | .[0].expires_at) as $next
+ | if $next != null then
+ (try ($next | fromdateiso8601) catch null) as $ts
+ | if $ts == null then "\(entry_name($entry)) reset credits: \($count) available"
+ else "\(entry_name($entry)) reset credits: \($count) available; next expires \($ts | fmt_local_ts)" end
+ elif $count > 0 then "\(entry_name($entry)) reset credits: \($count) available; no expiry"
+ else "\(entry_name($entry)) reset credits: none available" end
+ end;
+
+ def cost_lines(entry):
+ entry as $entry
+ |
+ [
+ ($entry.usage.providerCost? | select(.used? != null)
+ | "\(entry_name($entry)) \(.period // "budget"): \(money(.used; .currencyCode // "USD"))"
+ + (if .limit? > 0 then " / \(money(.limit; .currencyCode // "USD"))" else "" end)),
+ ($entry.usage.openRouterUsage.balance? | select(type == "number")
+ | "\(entry_name($entry)) balance: \(money(.; "USD"))"),
+ ($entry.usage.sakanaPayAsYouGo.creditBalance? | select(type == "number")
+ | "\(entry_name($entry)) pay-as-you-go balance: \(money(.; "USD"))"),
+ ($entry.antigravityPlanInfo? | (.planDisplayName // .displayName // .planShortName // .planName) as $plan
+ | select($plan != null) | "\(entry_name($entry)) plan: \($plan)")
+ ];
+
+ def provider_lines(entry):
+ entry as $entry
+ | if $entry.error then
+ "\(provider_name($entry.provider)): error — \($entry.error.message)"
+ else
+ (entry_name($entry)) as $name
+ | [
+ status_line($entry),
+ fmt_window($entry.usage.primary; "\($name) primary"),
+ fmt_window($entry.usage.secondary; "\($name) secondary"),
+ fmt_window($entry.usage.tertiary; "\($name) tertiary"),
+ ($entry.usage.extraRateWindows[]? | fmt_named_window(.; $name)),
+ reset_credit_line($entry)
+ ]
+ + pace_lines($entry)
+ + credits_lines($entry)
+ + cost_lines($entry)
+ | map(select(. != null and . != "")) | join("\n")
+ end;
+
+ def max_pct(entry):
+ if entry.error then 0
+ else
+ [entry.usage.primary.usedPercent,
+ entry.usage.secondary.usedPercent,
+ entry.usage.tertiary.usedPercent]
+ | map(select(. != null)) | (max // 0)
+ end;
+
+ def pct_or_null(w):
+ if w == null or w.usedPercent == null then null
+ else (w.usedPercent | floor) end;
+
+ # When the user has pinned a provider for the bar text, surface session
+ # and weekly inline ("3% • 12%"). Otherwise emit the global max%.
+ def bar_text(entry):
+ if entry == null or entry.error then "🤖 ⚠"
+ else
+ [pct_or_null(entry.usage.primary),
+ pct_or_null(entry.usage.secondary)]
+ | map(select(. != null))
+ | if length == 0 then "🤖 —"
+ elif length == 1 then "🤖 \(.[0])%"
+ else "🤖 \(.[0])% • \(.[1])%" end
+ end;
+
+ . as $all
+ | ($all | map(max_pct(.)) | max // 0 | round_pct) as $pct
+ | ($all | map(provider_lines(.)) | map(select(. != ""))) as $lines
+ | ($all | all(.error)) as $all_errored
+ | ($all | any(.stale == true)) as $any_stale
+ | (if $bar_provider == "" then null
+ else ($all | map(select(.provider == $bar_provider)) | .[0]) end) as $pinned
+ | {
+ text: (if $pinned != null then bar_text($pinned)
+ elif $all_errored then "🤖 ⚠"
+ else "🤖 \($pct | tostring)%" end),
+ tooltip: ($lines | join("\n")),
+ class: (if $all_errored then "stale"
+ elif $pct >= 90 then "critical"
+ elif $pct >= 70 then "warning"
+ elif $any_stale then "stale"
+ else "ok" end),
+ percentage: $pct
+ }
+'
diff --git a/.config/waybar/scripts/meteobar.sh b/.config/waybar/scripts/meteobar.sh
new file mode 100755
index 0000000..effd80f
--- /dev/null
+++ b/.config/waybar/scripts/meteobar.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+meteobar --location 'Los Angeles' --units imperial --icons fontawesome --tooltip-format days --days 3 | \
+ jq -c '
+ .class |= (if type == "array" then .[0] else . end) |
+ .tooltip |= (
+ split("\n") |
+ map(select(test("[╭╰╮]") | not)) |
+ map(select(test("foreground=.#5c6370.>[─]") | not)) |
+ map(
+ gsub("│ *"; "") |
+ gsub(" *│"; "")
+ ) |
+ map(select(test("^[[:space:]]*$") | not)) |
+ join("\n")
+ )
+ '
diff --git a/.config/waybar/user-style.css b/.config/waybar/user-style.css
new file mode 100644
index 0000000..1b5ef9c
--- /dev/null
+++ b/.config/waybar/user-style.css
@@ -0,0 +1,38 @@
+
+/* codexbar-waybar — appended by install.sh */
+/* CodexBar Waybar module — append to ~/.config/waybar/user-style.css
+ * or your theme's CSS. Tweak colors to taste; the four state classes
+ * (ok / warning / critical / stale) are emitted by codexbar.sh.
+ */
+
+#custom-codexbar {
+ padding: 0 8px;
+ margin: 0 4px;
+ border-radius: 6px;
+ font-weight: bold;
+}
+
+#custom-codexbar.ok {
+ color: #16d3b4;
+}
+
+#custom-codexbar.warning {
+ color: #f5a623;
+ background: alpha(#f5a623, 0.18);
+}
+
+#custom-codexbar.critical {
+ color: #ff5470;
+ background: alpha(#ff5470, 0.22);
+ animation: codexbar-pulse 2s ease-in-out infinite alternate;
+}
+
+#custom-codexbar.stale {
+ /* Latest fetch failed or the value came from the cache. */
+ color: #888;
+}
+
+@keyframes codexbar-pulse {
+ from { background: alpha(#ff5470, 0.22); }
+ to { background: alpha(#ff5470, 0.40); }
+}