From 49819250e2055efa2169b220cb85453191fce657 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 22 Sep 2026 18:49:23 -0700 Subject: [PATCH] update waybar --- .../waybar/catppuccin-macchiato/config.jsonc | 10 +- .config/waybar/catppuccin-macchiato/style.css | 8 +- .config/waybar/modules/custom-codexbar.json | 20 + .config/waybar/scripts/aibar-popup.py | 387 +++++ .config/waybar/scripts/cert_redirect.so | Bin 0 -> 15752 bytes .config/waybar/scripts/codexbar-popup.py | 1421 +++++++++++++++++ .config/waybar/scripts/codexbar.sh | 542 +++++++ .config/waybar/scripts/meteobar.sh | 16 + .config/waybar/user-style.css | 38 + 9 files changed, 2428 insertions(+), 14 deletions(-) create mode 100644 .config/waybar/modules/custom-codexbar.json create mode 100755 .config/waybar/scripts/aibar-popup.py create mode 100755 .config/waybar/scripts/cert_redirect.so create mode 100755 .config/waybar/scripts/codexbar-popup.py create mode 100755 .config/waybar/scripts/codexbar.sh create mode 100755 .config/waybar/scripts/meteobar.sh create mode 100644 .config/waybar/user-style.css 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 0000000000000000000000000000000000000000..e7e8e82d674a7e61a99c3f33178bc8b483fb9b48 GIT binary patch literal 15752 zcmeHOe{37o9e;5W2M0)eiM?KD;$JF+ir zT2WS(c*1y@%A_KuO{`3S7#bQN7!w;P&_ZEDD4_j7ngp3PkP8(Q7$d;e+voe<``YKj zsV0QFKe%Vzec$)z``-7x=X-bWd*5yE?C{mp2rji^y(nWsgf-)q7M38|S}_af3&mn7 zTUTa%s&?O5io{SfDxBJ=r>e%?@Sz4J$B_A?C)*1szHq?-pQ1B#>X9qc%N2imT_p@@ zy|o&$9-*Yy5LEIF>nq`e4i-c0w)BzwyieP6!==iMU?{H%G=g-jy+jGPVMvh$LvC;T z>sIWu=yhwoqXA{u4Lfyv4B5|bK~HzBmN-4&Zr-Nbo2effbi5eq@XDCXM-#O;Hf`w@ z*VgAlD_0+xT>i(X|CuYhU;OLk{g0Ce#6JfI>5+dvF%kG)=)lE9`x1`XIF{e}&b%87 zS^u8D{N%?XflmGgaJWkpglcxK z06pLq2Ht@~51k6`K49}OYl{>_lpj)qD7WGudfh^aif#o&LDOG7R}per{-CD!xa4os z^yqn&@;7Vx?(-F$+ZCp{b15n$FQsuKi9PRiNqYT@qosM7bP%Med00TRQP|2=Xpr?2mYpIVfvJAAP}yk zGydE)imv8_s?&MWL6`r>n)Qlwq%Eu5XtJHA_)x+I?YWkjbT+IdHzo! z%EB<6g&;cl>J={nUIe@dcoFa-;6=cTfER(!Hv;ebFZ$RRJ<(tk>Yr*B!pKcnK6}y_ zJ=$i0pcB>vQaL3ns8e39iNo=2vHU|B?fc6-P9L{LNB+%{~H6r;PkbVBI>f#qg?LV|3k{^dN8KU$^EMg>@H!ZZCKR zq+%4#JzGB?gqjfrwBP{yMIN3YC6SBRqfxrcf{Qjaby1yv=W4Dmy@ddw_ z3s7h;8u>pP`D4X5PuVsuZ3{S$RdOa3XEiuaR&su=IE%pfZ6)WYk~17Y0G}fc8c2X- ziw0BLHI) zR>0rXe!$<MmO*t>z5qteAj5o25U` z8pn=-Vi$eVt=uxyjY5vvCTE{fR6+|ma%FTXoSX2E-3epIav>1tLFQO)HEJPhV7$H2 z$miAqHO6z@1oF8sKtT$RPB-tl6y_!65UJ;1HfkmdvO4;7Lta+inghwgoOAdb;$@GThOiqMxH63I;3Nko_97T$! zLwPQIwVTKFl5=~8d- ztt>x0WsKMV81Hr*L2H4Wyv4}d+OeXo@S;)Zosze@Lf5pq-PtecKfFqNTPy9o@nNOC zU!B%oX^m1^mC&EESG)*#5%415MZk-I7lF?jf#$dsZO&$s&Cz(q$~H$M%c+v+PedbD zJR6K=EM-~B4ed%aTS-Wj&{S_;C*rTIV)?-`6(hEZ<>RQhf5S zAO6WbRp!?os38H;AH(qn_&pQZRzaC_Lsl>YC~^4KSlIL8r>cM`u^^Req7aeX=3>X`$a zx9)DSg|=H!IyC&YhW#1_!jdgEwY7acunbRnO@S3H!Ii-kft8_^Eupn5LW)1rnc`6= z-nHF)8c#m-;*f5K`F>45?4r}dHOcdMW%;$J)QAhjvaq6X{5MKEGN*N(tl|o>3l*}% zsC*(>@pk-@kb8y+0o3l#@u zLei=br5Ao?CLT*<;!!IY6wr|-;IsMcmd6P|>0}*;t zD9nx5ZfWh>+y*NCOU!mdD;OKMfrPAoR^8OObwg{XxwWHXdwY-B)4HLv9R|rUo!%ej z{CrBqKi$^@GQS^h5#1jGlnn3-M#!iS2ud7jy7D^(^|gS0BPqWyC`sc(0qMv8@TEdY zM}9{T#CHl-q)!CVyn+KChE6NDh(N<(I5rrdZn8zt7+f?@ZvRM(tkR8NG{PbM46oLbeE;V40UMzE5UojApVuu6WriI1)GnreX&uDti7;qnoAr4e z#PA4s&O$bv#Q;KU5Z32)2g8fBF#FGP4C%WhTGKGj>l%h(t?%|_;;}xjqZsl! zireS-@%5*){swK3*JTWMXgcXHz~R;(0gYn9>&DW3O5d+|eF_m-$X92{0&(gGv>wB5 zr!eY#8)SHsOP|*@3|F(m+79cn*)f+s-}f2v`kUq5{@<$gIsPJ`B{h6nqYPZ`h#!GL z*U$RAUSQbCiW<7xKMcB4zwu%v$Z#n!9mU=L{opz0?@Zr4vI)2T6JQ|!u=E_j-%as% zhHiPr89onPT6^;NEUg>qj{$|{6jsKW{wFYK?6E$t!}z<=34Q$>H`e2E@G5jkg!TD* zt>>CSL8GjEIl41XO zUAb%qeQL{DJ_JHEEnZ@=zI*<%z7z7;Eh(d7XoH4!jGjxY{eRH&9sBOoKe$R^?{h<% S=|TlP22|4@T~l4bt^Z%UVNb3A literal 0 HcmV?d00001 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); } +}