diff --git a/.gitignore b/.gitignore index 8bb204b..2fe5f95 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ ENV/ # IDE .vscode/ .idea/ +.claude/ *.swp *.swo *~ diff --git a/README.md b/README.md index 9078b07..3ed4b9c 100644 --- a/README.md +++ b/README.md @@ -1,249 +1,199 @@ -# ⚡ Claude AI Usage Widget — Linux Taskbar +# ⚡ Claude AI Usage Widget — Linux Taskbar (Multi-Account Fork) -A lightweight system tray widget that shows your Claude AI subscription usage percentage (5-hour and 7-day rate limit windows) directly in your Linux taskbar. +> **Fork of [StaticB1/claude_ai_usage_widget](https://github.com/StaticB1/claude_ai_usage_widget)** -![Claude Usage Widget Screenshot](screenshot.png) - +A lightweight system tray widget that shows your Claude AI subscription usage (5h and 7d rate limit windows) directly in your Linux taskbar. Supports multiple accounts and is fully configurable via a built-in UI — no config file editing required. -## Quick Start +| Tray menu | Details popup | +|---|---| +| ![Tray menu](screenshots/screenshot_tray.png) | ![Details popup](screenshots/screenshot_popup.png) | -```bash -# Install dependencies -sudo apt install python3 python3-gi gir1.2-appindicator3-0.1 gir1.2-notify-0.7 +## Features -# Install widget -git clone https://github.com/StaticB1/claude_ai_usage_widget.git && cd claude_ai_usage_widget -chmod +x install.sh && ./install.sh +- **Multiple accounts** — tray label shows `Work:67% Personal:12%` for all accounts at a glance; individual accounts can be hidden from the tray while still appearing in the dropdown and popup +- **Redesigned popup** — two-column table layout showing 5h and 7d side-by-side with inline reset times (`72% — 2h 15m`) +- **Configure window** — edit accounts, thresholds, burn rate alerts, and poll interval live from the tray menu (no config file editing needed) +- **Burn rate alerts** — warns when your 7d usage pace suggests you'll exceed your weekly allocation (e.g. 50% used with only 25% of the week elapsed) +- **Configurable notifications** — set your own warn/critical thresholds (defaults: 60% / 85%) +- **Per-account polling control** — disable background auto-refresh per account (e.g. keep personal accounts polling, skip a work account). The widget always fetches all accounts once on launch and "Refresh Now" always fetches everything. When polling is disabled for an account, reset times switch from a countdown (`2h 15m`) to the actual reset time (`9:00P` for 5h, `Th 7:00P` for 7d) +- **Configurable poll interval** — change how often the widget checks (default: 5 min) +- **Config-driven accounts** — `~/.config/claude-usage-widget/config.json` lists each account's label and Claude Code config dir +- **Graceful failures** — if one account errors it shows `Work:!`; if a period just rolled over and the API hasn't returned fresh data yet it shows `Work:?`; in between resets the last known value is preserved instead of flashing an error +- **Interactive install** — `install.sh` asks how many accounts you want and where their credentials are +- **pyenv support** — installer detects pyenv and creates an isolated venv so the widget survives Python version switches -# Start widget +--- + +## Quick Start + +```bash +git clone https://github.com/gqcorneby/claude_ai_usage_widget.git +cd claude_ai_usage_widget +./install.sh claude-widget-start ``` -## Features - -- **Taskbar percentage** — shows your 5h usage % at a glance with color-coded icon -- **Click for details** — popup with 5h + 7d utilization, progress bars, reset timers, and subscription plan -- **Extra usage tracking** — displays pay-as-you-go monthly credit usage if enabled on your account -- **Threshold notifications** — desktop alerts at startup, 75%, 90%, and 100% usage -- **Auto-refresh** — polls every 2 minutes (configurable) -- **Auto-detect credentials** — reads Claude Code's `~/.claude/.credentials.json` on Linux -- **Manual token entry** — dialog for manual OAuth token if you don't use Claude Code -- **Autostart** — installs a `.desktop` entry for autostart on login +The installer will ask how many accounts to monitor and where each one's Claude Code config directory is. ## Requirements - Linux with GTK3 (GNOME, KDE, XFCE, etc.) - Python 3.10+ -- System packages: - ``` - sudo apt install python3 python3-gi gir1.2-appindicator3-0.1 gir1.2-notify-0.7 - ``` +- `gir1.2-appindicator3-0.1`, `gir1.2-notify-0.7` (installer handles these) ## Install ```bash -git clone https://github.com/StaticB1/claude_ai_usage_widget.git && cd claude_ai_usage_widget -chmod +x install.sh ./install.sh ``` -Then run: -```bash -claude-widget-start -``` +The installer will: +1. Detect pyenv or fall back to system Python +2. Install system GI libraries via apt +3. Create an isolated venv (survives pyenv version changes) +4. Ask you to configure your accounts interactively +5. Set up autostart on login -It will autostart on next login. +``` +▸ Setting up accounts… + How many accounts do you want to monitor? [1]: 2 + + — Account 1 of 2 — + Label [Account1]: Work + Claude config dir [~/.claude]: ~/.claude/work + ✓ Found credentials + + — Account 2 of 2 — + Label [Account2]: Personal + Claude config dir [~/.claude]: ~/.claude + ✓ Found credentials +``` ## Usage ```bash -claude-widget-start # Start the widget -claude-widget-stop # Stop the widget +claude-widget-start # Start +claude-widget-stop # Stop ``` -The widget runs in the background and displays in your system tray. +The tray label updates every 5 minutes by default. Click for the full breakdown popup; right-click for the menu. -**Check if running:** -```bash -ps aux | grep '[c]laude_usage_widget' -``` +## What's Displayed Where -## Getting Your OAuth Token +| Location | Shows | +|---|---| +| **Tray label** | 5h usage % per account — `Work:67% Personal:12%`; `?` if the period just reset and fresh data hasn't arrived yet; `!` on auth/network error | +| **Dropdown menu** | 7d usage %, burn rate, 5h reset time — `Work: 45% ↑1.8× ↺ 1h 20m` (or `↺ 9:00P` when polling is disabled for that account) | +| **Details popup (5h column)** | Progress bar, 5h % and reset time (`2h 15m` or `9:00P`) | +| **Details popup (7d column)** | Progress bar, 7d % and reset time (`3d 4h` or `Th 7:00P`), burn rate pace (`↑1.8×` / `↓0.3×`) | -### Option A: Claude Code (automatic) +The tray gives you the quick hourly glance; the dropdown shows weekly pace at a glance; the popup has the full picture. -If you have [Claude Code](https://code.claude.com) installed and logged in: +### Notifications -```bash -claude login # if not already -``` +![Alert notification](screenshots/screenshot_alert.png) -The widget auto-reads `~/.claude/.credentials.json` — no extra config needed. +| Type | Triggers | Urgency | +|---|---|---| +| Usage threshold — warn | 5h or 7d hits warn % (default 60%) | Normal | +| Usage threshold — critical | 5h or 7d hits critical % (default 85%) | Critical | +| Burn rate — early | 7d pace exceeds multiplier before warn % | Normal | +| Burn rate — warn | 7d pace exceeds multiplier at warn % | Normal | +| Burn rate — critical | 7d pace exceeds multiplier at critical % | Critical | -### Option B: Browser DevTools (manual) +#### Window reset behaviour -1. Open https://claude.ai and log in -2. Open DevTools → **Network** tab -3. Send a message, then filter requests for `api.anthropic.com` -4. Find the `Authorization: Bearer sk-ant-oat01-...` header -5. Copy the full token starting with `sk-ant-oat01-` -6. Enter it via the widget's **Set Token…** menu item +Threshold and burn rate notifications each track the API's `resets_at` timestamp for their respective windows. When that timestamp shifts (i.e. the window rolled over), the escalation level resets to zero and the full warn → critical → 100% sequence can fire again. -The token is saved to `~/.config/claude-usage-widget/config.json` (mode 600). +- **5h threshold**: resets when the 5h `resets_at` shifts by more than 1 hour +- **7d threshold**: resets when the 7d `resets_at` shifts by more than 6 hours +- **Burn rate**: resets when the 7d `resets_at` shifts by more than 6 hours; additionally skips the first ~8 hours of each new window to avoid false alarms on low-elapsed-time data -## How It Works +State is persisted to `~/.config/claude-usage-widget/notification_state.json`, so restarting the widget mid-window will not re-fire notifications that already fired for the current window. -Uses the same internal API endpoint Claude Code uses: +## Configuration -``` -GET https://api.anthropic.com/api/oauth/usage -Authorization: Bearer -anthropic-beta: oauth-2025-04-20 -``` +The easiest way is via the tray menu → **Configure...**: -Returns: -```json -{ - "five_hour": { "utilization": 10.0, "resets_at": "2026-02-19T05:00:00Z" }, - "seven_day": { "utilization": 2.0, "resets_at": "2026-02-24T08:00:00Z" }, - "extra_usage": { - "is_enabled": true, - "monthly_limit": 2000, - "used_credits": 500.0, - "utilization": 25.0 - } -} -``` +| Accounts tab | Notifications tab | +|---|---| +| ![Config accounts](screenshots/screenshot_config_accounts.png) | ![Config notifications](screenshots/screenshot_config_notifications.png) | -The widget handles all three sections. `extra_usage` is shown only when `is_enabled` is true. +- **Accounts tab** — add, edit, or remove accounts (label + credentials directory); check **Hide tray** to exclude an account from the tray label; check **No poll** to disable background auto-refresh for that account +- **Notifications tab** — set the poll interval, warn/critical thresholds, and burn rate alert -## Configuration +Changes take effect immediately without restarting the widget. -Edit `~/.config/claude-usage-widget/config.json`: +The config is stored at `~/.config/claude-usage-widget/config.json` and can also be edited directly: ```json { - "oauth_token": "sk-ant-oat01-..." + "accounts": [ + { "label": "Work", "credentials_dir": "~/.claude/work", "disable_polling": true }, + { "label": "Personal", "credentials_dir": "~/.claude", "hide_from_tray": true } + ], + "poll_interval_seconds": 300, + "thresholds": { "warn": 60, "critical": 85 }, + "burn_rate": { "enabled": false, "multiplier": 1.5 } } ``` -To change refresh interval, edit `REFRESH_INTERVAL_SEC` in the Python script (default: 120s). +Each `credentials_dir` must contain a `.credentials.json` file from Claude Code (`claude login`). -## Upgrade +### Burn Rate Alert -```bash -cd claude_ai_usage_widget -chmod +x upgrade.sh && ./upgrade.sh -``` +When enabled, fires a notification if your 7-day usage rate suggests you'll exceed your weekly limit. The multiplier controls sensitivity — `1.5` means: warn if you're on pace to use 150% of your allocation. -This will pull the latest version, reinstall, and restart the widget automatically. Your OAuth token and config are preserved. - -**Manual upgrade** (if you prefer step by step): -```bash -cd claude_ai_usage_widget -git pull -claude-widget-stop -./install.sh -claude-widget-start -``` +Notifications escalate up to 3 times per window, mirroring the usage thresholds: -## Uninstall +| When | Condition | +|---|---| +| Early in the week (below warn %) | First alert — catches it before it's serious | +| At warn % (default 60%) | Second alert if burn rate is still high | +| At critical % (default 85%) | Final alert — critical urgency | -```bash -chmod +x uninstall.sh -./uninstall.sh -``` +Each level fires at most once. The first ~8 hours of a new window are ignored to avoid false alarms, and all levels reset when the window rolls over. -This will remove: -- Installation directory (`~/.local/share/claude-usage-widget/`) -- Wrapper scripts (`claude-widget-start`, `claude-widget-stop`) -- Symlink (`~/.local/bin/claude-usage-widget`) -- Autostart entry (`~/.config/autostart/claude-usage-widget.desktop`) -- Application entry (`~/.local/share/applications/claude-usage-widget.desktop`) +## How It Works -You'll be prompted whether to keep or remove your config (OAuth token). +Uses the same internal API endpoint as Claude Code's `/usage`: -## Development +``` +GET https://api.anthropic.com/api/oauth/usage +Authorization: Bearer +anthropic-beta: oauth-2025-04-20 +``` -### Pre-Release Validation +Credentials are read directly from Claude Code's credential files — no separate login required. -Before creating a release or pushing to the repository, run the validation script to check for common issues: +## Uninstall ```bash -chmod +x validate.sh -./validate.sh +./uninstall.sh ``` -The script performs these checks: -- **Python syntax** — validates `claude_usage_widget.py` compiles -- **Shell scripts** — validates `install.sh` and `uninstall.sh` syntax -- **Token leaks** — scans for real OAuth tokens in repository (placeholders OK) -- **File permissions** — verifies secure file modes -- **Required files** — checks all distribution files exist -- **TODO/FIXME** — finds unresolved comments -- **README placeholders** — ensures no template placeholders remain -- **Version tags** — validates git tag matches code version - -**Exit codes:** -- `0` — All checks passed, ready for release -- `1` — Errors found, fix before releasing - -This is especially useful for: -- Pre-commit validation -- CI/CD integration -- Ensuring quality before releases -- Catching common mistakes (token leaks, missing files, etc.) +Removes all installed files, scripts, desktop entries, and temp files. Prompts before removing your account config. ## Troubleshooting | Problem | Fix | |---|---| +| Account shows `!` | Check `/tmp/claude-widget.log`. Usually an expired token — re-run `claude login` | +| Account shows `?` | The usage window just rolled over and the API hasn't returned data for the new period yet — it will clear on the next successful poll | | No tray icon on GNOME 43+ | Install `gnome-shell-extension-appindicator` and enable it | | `AppIndicator3` import fails | `sudo apt install gir1.2-appindicator3-0.1` | -| `ModuleNotFoundError: No module named 'gi'` | You're using pyenv/conda Python. Use `claude-widget-start` which uses system Python | -| `symbol lookup error: libpthread.so.0` | Snap library conflict. Use `claude-widget-start` which sets clean environment | -| `command not found` after install/uninstall | Run `hash -r` to clear bash's command cache, or close/reopen terminal | -| Token expired / 401 | Re-run `claude login` (Claude Code) or re-extract from browser | -| Icon shows "ERR" | Check token validity and network connectivity | - -### Python Environment Issues - -If you use **pyenv**, **conda**, or other Python version managers, the `python3-gi` system package may not be accessible. The installer creates wrapper scripts (`claude-widget-start`/`claude-widget-stop`) that automatically use the system Python (`/usr/bin/python3`) with a clean environment to avoid conflicts. - -### Checking Logs - -If the widget fails to start or behaves unexpectedly, check the log file: +| Widget broke after pyenv switch | Re-run `./install.sh` — creates a new venv from current pyenv version | +| `command not found` after install | Run `hash -r` or open a new terminal | ```bash -cat /tmp/claude-widget.log +cat /tmp/claude-widget.log # Check logs ``` -Common issues in logs: -- **Symbol lookup errors**: Snap library conflicts (use `claude-widget-start`) -- **Module import errors**: Python environment issues (use `claude-widget-start`) -- **HTTP 401/403 errors**: Token expired or invalid (refresh token) -- **Network errors**: Check internet connectivity or API availability - -## Contributing & Contact - -Contributions are welcome! - -- **Bug reports / feature requests** — [Open an issue](https://github.com/StaticB1/claude_ai_usage_widget/issues) -- **Discussions / collaboration** — [GitHub Discussions](https://github.com/StaticB1/claude_ai_usage_widget/discussions) -- **Email** — contact@statotec.com - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md) for the full release history. - ## License -MIT - -## Author +MIT — see original repo for full history and credits. -Created by **Statotech Systems** - ---- +## Credits -Made with ⚡ by [Statotech Systems](https://github.com/StaticB1) +Original widget by **[Statotech Systems](https://github.com/StaticB1)**. +Multi-account fork by [gqcorneby](https://github.com/gqcorneby). diff --git a/claude_usage_widget.py b/claude_usage_widget.py index 2bbdad7..6df31da 100644 --- a/claude_usage_widget.py +++ b/claude_usage_widget.py @@ -1,19 +1,18 @@ #!/usr/bin/env python3 """ -Claude AI Usage Widget — Linux System Tray -Shows claude.ai subscription usage (5h / 7d) in the taskbar. +Claude AI Usage Widget — Linux System Tray (Multi-Account) +Shows claude.ai subscription usage (5h / 7d) in the taskbar for multiple accounts. Click to see detailed breakdown + reset timers. -Supports two auth methods: - 1. Auto-detect from Claude Code credentials (~/.claude/.credentials.json) - 2. Manual OAuth token via config file (~/.config/claude-usage-widget/config.json) +Reads credentials from configurable Claude Code directories. +Config: ~/.config/claude-usage-widget/config.json -Author: Statotech Systems -Version: 1.0.0 +Author: Statotech Systems (original), extended for multi-account +Version: 2.0.0 License: MIT """ -__version__ = "1.0.4" +__version__ = "2.0.0" __author__ = "Statotech Systems" import gi @@ -21,7 +20,7 @@ gi.require_version('AppIndicator3', '0.1') gi.require_version('Notify', '0.7') -from gi.repository import Gtk, AppIndicator3, GLib, Notify, Gdk, Pango +from gi.repository import Gtk, AppIndicator3, GLib, Notify import cairo import json import os @@ -34,148 +33,181 @@ from datetime import datetime, timezone from pathlib import Path +from shared import ( + COLOR_GRAY, DEFAULT_THRESHOLDS, get_color_for_pct, hex_to_rgb, + parse_utilization, format_reset_time, format_reset_clock, compute_burn_rate, +) +from usage_popup import UsageDetailWindow +from config_window import ConfigWindow + # ── Config ────────────────────────────────────────────────────────────────── APP_ID = "claude-usage-widget" APP_NAME = "Claude Usage" -ICON_NAME = "network-transmit-receive" # fallback icon -REFRESH_INTERVAL_SEC = 120 # 2 minutes USAGE_API_URL = "https://api.anthropic.com/api/oauth/usage" CONFIG_DIR = Path.home() / ".config" / APP_ID CONFIG_FILE = CONFIG_DIR / "config.json" -CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json" - -# ── Colors for the dynamic SVG icon ───────────────────────────────────────── - -COLOR_GREEN = "#22c55e" -COLOR_YELLOW = "#eab308" -COLOR_ORANGE = "#f97316" -COLOR_RED = "#ef4444" -COLOR_GRAY = "#6b7280" - +NOTIFICATION_STATE_FILE = CONFIG_DIR / "notification_state.json" -def get_color_for_pct(pct: float) -> str: - if pct < 0.5: - return COLOR_GREEN - elif pct < 0.75: - return COLOR_YELLOW - elif pct < 0.9: - return COLOR_ORANGE - else: - return COLOR_RED +DEFAULT_ACCOUNTS = [ + {"label": "Claude", "credentials_dir": "~/.claude"}, +] +DEFAULT_POLL_INTERVAL = 300 # 5 minutes -def hex_to_rgb(hex_color: str) -> tuple: - """Convert hex color to RGB tuple (0-1 range).""" - hex_color = hex_color.lstrip('#') - return tuple(int(hex_color[i:i+2], 16) / 255.0 for i in (0, 2, 4)) - +# ── Icon generation ───────────────────────────────────────────────────────── def write_icon(pct: float, error: bool = False) -> str: """Generate PNG icon with Cairo and return path.""" - if error: - color = COLOR_GRAY - else: - color = get_color_for_pct(pct) - + color = COLOR_GRAY if error else get_color_for_pct(pct) r, g, b = hex_to_rgb(color) - # Create PNG icon with Cairo size = 32 surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) ctx = cairo.Context(surface) - # Clear background (transparent) ctx.set_operator(cairo.OPERATOR_CLEAR) ctx.paint() ctx.set_operator(cairo.OPERATOR_OVER) - # Draw filled circle (background) ctx.set_source_rgba(r, g, b, 0.25) ctx.arc(size/2, size/2, 13, 0, 2 * 3.14159) ctx.fill() - # Draw circle border ctx.set_source_rgb(r, g, b) ctx.set_line_width(2) ctx.arc(size/2, size/2, 13, 0, 2 * 3.14159) ctx.stroke() - # Draw "C" text ctx.set_source_rgb(r, g, b) ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) ctx.set_font_size(22) - text = "C" - x_bearing, y_bearing, width, height, x_advance, y_advance = ctx.text_extents(text) + x_bearing, y_bearing, width, height, *_ = ctx.text_extents(text) ctx.move_to(size/2 - width/2 - x_bearing, size/2 - height/2 - y_bearing) ctx.show_text(text) - # Save to file icon_dir = Path("/tmp") / APP_ID icon_dir.mkdir(exist_ok=True) - icon_path = icon_dir / "icon.png" + # Reason: AppIndicator3 compares the path string and skips updates if + # unchanged, so we encode the color into the filename to force a reload. + icon_path = icon_dir / f"icon_{color.lstrip('#')}.png" surface.write_to_png(str(icon_path)) - return str(icon_path) -# ── Token loading ─────────────────────────────────────────────────────────── +def write_loading_icon(frame: int) -> str: + """Generate a spinner icon for the given frame (0–7), rotating a 3/4 arc.""" + PI2 = 2 * 3.14159 + size = 32 -def load_token() -> str | None: - """Try loading OAuth token from Claude Code creds, then config file.""" - # 1. Claude Code credentials (Linux) - if CREDENTIALS_FILE.exists(): - try: - data = json.loads(CREDENTIALS_FILE.read_text()) - token = data.get("claudeAiOauth", {}).get("accessToken") - if token: - return token - except (json.JSONDecodeError, KeyError): - pass + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + ctx = cairo.Context(surface) + ctx.set_operator(cairo.OPERATOR_CLEAR) + ctx.paint() + ctx.set_operator(cairo.OPERATOR_OVER) - # 2. Widget config file - if CONFIG_FILE.exists(): - try: - data = json.loads(CONFIG_FILE.read_text()) - token = data.get("oauth_token") - if token: - return token - except (json.JSONDecodeError, KeyError): - pass + # Dim background circle + ctx.set_source_rgba(0.5, 0.5, 0.5, 0.2) + ctx.arc(size/2, size/2, 13, 0, PI2) + ctx.fill() - return None + # Rotating 3/4 arc — start angle advances by 45° per frame + angle_start = (frame / 8) * PI2 - (PI2 / 4) # offset so 0 starts at top + angle_end = angle_start + PI2 * 0.75 + ctx.set_source_rgba(0.55, 0.55, 0.55, 0.9) + ctx.set_line_width(2.5) + ctx.arc(size/2, size/2, 13, angle_start, angle_end) + ctx.stroke() + + # Gray "C" to keep it recognisable while loading + ctx.set_source_rgba(0.5, 0.5, 0.5, 0.7) + ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + ctx.set_font_size(22) + text = "C" + x_bearing, y_bearing, width, height, *_ = ctx.text_extents(text) + ctx.move_to(size/2 - width/2 - x_bearing, size/2 - height/2 - y_bearing) + ctx.show_text(text) + icon_dir = Path("/tmp") / APP_ID + icon_dir.mkdir(exist_ok=True) + icon_path = icon_dir / f"icon_loading_{frame}.png" + surface.write_to_png(str(icon_path)) + return str(icon_path) -def load_subscription_info() -> dict | None: - """Load subscription information from Claude Code credentials.""" - if CREDENTIALS_FILE.exists(): - try: - data = json.loads(CREDENTIALS_FILE.read_text()) - oauth = data.get("claudeAiOauth", {}) - if oauth: - return { - "subscription_type": oauth.get("subscriptionType", "").title(), - "rate_limit_tier": oauth.get("rateLimitTier", ""), - } - except (json.JSONDecodeError, KeyError): - pass - return None +# ── Config loading ────────────────────────────────────────────────────────── -def save_token(token: str): - """Save token to widget config.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - config = {} +def load_config() -> dict: + """Load widget config, creating defaults if missing.""" if CONFIG_FILE.exists(): try: - config = json.loads(CONFIG_FILE.read_text()) - except json.JSONDecodeError: - pass - config["oauth_token"] = token + return json.loads(CONFIG_FILE.read_text()) + except (json.JSONDecodeError, OSError) as e: + print(f"[claude-usage] Bad config, using defaults: {e}", file=sys.stderr) + + config = { + "accounts": DEFAULT_ACCOUNTS, + "poll_interval_seconds": DEFAULT_POLL_INTERVAL, + "thresholds": DEFAULT_THRESHOLDS, + "burn_rate": {"enabled": False, "multiplier": 1.5}, + } + CONFIG_DIR.mkdir(parents=True, exist_ok=True) CONFIG_FILE.write_text(json.dumps(config, indent=2)) os.chmod(CONFIG_FILE, 0o600) + return config + + +def load_notification_state() -> dict: + """Load persisted notification state (keyed by account label).""" + if NOTIFICATION_STATE_FILE.exists(): + try: + return json.loads(NOTIFICATION_STATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + pass + return {} + + +def save_notification_state(state_map: dict): + """Persist notification state so restarts don't re-fire.""" + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + NOTIFICATION_STATE_FILE.write_text(json.dumps(state_map, indent=2)) + except OSError as e: + print(f"[claude-usage] Could not save notification state: {e}", file=sys.stderr) + + +# ── Per-account token loading ─────────────────────────────────────────────── + +def load_token(credentials_dir: str) -> str | None: + """Load OAuth access token from a Claude Code credentials directory.""" + cred_path = Path(credentials_dir).expanduser() / ".credentials.json" + if not cred_path.exists(): + return None + try: + data = json.loads(cred_path.read_text()) + return data.get("claudeAiOauth", {}).get("accessToken") + except (json.JSONDecodeError, KeyError, OSError): + return None + + +def load_subscription_info(credentials_dir: str) -> dict | None: + """Load subscription info from a Claude Code credentials directory.""" + cred_path = Path(credentials_dir).expanduser() / ".credentials.json" + if not cred_path.exists(): + return None + try: + data = json.loads(cred_path.read_text()) + oauth = data.get("claudeAiOauth", {}) + if oauth: + return { + "subscription_type": oauth.get("subscriptionType", "").title(), + "rate_limit_tier": oauth.get("rateLimitTier", ""), + } + except (json.JSONDecodeError, KeyError, OSError): + pass + return None # ── API call ──────────────────────────────────────────────────────────────── @@ -189,13 +221,11 @@ def fetch_usage(token: str) -> dict | None: headers = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-usage-widget/1.0", + "User-Agent": f"claude-usage-widget/{__version__}", "Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20", } - req = urllib.request.Request(USAGE_API_URL, headers=headers, method="GET") - ctx = ssl.create_default_context() try: with urllib.request.urlopen(req, context=ctx, timeout=15) as resp: @@ -210,314 +240,94 @@ def fetch_usage(token: str) -> dict | None: return None -# ── Time formatting ───────────────────────────────────────────────────────── - -def format_reset_time(iso_str: str | None) -> str: - if not iso_str: - return "unknown" +def _is_resets_at_future(resets_at_str: str | None) -> bool: + """Return True if the given resets_at timestamp is still in the future.""" + if not resets_at_str: + return False try: - reset_dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) - now = datetime.now(timezone.utc) - delta = reset_dt - now - total_sec = int(delta.total_seconds()) - if total_sec <= 0: - return "any moment" - days, remainder = divmod(total_sec, 86400) - hours, remainder = divmod(remainder, 3600) - minutes, _ = divmod(remainder, 60) - if days > 0: - return f"{days}d {hours}h" - if hours > 0: - return f"{hours}h {minutes}m" - return f"{minutes}m" + resets_at = datetime.fromisoformat(resets_at_str.replace("Z", "+00:00")) + return datetime.now(timezone.utc) < resets_at except Exception: - return iso_str - - -# ── Detail popup window ───────────────────────────────────────────────────── - -class UsageDetailWindow(Gtk.Window): - """Popup window showing detailed usage info.""" - - def __init__(self, usage_data: dict | None, last_updated: str, token_status: str, user_info: dict | None = None): - super().__init__(title="Claude AI Usage") - self.set_default_size(380, -1) - self.set_resizable(False) - self.set_position(Gtk.WindowPosition.MOUSE) - self.set_type_hint(Gdk.WindowTypeHint.DIALOG) - self.set_keep_above(True) - self.set_decorated(True) - - # Lose focus → close - self.connect("focus-out-event", lambda *_: self.destroy()) - - # Apply CSS - css = Gtk.CssProvider() - css.load_from_data(b""" - window { background-color: #1a1a2e; } - .title-label { color: #e0e0ff; font-size: 16px; font-weight: bold; } - .section-label { color: #a0a0c0; font-size: 11px; font-weight: bold; letter-spacing: 2px; } - .metric-value { color: #ffffff; font-size: 28px; font-weight: bold; } - .metric-sub { color: #8888aa; font-size: 11px; } - .reset-label { color: #6b7280; font-size: 11px; } - .status-ok { color: #22c55e; font-size: 11px; } - .status-warn { color: #eab308; font-size: 11px; } - .status-err { color: #ef4444; font-size: 11px; } - .bar-bg { background-color: #2a2a4a; border-radius: 4px; } - .separator { background-color: #2a2a4a; } - """) - Gtk.StyleContext.add_provider_for_screen( - Gdk.Screen.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - vbox.set_margin_top(16) - vbox.set_margin_bottom(16) - vbox.set_margin_start(20) - vbox.set_margin_end(20) - - # Header - header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - title = Gtk.Label(label="⚡ Claude Usage") - title.get_style_context().add_class("title-label") - header.pack_start(title, False, False, 0) - - status_label = Gtk.Label(label=f"● {token_status}") - sc = "status-ok" if token_status == "Connected" else ("status-warn" if token_status == "Rate limited" else "status-err") - status_label.get_style_context().add_class(sc) - status_label.set_halign(Gtk.Align.END) - header.pack_end(status_label, False, False, 0) - vbox.pack_start(header, False, False, 0) - - # Subscription info (if available) - if user_info: - sub_type = user_info.get("subscription_type") - if sub_type: - sub_label = Gtk.Label(label=f"Plan: {sub_type}") - sub_label.get_style_context().add_class("metric-sub") - sub_label.set_halign(Gtk.Align.START) - vbox.pack_start(sub_label, False, False, 0) - - if usage_data: - # Extra usage section (pay-as-you-go credits) - extra = usage_data.get("extra_usage") or {} - if extra and extra.get("is_enabled"): - sep = Gtk.Separator() - sep.get_style_context().add_class("separator") - vbox.pack_start(sep, False, False, 4) - - extra_section = Gtk.Label(label="EXTRA USAGE (MONTHLY)") - extra_section.get_style_context().add_class("section-label") - extra_section.set_halign(Gtk.Align.START) - vbox.pack_start(extra_section, False, False, 0) - - used = extra.get("used_credits", 0) - limit = extra.get("monthly_limit", 0) - extra_pct = min(int(extra.get("utilization", 0)), 100) - extra_decimal = extra_pct / 100 - extra_color = get_color_for_pct(extra_decimal) - - extra_val = Gtk.Label() - extra_val.set_markup( - f'{extra_pct}%' - ) - extra_val.set_halign(Gtk.Align.START) - vbox.pack_start(extra_val, False, False, 0) - - credits_lbl = Gtk.Label(label=f"{used:.0f} / {limit:.0f} credits used") - credits_lbl.get_style_context().add_class("metric-sub") - credits_lbl.set_halign(Gtk.Align.START) - vbox.pack_start(credits_lbl, False, False, 0) - - for key, label_text in [("five_hour", "5-HOUR WINDOW"), ("seven_day", "7-DAY WINDOW")]: - bucket = usage_data.get(key) - if not bucket: - continue - - sep = Gtk.Separator() - sep.get_style_context().add_class("separator") - vbox.pack_start(sep, False, False, 4) - - section_label = Gtk.Label(label=label_text) - section_label.get_style_context().add_class("section-label") - section_label.set_halign(Gtk.Align.START) - vbox.pack_start(section_label, False, False, 0) - - utilization = bucket.get("utilization", 0) - # Handle both decimal (0-1) and percentage (0-100) formats - if utilization > 1: # Already a percentage - pct = int(utilization) - utilization_decimal = utilization / 100 - else: # Decimal format - pct = int(utilization * 100) - utilization_decimal = utilization - - # Big number - val = Gtk.Label(label=f"{pct}%") - val.get_style_context().add_class("metric-value") - color = get_color_for_pct(utilization_decimal) - val.set_markup(f'{pct}%') - val.set_halign(Gtk.Align.START) - vbox.pack_start(val, False, False, 0) - - # Progress bar (GTK level bar) - bar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - bar_box.get_style_context().add_class("bar-bg") - - bar = Gtk.LevelBar() - bar.set_min_value(0) - bar.set_max_value(1.0) - bar.set_value(utilization_decimal) - bar.set_size_request(-1, 8) - - # Remove default offset classes and add custom - bar.remove_offset_value("low") - bar.remove_offset_value("high") - bar.remove_offset_value("full") - bar_css = Gtk.CssProvider() - bar_css.load_from_data(f""" - levelbar trough {{ - background-color: #2a2a4a; - border-radius: 4px; - min-height: 8px; - }} - levelbar trough block.filled {{ - background-color: {color}; - border-radius: 4px; - min-height: 8px; - }} - """.encode()) - bar.get_style_context().add_provider(bar_css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) - - bar_box.pack_start(bar, True, True, 0) - vbox.pack_start(bar_box, False, False, 0) - - # Reset time - resets = bucket.get("resets_at") - reset_str = format_reset_time(resets) - reset_lbl = Gtk.Label(label=f"Resets in {reset_str}") - reset_lbl.get_style_context().add_class("reset-label") - reset_lbl.set_halign(Gtk.Align.START) - vbox.pack_start(reset_lbl, False, False, 0) - else: - err_label = Gtk.Label(label="Unable to fetch usage data.\nCheck token and connectivity.") - err_label.get_style_context().add_class("status-err") - vbox.pack_start(err_label, False, False, 8) - - # Footer - sep = Gtk.Separator() - sep.get_style_context().add_class("separator") - vbox.pack_start(sep, False, False, 4) - - footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - updated = Gtk.Label(label=f"Updated: {last_updated}") - updated.get_style_context().add_class("metric-sub") - footer.pack_start(updated, False, False, 0) - - refresh_btn = Gtk.Button(label="↻ Refresh") - refresh_btn.set_relief(Gtk.ReliefStyle.NONE) - refresh_css = Gtk.CssProvider() - refresh_css.load_from_data(b""" - button { color: #8888aa; background: transparent; border: none; padding: 2px 8px; } - button:hover { color: #e0e0ff; } - """) - refresh_btn.get_style_context().add_provider(refresh_css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) - refresh_btn.connect("clicked", lambda _: (self.destroy(), app.force_refresh())) - footer.pack_end(refresh_btn, False, False, 0) - - vbox.pack_start(footer, False, False, 0) - - # Version info - version_label = Gtk.Label(label=f"v{__version__}") - version_label.get_style_context().add_class("reset-label") - version_label.set_halign(Gtk.Align.CENTER) - version_label.set_margin_top(4) - vbox.pack_start(version_label, False, False, 0) - - self.add(vbox) - self.show_all() - - -# ── Token entry dialog ────────────────────────────────────────────────────── - -class TokenDialog(Gtk.Dialog): - def __init__(self, parent=None): - super().__init__(title="Claude OAuth Token", transient_for=parent, flags=0) - self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OK, Gtk.ResponseType.OK) - self.set_default_size(450, -1) - - box = self.get_content_area() - box.set_spacing(8) - box.set_margin_top(12) - box.set_margin_bottom(12) - box.set_margin_start(12) - box.set_margin_end(12) - - label = Gtk.Label() - label.set_markup( - "Enter your Claude OAuth token.\n" - "Get it from ~/.claude/.credentials.json (Claude Code)\n" - "or browser DevTools → Network → api.anthropic.com headers." - ) - label.set_line_wrap(True) - box.pack_start(label, False, False, 0) - - self.entry = Gtk.Entry() - self.entry.set_placeholder_text("sk-ant-oat01-...") - self.entry.set_visibility(False) - box.pack_start(self.entry, False, False, 0) + return False - self.show_all() - def get_token(self) -> str: - return self.entry.get_text().strip() +def _is_usage_stale(usage: dict) -> bool: + """Return True if any usage window's resets_at has passed, meaning the period rolled over.""" + now = datetime.now(timezone.utc) + for key in ("five_hour", "seven_day"): + window = usage.get(key) or {} + resets_at_str = window.get("resets_at") + if resets_at_str: + try: + resets_at = datetime.fromisoformat(resets_at_str.replace("Z", "+00:00")) + if now >= resets_at: + return True + except Exception: + pass + return False # ── Main App ──────────────────────────────────────────────────────────────── class ClaudeUsageApp: def __init__(self): - self.usage_data: dict | None = None - self.subscription_info: dict | None = None - self.last_updated: str = "never" - self.token: str | None = None + self.config = load_config() + self.accounts = self.config.get("accounts", DEFAULT_ACCOUNTS) + self.poll_interval = self.config.get("poll_interval_seconds", DEFAULT_POLL_INTERVAL) + self.thresholds = self.config.get("thresholds", DEFAULT_THRESHOLDS) + self.burn_rate_cfg = self.config.get("burn_rate", {"enabled": False, "multiplier": 1.5}) + + # Per-account state — seed notification fields from disk so restarts don't re-fire + persisted = load_notification_state() + self.account_states: dict[str, dict] = {} + for acct in self.accounts: + state = self._blank_state(acct["credentials_dir"]) + saved = persisted.get(acct["label"], {}) + state["last_notification_threshold"] = saved.get("last_notification_threshold", 0) + state["last_threshold_five_resets_at"] = saved.get("last_threshold_five_resets_at") + state["last_threshold_seven_resets_at"] = saved.get("last_threshold_seven_resets_at") + state["last_burn_rate_resets_at"] = saved.get("last_burn_rate_resets_at") + state["last_burn_rate_threshold"] = saved.get("last_burn_rate_threshold", 0) + state["last_seven_resets_at"] = saved.get("last_seven_resets_at") + self.account_states[acct["label"]] = state + + self.last_updated = "never" self.running = True - self.last_notification_threshold: int = 0 # Track last notified threshold (0, 75, 90, 100) - self.startup_notification_sent: bool = False + self.startup_notification_sent = False + self._loading = False + self._loading_frame = 0 Notify.init(APP_NAME) # Create indicator icon_path = write_icon(0, error=True) self.indicator = AppIndicator3.Indicator.new( - APP_ID, - icon_path, - AppIndicator3.IndicatorCategory.APPLICATION_STATUS, + APP_ID, icon_path, AppIndicator3.IndicatorCategory.APPLICATION_STATUS, ) self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) self.indicator.set_title(APP_NAME) self.indicator.set_label("--", "") - # Build menu - self.menu = Gtk.Menu() + self._build_menu() - self.item_5h = Gtk.MenuItem(label="5h: --%") - self.item_5h.set_sensitive(False) - self.menu.append(self.item_5h) + # Start background polling + self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True) + self.poll_thread.start() - self.item_7d = Gtk.MenuItem(label="7d: --%") - self.item_7d.set_sensitive(False) - self.menu.append(self.item_7d) + def _build_menu(self): + self.menu = Gtk.Menu() + self.menu_items = {} - self.item_extra = Gtk.MenuItem(label="") - self.item_extra.set_sensitive(False) - self.item_extra.set_no_show_all(True) - self.menu.append(self.item_extra) + for acct in self.accounts: + lbl = acct["label"] + item = Gtk.MenuItem(label=f"{lbl}: --%") + item.set_sensitive(False) + self.menu.append(item) + self.menu_items[lbl] = item self.menu.append(Gtk.SeparatorMenuItem()) - item_details = Gtk.MenuItem(label="Show Details…") + item_details = Gtk.MenuItem(label="Show Details...") item_details.connect("activate", self.on_show_details) self.menu.append(item_details) @@ -525,9 +335,9 @@ def __init__(self): item_refresh.connect("activate", lambda _: self.force_refresh()) self.menu.append(item_refresh) - item_token = Gtk.MenuItem(label="Set Token…") - item_token.connect("activate", self.on_set_token) - self.menu.append(item_token) + item_configure = Gtk.MenuItem(label="Configure...") + item_configure.connect("activate", self.on_configure) + self.menu.append(item_configure) self.menu.append(Gtk.SeparatorMenuItem()) @@ -538,192 +348,376 @@ def __init__(self): self.menu.show_all() self.indicator.set_menu(self.menu) - # Load token and subscription info - self.token = load_token() - self.subscription_info = load_subscription_info() - if not self.token: - GLib.timeout_add_seconds(2, self._prompt_token_once) - - # Start background polling - self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True) - self.poll_thread.start() - - def _prompt_token_once(self): - """Show token dialog on first run if no token found.""" - if not self.token: - self.on_set_token(None) - return False # don't repeat - def _poll_loop(self): - """Background thread: fetch usage periodically.""" + """Background thread: fetch usage for all accounts periodically.""" + # Reason: initial fetch always runs for all accounts regardless of disable_polling + results = {label: self._fetch_account(label, state) + for label, state in self.account_states.items()} + GLib.idle_add(self._update_ui, results) while self.running: - try: - # Re-read credentials on every cycle so a token refreshed - # by Claude Code overnight is picked up automatically. - fresh = load_token() - if fresh: - self.token = fresh - if self.token: - data = fetch_usage(self.token) - GLib.idle_add(self._update_ui, data) - except RateLimitError: - # Show ERR but keep last good data so details window still works - print("[claude-usage] Rate limited, backing off 10 min", file=sys.stderr) - GLib.idle_add(self._set_rate_limit_ui) - time.sleep(600) - continue - except Exception as e: - print(f"[claude-usage] Poll error: {e}", file=sys.stderr) - time.sleep(REFRESH_INTERVAL_SEC) + time.sleep(self.poll_interval) + if any(not a.get("disable_polling", False) for a in self.accounts): + self._do_poll() + + def _do_poll(self): + """Background periodic poll — respects per-account disable_polling.""" + acct_cfg = {a["label"]: a for a in self.accounts} + results = {} + for label, state in self.account_states.items(): + if not acct_cfg.get(label, {}).get("disable_polling", False): + results[label] = self._fetch_account(label, state) + GLib.idle_add(self._update_ui, results) + + def _fetch_account(self, label: str, state: dict) -> dict: + """Fetch usage for a single account. Returns update dict.""" + cred_dir = state["credentials_dir"] + try: + token = load_token(cred_dir) + if not token: + return {"error": "No token", "usage_data": None, "token": None} + data = fetch_usage(token) + sub_info = load_subscription_info(cred_dir) + if data is None: + return {"error": "API error", "usage_data": None, "token": token, + "subscription_info": sub_info} + return {"error": None, "usage_data": data, "token": token, + "subscription_info": sub_info} + except RateLimitError: + print(f"[claude-usage] {label}: rate limited", file=sys.stderr) + return {"error": "Rate limited", "usage_data": None, "token": state.get("token")} + except Exception as e: + print(f"[claude-usage] {label}: {e}", file=sys.stderr) + return {"error": str(e), "usage_data": None, "token": state.get("token")} def force_refresh(self): - """Immediate refresh triggered by user.""" + GLib.idle_add(self._start_loading_animation) def _do(): - if self.token: - data = fetch_usage(self.token) - GLib.idle_add(self._update_ui, data) + results = {} + for label, state in self.account_states.items(): + results[label] = self._fetch_account(label, state) + GLib.idle_add(self._update_ui, results) threading.Thread(target=_do, daemon=True).start() - def _set_rate_limit_ui(self): - """Show ERR state without clearing cached usage_data (preserves details window).""" - self.last_updated = datetime.now().strftime("%H:%M:%S") - self.indicator.set_label("ERR", "") - icon_path = write_icon(0, error=True) - self.indicator.set_icon_full(icon_path, "Error") - self.item_5h.set_label("5h: rate limited") - self.item_7d.set_label("7d: rate limited") - return False - - def _update_ui(self, data: dict | None): - """Update indicator label + icon from fetched data (runs on GTK thread).""" - self.usage_data = data + def _start_loading_animation(self): + """Begin spinner animation (must run on GTK thread via idle_add).""" + self._loading = True + self._loading_frame = 0 + GLib.timeout_add(120, self._tick_loading_icon) + return False # one-shot + + def _tick_loading_icon(self): + """Advance spinner one frame; stops automatically when _loading is cleared.""" + if not self._loading: + return False + self._loading_frame = (self._loading_frame + 1) % 8 + icon_path = write_loading_icon(self._loading_frame) + self.indicator.set_icon_full(icon_path, "Refreshing...") + return True # keep going + + def _update_ui(self, results: dict): + """Update indicator label + icon from fetched data (GTK thread).""" + self._loading = False # stop spinner; _tick_loading_icon will not reschedule self.last_updated = datetime.now().strftime("%H:%M:%S") - if data: - five = data.get("five_hour", {}) or {} - seven = data.get("seven_day", {}) or {} - u5 = five.get("utilization", 0) - u7 = seven.get("utilization", 0) - - # Handle both decimal (0-1) and percentage (0-100) formats - if u5 > 1: # Already a percentage - pct5 = int(u5) - u5_decimal = u5 / 100 - else: # Decimal format, convert to percentage - pct5 = int(u5 * 100) - u5_decimal = u5 - - if u7 > 1: # Already a percentage - pct7 = int(u7) - u7_decimal = u7 / 100 - else: # Decimal format, convert to percentage - pct7 = int(u7 * 100) - u7_decimal = u7 - - dominant = max(u5_decimal, u7_decimal) - - self.indicator.set_label(f"{pct5}%", "") - icon_path = write_icon(dominant) - self.indicator.set_icon_full(icon_path, f"{pct5}%") - - self.item_5h.set_label(f"5h: {pct5}% (resets {format_reset_time(five.get('resets_at'))})") - self.item_7d.set_label(f"7d: {pct7}% (resets {format_reset_time(seven.get('resets_at'))})") - - # Extra usage (pay-as-you-go credits) - extra = data.get("extra_usage") or {} - if extra and extra.get("is_enabled"): - used = extra.get("used_credits", 0) - limit = extra.get("monthly_limit", 0) - self.item_extra.set_label(f"Extra: {used:.0f}/{limit:.0f} credits") - self.item_extra.show() + persist_needed = False + for label, result in results.items(): + if result.get("usage_data"): + result["pending_reset"] = False + seven = result["usage_data"].get("seven_day") or {} + new_resets_at = seven.get("resets_at") + if new_resets_at and new_resets_at != self.account_states[label].get("last_seven_resets_at"): + result["last_seven_resets_at"] = new_resets_at + persist_needed = True else: - self.item_extra.hide() + cached = self.account_states[label].get("usage_data") + if cached and _is_usage_stale(cached): + # Window(s) have reset — cached data is misleading, clear it and wait for fresh data + result = {k: v for k, v in result.items() if k != "usage_data"} + result["pending_reset"] = True + elif cached: + # Still within the window, preserve last known values + result = {k: v for k, v in result.items() if k != "usage_data"} + # else: no cached data at all — leave as-is (will show ! or ? if 7d still active) + self.account_states[label].update(result) + + if persist_needed: + self._persist_notification_state() + + # Build tray label: "G:67% N:12%" — accounts with hide_from_tray are skipped + label_parts = [] + max_pct = 0 + any_ok = False + + for acct in self.accounts: + lbl = acct["label"] + hide = acct.get("hide_from_tray", False) + state = self.account_states[lbl] + usage = state.get("usage_data") + + if usage: + five = usage.get("five_hour", {}) or {} + seven = usage.get("seven_day", {}) or {} + pct5, _ = parse_utilization(five.get("utilization", 0)) + pct7, _ = parse_utilization(seven.get("utilization", 0)) + + if not hide: + any_ok = True + label_parts.append(f"{lbl}:{pct5}%") + max_pct = max(max_pct, pct5, pct7) + + r5 = format_reset_clock(five.get("resets_at")) if acct.get("disable_polling", False) else format_reset_time(five.get("resets_at")) + burn_rate = compute_burn_rate(seven) + if burn_rate is not None: + arrow = "\u2191" if burn_rate >= 1.0 else "\u2193" + br_str = f" {arrow}{burn_rate:.1f}\u00d7 \u21ba {r5}" + else: + br_str = f" \u21ba {r5}" + self.menu_items[lbl].set_label(f"{lbl}: {pct7}%{br_str}") + else: + seven_resets_at = state.get("last_seven_resets_at") + seven_active = _is_resets_at_future(seven_resets_at) + if not hide: + if state.get("pending_reset") or seven_active: + label_parts.append(f"{lbl}:?") + else: + label_parts.append(f"{lbl}:!") + if state.get("pending_reset"): + self.menu_items[lbl].set_label(f"{lbl}: waiting for new period data...") + elif seven_active: + reset_str = format_reset_time(seven_resets_at) + self.menu_items[lbl].set_label(f"{lbl}: ? \u21ba {reset_str}") + else: + self.menu_items[lbl].set_label(f"{lbl}: {state.get('error', 'error')}") + + tray_text = " ".join(label_parts) + self.indicator.set_label(tray_text, "") + + icon_path = write_icon(max_pct) if any_ok else write_icon(0, error=True) + self.indicator.set_icon_full(icon_path, tray_text) + + # Startup notification + if not self.startup_notification_sent and any_ok: + self.startup_notification_sent = True + n = Notify.Notification.new("Claude Usage Widget Started", tray_text, "dialog-information") + n.show() - # Send notifications at specific thresholds only - self._check_and_notify_threshold(pct5, pct7, dominant) - else: - self.indicator.set_label("ERR", "") - icon_path = write_icon(0, error=True) - self.indicator.set_icon_full(icon_path, "Error") - self.item_5h.set_label("5h: error") - self.item_7d.set_label("7d: error") + # Per-account threshold notifications + for acct in self.accounts: + lbl = acct["label"] + state = self.account_states[lbl] + usage = state.get("usage_data") + if not usage: + continue + self._check_threshold(lbl, usage, state) + self._check_burn_rate(lbl, usage, state) return False # GLib.idle_add one-shot - def _check_and_notify_threshold(self, pct5: int, pct7: int, dominant: float): - """Send notifications only at specific thresholds: startup, 75%, 90%, 100%""" - # Startup notification (first successful data fetch) + @staticmethod + def _resets_at_changed(stored_str: str | None, new_str: str | None, tolerance_hours: float) -> bool: + """Return True if resets_at shifted enough to indicate a new window.""" + if not new_str: + return False + if stored_str is None: + return True + try: + stored_dt = datetime.fromisoformat(stored_str.replace("Z", "+00:00")) + new_dt = datetime.fromisoformat(new_str.replace("Z", "+00:00")) + return abs((new_dt - stored_dt).total_seconds()) > tolerance_hours * 3600 + except Exception: + return stored_str != new_str + + def _check_threshold(self, label: str, usage: dict, state: dict): + """Send notification when an account crosses a threshold. + + Resets the escalation level whenever either the 5h or 7d window rolls + over, so notifications fire again at the start of each new window. + """ if not self.startup_notification_sent: - self.startup_notification_sent = True - n = Notify.Notification.new( - "✓ Claude Usage Widget Started", - f"Current usage: 5h: {pct5}% | 7d: {pct7}%", - "dialog-information", - ) - n.show() return - # Determine current threshold - pct_val = int(dominant * 100) - current_threshold = 0 - - if pct_val >= 100: - current_threshold = 100 - elif pct_val >= 90: - current_threshold = 90 - elif pct_val >= 75: - current_threshold = 75 - - # Only notify if crossing a new threshold - if current_threshold > self.last_notification_threshold: - if current_threshold == 75: - n = Notify.Notification.new( - "⚠️ Claude Usage: 75%", - f"5h: {pct5}% | 7d: {pct7}%\nApproaching rate limits.", - "dialog-warning", - ) - n.set_urgency(Notify.Urgency.NORMAL) - elif current_threshold == 90: - n = Notify.Notification.new( - "⚠️ Claude Usage: 90%", - f"5h: {pct5}% | 7d: {pct7}%\nClose to rate limits!", - "dialog-warning", - ) - n.set_urgency(Notify.Urgency.CRITICAL) - elif current_threshold == 100: - n = Notify.Notification.new( - "🛑 Claude Usage: 100%", - f"5h: {pct5}% | 7d: {pct7}%\nRate limit reached!", - "dialog-error", - ) - n.set_urgency(Notify.Urgency.CRITICAL) + five = usage.get("five_hour", {}) or {} + seven = usage.get("seven_day", {}) or {} + pct5, _ = parse_utilization(five.get("utilization", 0)) + pct7, _ = parse_utilization(seven.get("utilization", 0)) + pct = max(pct5, pct7) + + five_resets_at = five.get("resets_at") + seven_resets_at = seven.get("resets_at") + + # Detect window rollovers — 5h uses 1h tolerance, 7d uses 6h tolerance + five_new = self._resets_at_changed(state.get("last_threshold_five_resets_at"), five_resets_at, 1) + seven_new = self._resets_at_changed(state.get("last_threshold_seven_resets_at"), seven_resets_at, 6) + if five_new or seven_new: + state["last_notification_threshold"] = 0 + if five_resets_at: + state["last_threshold_five_resets_at"] = five_resets_at + if seven_resets_at: + state["last_threshold_seven_resets_at"] = seven_resets_at + + prev = state.get("last_notification_threshold", 0) + warn = self.thresholds.get("warn", 60) + crit = self.thresholds.get("critical", 85) + + current = 0 + if pct >= 100: + current = 100 + elif pct >= crit: + current = crit + elif pct >= warn: + current = warn + + if current <= prev: + return + + urgency = Notify.Urgency.CRITICAL if current >= crit else Notify.Urgency.NORMAL + icon = "dialog-warning" if current >= crit else "dialog-information" + n = Notify.Notification.new(f"{label}: Usage at {pct}%", f"Account {label} reached {pct}%", icon) + n.set_urgency(urgency) + n.show() + state["last_notification_threshold"] = current + self._persist_notification_state() + + @staticmethod + def _blank_state(credentials_dir: str) -> dict: + return { + "credentials_dir": credentials_dir, + "token": None, "usage_data": None, "pending_reset": False, + "subscription_info": None, "error": None, + "last_notification_threshold": 0, # highest level already notified this window + "last_threshold_five_resets_at": None, # 5h window resets_at seen last poll + "last_threshold_seven_resets_at": None, # 7d window resets_at seen last poll + "last_burn_rate_resets_at": None, # resets_at value of the current window + "last_burn_rate_threshold": 0, # highest usage % level already notified (0/warn/critical) + "last_seven_resets_at": None, # last known 7d resets_at, persisted across restarts + } + + def _persist_notification_state(self): + """Persist notification state to disk so restarts don't re-fire. + + Both threshold and burn-rate state are now safe to persist because both + track their respective window resets_at timestamps — a new window is + detected on the next poll and the level resets to 0 automatically. + """ + state_map = { + lbl: { + "last_notification_threshold": s.get("last_notification_threshold", 0), + "last_threshold_five_resets_at": s.get("last_threshold_five_resets_at"), + "last_threshold_seven_resets_at": s.get("last_threshold_seven_resets_at"), + "last_burn_rate_resets_at": s.get("last_burn_rate_resets_at"), + "last_burn_rate_threshold": s.get("last_burn_rate_threshold", 0), + "last_seven_resets_at": s.get("last_seven_resets_at"), + } + for lbl, s in self.account_states.items() + } + save_notification_state(state_map) + + def on_configure(self, _widget): + ConfigWindow(self.accounts, self.thresholds, self.burn_rate_cfg, + self.poll_interval, self._on_config_saved) + + def _on_config_saved(self, new_config: dict): + """Rebuild live state from saved config and refresh.""" + self.accounts = new_config["accounts"] + self.thresholds = new_config["thresholds"] + self.burn_rate_cfg = new_config["burn_rate"] + self.poll_interval = new_config["poll_interval_seconds"] + persisted = load_notification_state() + self.account_states = {} + for acct in self.accounts: + state = self._blank_state(acct["credentials_dir"]) + saved = persisted.get(acct["label"], {}) + state["last_notification_threshold"] = saved.get("last_notification_threshold", 0) + state["last_threshold_five_resets_at"] = saved.get("last_threshold_five_resets_at") + state["last_threshold_seven_resets_at"] = saved.get("last_threshold_seven_resets_at") + state["last_burn_rate_resets_at"] = saved.get("last_burn_rate_resets_at") + state["last_burn_rate_threshold"] = saved.get("last_burn_rate_threshold", 0) + state["last_seven_resets_at"] = saved.get("last_seven_resets_at") + self.account_states[acct["label"]] = state + self._build_menu() + self.force_refresh() + + def _check_burn_rate(self, label: str, usage: dict, state: dict): + """Notify when 7d burn rate exceeds the multiplier. + + Escalation levels mirror the usage thresholds: + 0 → fires once early in the window (before warn threshold) + warn % → fires again if burn rate still high at warn level + critical % → fires again if burn rate still high at critical level + Each level fires at most once per window cycle. + """ + if not self.burn_rate_cfg.get("enabled"): + return + if not self.startup_notification_sent: + return + + seven = usage.get("seven_day") or {} + resets_at_str = seven.get("resets_at") + if not resets_at_str: + return + + pct7, _ = parse_utilization(seven.get("utilization", 0)) + if pct7 <= 0: + return + + try: + burn_rate = compute_burn_rate(seven) + if burn_rate is None: + return + + multiplier = self.burn_rate_cfg.get("multiplier", 1.5) + if burn_rate < multiplier: + return + + # Reset escalation tracker when a genuinely new window starts (6h tolerance). + if self._resets_at_changed(state.get("last_burn_rate_resets_at"), resets_at_str, 6): + state["last_burn_rate_resets_at"] = resets_at_str + state["last_burn_rate_threshold"] = 0 + + # Determine which escalation level we're at (mirrors usage thresholds) + warn = self.thresholds.get("warn", 60) + crit = self.thresholds.get("critical", 85) + if pct7 >= crit: + current_level = crit + elif pct7 >= warn: + current_level = warn else: - return # No notification for this threshold + current_level = 1 # early warning — below warn threshold + prev_level = state.get("last_burn_rate_threshold", 0) + if current_level <= prev_level: + return + + projected = int(burn_rate * 100) + urgency = Notify.Urgency.CRITICAL if pct7 >= crit else Notify.Urgency.NORMAL + icon = "dialog-warning" if pct7 >= crit else "dialog-information" + n = Notify.Notification.new( + f"{label}: High burn rate", + f"7d usage at {pct7}% — on pace for {projected}%", + icon, + ) + n.set_urgency(urgency) n.show() - self.last_notification_threshold = current_threshold + state["last_burn_rate_threshold"] = current_level + # Persist so a widget restart won't re-fire for the same window/level + self._persist_notification_state() + except Exception as e: + print(f"[claude-usage] burn rate check error: {e}", file=sys.stderr) def on_show_details(self, _widget): - if self.usage_data: - token_status = "Connected" - elif not self.token: - token_status = "No token" - elif self.item_5h.get_label().endswith("rate limited"): - token_status = "Rate limited" - else: - token_status = "Error" - UsageDetailWindow(self.usage_data, self.last_updated, token_status, self.subscription_info) - - def on_set_token(self, _widget): - dialog = TokenDialog() - response = dialog.run() - if response == Gtk.ResponseType.OK: - token = dialog.get_token() - if token: - self.token = token - save_token(token) - self.force_refresh() - dialog.destroy() + accts_data = [] + for acct in self.accounts: + lbl = acct["label"] + state = self.account_states[lbl] + accts_data.append({ + "label": lbl, + "usage_data": state.get("usage_data"), + "error": state.get("error"), + "subscription_info": state.get("subscription_info"), + "disable_polling": acct.get("disable_polling", False), + }) + UsageDetailWindow(accts_data, self.last_updated, self.thresholds, + self.burn_rate_cfg, __version__, self.force_refresh) def on_quit(self, _widget): self.running = False diff --git a/config_window.py b/config_window.py new file mode 100644 index 0000000..2f1b346 --- /dev/null +++ b/config_window.py @@ -0,0 +1,452 @@ +""" +Configuration window for Claude Usage Widget. +Two tabs: Accounts (add/edit/remove) and Notifications (thresholds + burn rate). +On save, writes config.json and calls back so the app reloads live. +""" + +import gi +gi.require_version('Gtk', '3.0') + +import json +import os +from pathlib import Path + +from gi.repository import Gtk, Gdk + +CONFIG_DIR = Path.home() / ".config" / "claude-usage-widget" +CONFIG_FILE = CONFIG_DIR / "config.json" + +DEFAULT_THRESHOLDS = {"warn": 60, "critical": 85} +DEFAULT_BURN_RATE = {"enabled": False, "multiplier": 1.5} + + +class ConfigWindow(Gtk.Window): + """Dialog for editing accounts and notification settings.""" + + def __init__(self, current_accounts: list[dict], thresholds: dict, + burn_rate_cfg: dict, poll_interval_secs: int, on_save): + super().__init__(title="Configure") + self.set_default_size(540, -1) + self.set_resizable(False) + self.set_position(Gtk.WindowPosition.CENTER) + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + self.set_keep_above(True) + self._on_save_cb = on_save + self._rows: list[tuple[Gtk.Entry, Gtk.Entry, Gtk.CheckButton, Gtk.CheckButton]] = [] + + self._apply_css() + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + notebook = Gtk.Notebook() + notebook.set_margin_top(8) + notebook.set_margin_start(4) + notebook.set_margin_end(4) + notebook.append_page( + self._build_accounts_tab(current_accounts), + Gtk.Label(label="Accounts"), + ) + notebook.append_page( + self._build_notifications_tab(thresholds, burn_rate_cfg, poll_interval_secs), + Gtk.Label(label="Notifications"), + ) + outer.pack_start(notebook, True, True, 0) + + # Footer (shared across tabs) + sep = Gtk.Separator() + sep.get_style_context().add_class("cfg-sep") + outer.pack_start(sep, False, False, 0) + + footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + footer.set_margin_top(10) + footer.set_margin_bottom(12) + footer.set_margin_start(20) + footer.set_margin_end(20) + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.connect("clicked", lambda _: self.destroy()) + save_btn = Gtk.Button(label="Save & Refresh") + save_btn.connect("clicked", self._on_save) + footer.pack_end(save_btn, False, False, 0) + footer.pack_end(cancel_btn, False, False, 0) + outer.pack_start(footer, False, False, 0) + + self.add(outer) + self.show_all() + + # ── Tab builders ────────────────────────────────────────────────────────── + + def _build_accounts_tab(self, current_accounts: list[dict]) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_margin_top(16) + box.set_margin_bottom(8) + box.set_margin_start(20) + box.set_margin_end(20) + + # Column headers + hdr_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + for text, expand in [("Label", False), ("Credentials Directory", True)]: + h = Gtk.Label(label=text) + h.get_style_context().add_class("cfg-col-header") + h.set_halign(Gtk.Align.START) + hdr_row.pack_start(h, expand, expand, 0) + box.pack_start(hdr_row, False, False, 0) + + self._rows_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.pack_start(self._rows_box, False, False, 0) + + for acct in current_accounts: + self._add_row(acct.get("label", ""), acct.get("credentials_dir", ""), + acct.get("hide_from_tray", False), acct.get("disable_polling", False)) + + add_btn = Gtk.Button(label="+ Add Account") + add_btn.get_style_context().add_class("cfg-add-btn") + add_btn.set_halign(Gtk.Align.START) + add_btn.connect("clicked", lambda _: self._add_row("", "~/.claude", False, False)) + box.pack_start(add_btn, False, False, 4) + + return box + + def _build_notifications_tab(self, thresholds: dict, + burn_rate_cfg: dict, + poll_interval_secs: int) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + box.set_margin_top(16) + box.set_margin_bottom(8) + box.set_margin_start(20) + box.set_margin_end(20) + + # ── Polling interval ───────────────────────────────────────────────── + poll_lbl = Gtk.Label(label="POLLING") + poll_lbl.get_style_context().add_class("cfg-section-header") + poll_lbl.set_halign(Gtk.Align.START) + box.pack_start(poll_lbl, False, False, 0) + + poll_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + poll_field_lbl = Gtk.Label(label="Check every") + poll_field_lbl.get_style_context().add_class("cfg-field-label") + self._poll_spin = Gtk.SpinButton.new_with_range(1, 60, 1) + self._poll_spin.set_value(max(1, poll_interval_secs // 60)) + poll_suffix = Gtk.Label(label="minutes") + poll_suffix.get_style_context().add_class("cfg-field-label") + poll_row.pack_start(poll_field_lbl, False, False, 0) + poll_row.pack_start(self._poll_spin, False, False, 0) + poll_row.pack_start(poll_suffix, False, False, 0) + box.pack_start(poll_row, False, False, 0) + + box.pack_start(Gtk.Separator(), False, False, 0) + + # ── Usage thresholds ────────────────────────────────────────────────── + thresh_lbl = Gtk.Label(label="USAGE THRESHOLDS") + thresh_lbl.get_style_context().add_class("cfg-section-header") + thresh_lbl.set_halign(Gtk.Align.START) + box.pack_start(thresh_lbl, False, False, 0) + + grid = Gtk.Grid() + grid.set_column_spacing(12) + grid.set_row_spacing(8) + + warn_label = Gtk.Label(label="Warn at") + warn_label.get_style_context().add_class("cfg-field-label") + warn_label.set_halign(Gtk.Align.START) + self._warn_spin = Gtk.SpinButton.new_with_range(0, 100, 1) + self._warn_spin.set_value(thresholds.get("warn", DEFAULT_THRESHOLDS["warn"])) + warn_suffix = Gtk.Label(label="%") + warn_suffix.get_style_context().add_class("cfg-field-label") + + crit_label = Gtk.Label(label="Critical at") + crit_label.get_style_context().add_class("cfg-field-label") + crit_label.set_halign(Gtk.Align.START) + self._crit_spin = Gtk.SpinButton.new_with_range(0, 100, 1) + self._crit_spin.set_value(thresholds.get("critical", DEFAULT_THRESHOLDS["critical"])) + crit_suffix = Gtk.Label(label="%") + crit_suffix.get_style_context().add_class("cfg-field-label") + + grid.attach(warn_label, 0, 0, 1, 1) + grid.attach(self._warn_spin, 1, 0, 1, 1) + grid.attach(warn_suffix, 2, 0, 1, 1) + grid.attach(crit_label, 0, 1, 1, 1) + grid.attach(self._crit_spin, 1, 1, 1, 1) + grid.attach(crit_suffix, 2, 1, 1, 1) + box.pack_start(grid, False, False, 0) + + box.pack_start(Gtk.Separator(), False, False, 0) + + # ── Burn rate alert ─────────────────────────────────────────────────── + burn_lbl = Gtk.Label(label="BURN RATE ALERT (7-day window)") + burn_lbl.get_style_context().add_class("cfg-section-header") + burn_lbl.set_halign(Gtk.Align.START) + box.pack_start(burn_lbl, False, False, 0) + + desc = Gtk.Label( + label="Warns when your usage rate suggests you'll exceed\n" + "your weekly limit — e.g. 50% used with only 25% of\n" + "the week elapsed." + ) + desc.get_style_context().add_class("cfg-desc") + desc.set_halign(Gtk.Align.START) + box.pack_start(desc, False, False, 0) + + self._burn_check = Gtk.CheckButton(label="Enable burn rate warnings") + self._burn_check.set_active(burn_rate_cfg.get("enabled", False)) + box.pack_start(self._burn_check, False, False, 0) + + mult_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + mult_lbl = Gtk.Label(label="Alert when on pace for") + mult_lbl.get_style_context().add_class("cfg-field-label") + self._mult_spin = Gtk.SpinButton.new_with_range(1.0, 5.0, 0.1) + self._mult_spin.set_digits(1) + self._mult_spin.set_value(burn_rate_cfg.get("multiplier", DEFAULT_BURN_RATE["multiplier"])) + mult_suffix = Gtk.Label(label="× your weekly allocation") + mult_suffix.get_style_context().add_class("cfg-field-label") + mult_row.pack_start(mult_lbl, False, False, 0) + mult_row.pack_start(self._mult_spin, False, False, 0) + mult_row.pack_start(mult_suffix, False, False, 0) + + # Dim the multiplier row when burn rate is disabled + def on_burn_toggle(btn): + mult_row.set_sensitive(btn.get_active()) + + self._burn_check.connect("toggled", on_burn_toggle) + mult_row.set_sensitive(self._burn_check.get_active()) + box.pack_start(mult_row, False, False, 0) + + return box + + # ── Account row helpers ─────────────────────────────────────────────────── + + def _add_row(self, label: str, cred_dir: str, + hide_from_tray: bool = False, disable_polling: bool = False): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + label_entry = Gtk.Entry() + label_entry.set_text(label) + label_entry.set_width_chars(10) + label_entry.set_max_width_chars(12) + label_entry.set_placeholder_text("Name") + + dir_entry = Gtk.Entry() + dir_entry.set_text(cred_dir) + dir_entry.set_placeholder_text("~/.claude") + + hide_check = Gtk.CheckButton(label="Hide tray") + hide_check.set_active(hide_from_tray) + hide_check.set_tooltip_text("Hide this account from the tray label") + + disable_check = Gtk.CheckButton(label="No poll") + disable_check.set_active(disable_polling) + disable_check.set_tooltip_text("Disable background auto-refresh for this account") + + remove_btn = Gtk.Button(label="✕") + remove_btn.get_style_context().add_class("cfg-remove-btn") + remove_btn.set_relief(Gtk.ReliefStyle.NONE) + + row.pack_start(label_entry, False, False, 0) + row.pack_start(dir_entry, True, True, 0) + row.pack_start(hide_check, False, False, 0) + row.pack_start(disable_check, False, False, 0) + row.pack_start(remove_btn, False, False, 0) + + entry_quad = (label_entry, dir_entry, hide_check, disable_check) + self._rows.append(entry_quad) + self._rows_box.pack_start(row, False, False, 0) + row.show_all() + + def on_remove(_btn, r=row, eq=entry_quad): + self._rows_box.remove(r) + self._rows.remove(eq) + + remove_btn.connect("clicked", on_remove) + + # ── Save ────────────────────────────────────────────────────────────────── + + def _on_save(self, _btn): + accounts = [ + { + "label": le.get_text().strip(), + "credentials_dir": de.get_text().strip(), + "hide_from_tray": hc.get_active(), + "disable_polling": dc.get_active(), + } + for le, de, hc, dc in self._rows + if le.get_text().strip() and de.get_text().strip() + ] + + if not accounts: + dlg = Gtk.MessageDialog( + transient_for=self, flags=0, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="At least one account is required.", + ) + dlg.run() + dlg.destroy() + return + + labels = [a["label"] for a in accounts] + if len(labels) != len(set(labels)): + dlg = Gtk.MessageDialog( + transient_for=self, flags=0, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Account labels must be unique.", + ) + dlg.run() + dlg.destroy() + return + + warn = int(self._warn_spin.get_value()) + crit = int(self._crit_spin.get_value()) + if warn >= crit: + dlg = Gtk.MessageDialog( + transient_for=self, flags=0, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Warn threshold must be lower than Critical threshold.", + ) + dlg.run() + dlg.destroy() + return + + new_config = { + "accounts": accounts, + "poll_interval_seconds": int(self._poll_spin.get_value()) * 60, + "thresholds": {"warn": warn, "critical": crit}, + "burn_rate": { + "enabled": self._burn_check.get_active(), + "multiplier": round(self._mult_spin.get_value(), 1), + }, + } + + try: + existing = json.loads(CONFIG_FILE.read_text()) if CONFIG_FILE.exists() else {} + except (json.JSONDecodeError, OSError): + existing = {} + + existing.update(new_config) + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(existing, indent=2)) + os.chmod(CONFIG_FILE, 0o600) + + self.destroy() + self._on_save_cb(new_config) + + # ── CSS ─────────────────────────────────────────────────────────────────── + + def _apply_css(self): + css = Gtk.CssProvider() + css.load_from_data(b""" + /* Base */ + window, box, grid { background-color: #1a1a2e; } + + /* Notebook tabs */ + notebook > header { + background-color: #12122a; + border-bottom: 1px solid #2a2a4a; + padding: 0; + } + notebook > header > tabs > tab { + background-color: #12122a; + color: #6b7280; + padding: 6px 18px; + border: none; + box-shadow: none; + outline: none; + } + notebook > header > tabs > tab:hover { + background-color: #1e1e38; + color: #c0c0ff; + } + notebook > header > tabs > tab:checked { + background-color: #1a1a2e; + color: #e0e0ff; + font-weight: bold; + border-bottom: 2px solid #7070cc; + } + /* Page content area */ + notebook > stack { + background-color: #1a1a2e; + border: none; + } + + /* Fields */ + .cfg-section-header { color: #7070aa; font-size: 10px; font-weight: bold; letter-spacing: 1px; } + .cfg-col-header { color: #6b7280; font-size: 11px; font-weight: bold; } + .cfg-field-label { color: #a0a0c0; font-size: 12px; } + .cfg-desc { color: #555577; font-size: 11px; } + + /* Inputs */ + entry { + background-color: #20203a; + color: #e0e0ff; + border: 1px solid #3a3a5a; + border-radius: 4px; + padding: 4px 8px; + caret-color: #e0e0ff; + } + entry:focus { border-color: #7070cc; } + + spinbutton entry { border: none; padding: 4px 6px; } + spinbutton { + background-color: #20203a; + color: #e0e0ff; + border: 1px solid #3a3a5a; + border-radius: 4px; + } + spinbutton button { + background-color: #2a2a4a; + color: #a0a0c0; + border: none; + border-left: 1px solid #3a3a5a; + border-radius: 0; + padding: 2px 6px; + min-width: 0; + } + spinbutton button:hover { background-color: #3a3a6a; color: #e0e0ff; } + + checkbutton { color: #c0c0ff; background-color: transparent; } + checkbutton check { + background-color: #20203a; + border: 1px solid #3a3a5a; + border-radius: 3px; + } + checkbutton check:checked { background-color: #5050aa; border-color: #7070cc; } + + /* Buttons */ + button { + color: #8888aa; + background-color: transparent; + border: 1px solid #3a3a5a; + border-radius: 4px; + padding: 4px 12px; + } + button:hover { color: #e0e0ff; border-color: #6060aa; } + + .cfg-remove-btn { + color: #ef4444; background: transparent; + border: none; padding: 2px 6px; + } + .cfg-remove-btn:hover { color: #ff7070; } + + .cfg-add-btn { + color: #7070aa; background: transparent; + border: 1px solid #3a3a5a; border-radius: 4px; padding: 4px 12px; + } + .cfg-add-btn:hover { color: #c0c0ff; border-color: #6060aa; } + + .cfg-save-btn { + background-color: #4545b0; + color: #ffffff; + border: 1px solid #7575cc; + border-radius: 4px; + padding: 4px 16px; + font-weight: bold; + } + .cfg-save-btn:hover { background-color: #5a5acc; color: #ffffff; border-color: #9090dd; } + + /* Misc */ + separator, .cfg-sep { background-color: #2a2a4a; min-height: 1px; } + label { background-color: transparent; } + """) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) diff --git a/install.sh b/install.sh index b3d6c0f..9dc5d50 100755 --- a/install.sh +++ b/install.sh @@ -9,39 +9,124 @@ echo "╔═══════════════════════ echo "║ Claude AI Usage Widget — Installer ║" echo "╚══════════════════════════════════════════╝" -# ── Dependencies ──────────────────────────────────────────────────────────── +# ── Python — detect pyenv vs system ───────────────────────────────────────── echo "" -echo "▸ Checking dependencies…" +echo "▸ Detecting Python environment…" -MISSING=() +PYTHON="" -# Python 3 -if ! command -v python3 &>/dev/null; then - MISSING+=("python3") +if command -v pyenv &>/dev/null; then + # Resolve pyenv's active python binary + PYTHON=$(pyenv which python3 2>/dev/null || pyenv which python 2>/dev/null || true) + if [ -z "$PYTHON" ]; then + echo " ✗ pyenv found but no Python version is active." + echo " Run: pyenv install 3.12 && pyenv global 3.12" + exit 1 + fi + echo " ✓ pyenv found — using $PYTHON" +else + echo " ✗ pyenv not found." + echo "" + echo " pyenv is recommended for managing Python environments." + echo " Install it with: curl https://pyenv.run | bash" + echo " Then reload your shell and run: pyenv install 3.12 && pyenv global 3.12" + echo "" + read -rp " Continue with system Python instead? [Y/n] " yn + case "${yn,,}" in + n|no) echo " Aborted. Install pyenv then re-run."; exit 1 ;; + *) PYTHON=$(command -v python3) ;; + esac + echo " Using system Python: $PYTHON" fi -# GIR packages -python3 -c "import gi; gi.require_version('Gtk','3.0'); gi.require_version('AppIndicator3','0.1'); gi.require_version('Notify','0.7')" 2>/dev/null || { - MISSING+=("gir1.2-appindicator3-0.1" "gir1.2-notify-0.7") -} +# ── Dependencies ───────────────────────────────────────────────────────────── -if [ ${#MISSING[@]} -gt 0 ]; then - echo " ✗ Missing packages: ${MISSING[*]}" - echo "" - echo " Install them with:" - echo " sudo apt install python3 gir1.2-appindicator3-0.1 gir1.2-notify-0.7 python3-gi" - echo "" - read -rp " Install now? [Y/n] " yn +echo "" +echo "▸ Checking dependencies…" + +# System GI type libraries — always required via apt regardless of Python env. +# These are runtime type data, not Python packages; pip cannot provide them. +SYSTEM_MISSING=() +for pkg in gir1.2-appindicator3-0.1 gir1.2-notify-0.7; do + dpkg -s "$pkg" &>/dev/null || SYSTEM_MISSING+=("$pkg") +done + +if [ ${#SYSTEM_MISSING[@]} -gt 0 ]; then + echo " ✗ Missing system GI libraries: ${SYSTEM_MISSING[*]}" + echo " (These are needed even with pyenv — they are not pip-installable.)" + read -rp " Install via apt now? [Y/n] " yn case "${yn,,}" in n|no) echo " Aborted."; exit 1 ;; *) - sudo apt update - sudo apt install -y python3 python3-gi gir1.2-appindicator3-0.1 gir1.2-notify-0.7 + sudo apt update -qq + sudo apt install -y "${SYSTEM_MISSING[@]}" ;; esac fi +# Python packages — always use a dedicated venv with --copies. +# --copies physically copies the Python binary into the venv, so the widget +# keeps working even if pyenv switches versions or removes the source version. +VENV_DIR="$INSTALL_DIR/venv" +echo " ▸ Creating isolated venv at $VENV_DIR …" +mkdir -p "$INSTALL_DIR" +"$PYTHON" -m venv --copies "$VENV_DIR" +VENV_PYTHON="$VENV_DIR/bin/python3" + +if command -v pyenv &>/dev/null; then + # Build deps needed to compile PyGObject from source. + # PyGObject ≥3.51 requires girepository-2.0 (libgirepository-2.0-dev), + # which is only available on Ubuntu 24.04+. On 22.04 we must use the + # 1.0 API and pin PyGObject to the last compatible release (<3.51). + BUILD_PKGS=(libcairo2-dev pkg-config python3-dev) + if apt-cache show libgirepository-2.0-dev &>/dev/null 2>&1; then + BUILD_PKGS+=(libgirepository-2.0-dev) + PYGOBJECT_VERSION="" # latest works fine + else + BUILD_PKGS+=(libgirepository1.0-dev) + PYGOBJECT_VERSION="<3.51" # last version supporting girepository-1.0 + fi + + MISSING_BUILD=() + for pkg in "${BUILD_PKGS[@]}"; do + dpkg -s "$pkg" &>/dev/null || MISSING_BUILD+=("$pkg") + done + if [ ${#MISSING_BUILD[@]} -gt 0 ]; then + echo " ▸ Installing build deps: ${MISSING_BUILD[*]}" + sudo apt install -y "${MISSING_BUILD[@]}" + fi + + echo " ▸ Installing PyGObject${PYGOBJECT_VERSION:+ (pinned to ${PYGOBJECT_VERSION})} + pycairo into venv…" + "$VENV_PYTHON" -m pip install --quiet --upgrade pip + "$VENV_PYTHON" -m pip install --quiet "PyGObject${PYGOBJECT_VERSION}" pycairo + echo " ✓ pip packages installed into venv" +else + # System python — python3-gi is managed by apt and lives outside venv. + # Add a .pth file to the venv so it can see system site-packages for gi. + if ! "$PYTHON" -c "import gi" 2>/dev/null; then + echo " ✗ python3-gi not found" + read -rp " Install via apt? [Y/n] " yn + case "${yn,,}" in + n|no) echo " Aborted."; exit 1 ;; + *) sudo apt install -y python3-gi ;; + esac + fi + # Allow venv to see system gi package (apt-installed) + SITE_PKG=$("$VENV_PYTHON" -c "import site; print(site.getsitepackages()[0])") + SYS_SITE=$("$PYTHON" -c "import site; print(site.getsitepackages()[0])") + echo "$SYS_SITE" > "$SITE_PKG/system-gi.pth" +fi + +# Final check inside the venv +"$VENV_PYTHON" -c " +import gi +gi.require_version('Gtk','3.0') +gi.require_version('AppIndicator3','0.1') +gi.require_version('Notify','0.7') +from gi.repository import Gtk, AppIndicator3, Notify +" || { echo " ✗ GI import failed inside venv — check errors above"; exit 1; } + echo " ✓ All dependencies satisfied" # ── Install files ─────────────────────────────────────────────────────────── @@ -49,24 +134,23 @@ echo " ✓ All dependencies satisfied" echo "" echo "▸ Installing to $INSTALL_DIR …" -mkdir -p "$INSTALL_DIR" -cp claude_usage_widget.py "$INSTALL_DIR/claude_usage_widget.py" +cp claude_usage_widget.py shared.py usage_popup.py "$INSTALL_DIR/" chmod +x "$INSTALL_DIR/claude_usage_widget.py" mkdir -p "$(dirname "$BIN_LINK")" ln -sf "$INSTALL_DIR/claude_usage_widget.py" "$BIN_LINK" -# Create wrapper scripts for easy start/stop -cat > "$HOME/.local/bin/claude-widget-start" <<'EOFSTART' +# Wrapper scripts use the venv Python — fully independent of pyenv version changes +cat > "$HOME/.local/bin/claude-widget-start" < /tmp/claude-widget.log 2>&1 & +# Start Claude Usage Widget — uses dedicated venv (pyenv-version-independent) +env -i \\ + HOME="\$HOME" \\ + DISPLAY="\$DISPLAY" \\ + DBUS_SESSION_BUS_ADDRESS="\$DBUS_SESSION_BUS_ADDRESS" \\ + XDG_RUNTIME_DIR="\$XDG_RUNTIME_DIR" \\ + PATH="/usr/local/bin:/usr/bin:/bin" \\ + $VENV_PYTHON $INSTALL_DIR/claude_usage_widget.py > /tmp/claude-widget.log 2>&1 & sleep 1 if ps aux | grep -q '[c]laude_usage_widget'; then @@ -106,7 +190,7 @@ cat > "$AUTOSTART_DIR/$APP_ID.desktop" < "$CONFIG_OUT" < str: + """Return color based on percentage (0-100 scale).""" + warn = (thresholds or DEFAULT_THRESHOLDS).get("warn", 60) + critical = (thresholds or DEFAULT_THRESHOLDS).get("critical", 85) + if pct < warn: + return COLOR_GREEN + elif pct < critical: + return COLOR_YELLOW + else: + return COLOR_RED + + +def hex_to_rgb(hex_color: str) -> tuple: + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) / 255.0 for i in (0, 2, 4)) + + +def parse_utilization(raw: float) -> tuple[int, float]: + """Return (percentage_int, decimal_0_to_1) from API utilization value.""" + if raw >= 1: # Already a percentage (API returns 0-100 scale; 1.0 means 1%) + return int(raw), raw / 100 + return int(raw * 100), raw + + +def compute_burn_rate(seven: dict) -> float | None: + """Return the 7d burn rate multiplier, or None if the window is too new.""" + resets_at_str = seven.get("resets_at") + if not resets_at_str: + return None + pct7, _ = parse_utilization(seven.get("utilization", 0)) + try: + resets_at = datetime.fromisoformat(resets_at_str.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + window_secs = 7 * 24 * 3600 + elapsed_secs = window_secs - (resets_at - now).total_seconds() + if elapsed_secs < 0.05 * window_secs: # ignore first ~8h + return None + return (pct7 / 100) / (elapsed_secs / window_secs) + except Exception: + return None + + +def format_reset_clock(iso_str: str | None) -> str: + """Format reset time as a clock time, e.g. '9:00P'. Used when auto-poll is off.""" + if not iso_str: + return "unknown" + try: + reset_dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")).astimezone() + return reset_dt.strftime("%-I:%M") + ("A" if reset_dt.hour < 12 else "P") + except Exception: + return "unknown" + + +def format_reset_clock_7d(iso_str: str | None) -> str: + """Format reset time as day + clock, e.g. 'Th 7:00P'. Used for 7d window when auto-poll is off.""" + if not iso_str: + return "unknown" + try: + reset_dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")).astimezone() + day = reset_dt.strftime("%a")[:2] # "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" + time_str = reset_dt.strftime("%-I:%M") + ("A" if reset_dt.hour < 12 else "P") + return f"{day} {time_str}" + except Exception: + return "unknown" + + +def format_reset_time(iso_str: str | None) -> str: + if not iso_str: + return "unknown" + try: + reset_dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) + total_sec = int((reset_dt - datetime.now(timezone.utc)).total_seconds()) + if total_sec <= 0: + return "any moment" + days, remainder = divmod(total_sec, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + if days > 0: + return f"{days}d {hours}h" + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + except Exception: + return iso_str diff --git a/uninstall.sh b/uninstall.sh index 2f08deb..df99f5c 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -3,25 +3,54 @@ set -euo pipefail APP_ID="claude-usage-widget" INSTALL_DIR="$HOME/.local/share/$APP_ID" -BIN_LINK="$HOME/.local/bin/claude-usage-widget" -echo "▸ Removing Claude Usage Widget…" +echo "╔══════════════════════════════════════════╗" +echo "║ Claude AI Usage Widget — Uninstaller ║" +echo "╚══════════════════════════════════════════╝" +echo "" -# Kill running instance -pkill -f "claude_usage_widget.py" 2>/dev/null || true +# Stop running instance +echo "▸ Stopping widget if running…" +pkill -f "claude_usage_widget.py" 2>/dev/null && echo " ✓ Stopped" || echo " (not running)" +# App files + venv +echo "" +echo "▸ Removing installed files…" rm -rf "$INSTALL_DIR" -rm -f "$BIN_LINK" -rm -f "$HOME/.local/bin/claude-widget-start" -rm -f "$HOME/.local/bin/claude-widget-stop" -rm -f "$HOME/.config/autostart/$APP_ID.desktop" -rm -f "$HOME/.local/share/applications/$APP_ID.desktop" - -# Optionally remove config (preserves token) -read -rp " Remove config (~/.config/$APP_ID)? [y/N] " yn +echo " ✓ $INSTALL_DIR" + +# Bin scripts + symlink +for f in \ + "$HOME/.local/bin/claude-usage-widget" \ + "$HOME/.local/bin/claude-widget-start" \ + "$HOME/.local/bin/claude-widget-stop" +do + rm -f "$f" && echo " ✓ $f" +done + +# Desktop entries +rm -f "$HOME/.config/autostart/$APP_ID.desktop" && echo " ✓ autostart entry" +rm -f "$HOME/.local/share/applications/$APP_ID.desktop" && echo " ✓ app launcher entry" + +# Temp files +rm -f /tmp/claude-widget.log +rm -rf "/tmp/$APP_ID" +echo " ✓ temp files" + +# Config — ask, since the user may want to keep their account setup +echo "" +read -rp "▸ Remove config + account setup (~/.config/$APP_ID)? [y/N] " yn case "${yn,,}" in - y|yes) rm -rf "$HOME/.config/$APP_ID"; echo " ✓ Config removed" ;; - *) echo " ✓ Config preserved" ;; + y|yes) + rm -rf "$HOME/.config/$APP_ID" + echo " ✓ Config removed" + ;; + *) + echo " Config preserved at ~/.config/$APP_ID" + ;; esac -echo " ✓ Uninstalled" +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ ✓ Uninstall complete ║" +echo "╚══════════════════════════════════════════╝" diff --git a/usage_popup.py b/usage_popup.py new file mode 100644 index 0000000..fc321ec --- /dev/null +++ b/usage_popup.py @@ -0,0 +1,215 @@ +""" +Detail popup window for Claude Usage Widget. +Shows per-account usage breakdown with progress bars and reset timers. +""" + +import gi +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, Gdk + +from shared import get_color_for_pct, parse_utilization, format_reset_time, format_reset_clock, format_reset_clock_7d, compute_burn_rate + + +class UsageDetailWindow(Gtk.Window): + """Popup window showing detailed usage info for all accounts.""" + + def __init__(self, accounts_data: list[dict], last_updated: str, + thresholds: dict, burn_rate_cfg: dict, version: str, on_refresh): + super().__init__(title="Claude AI Usage") + self.set_default_size(400, -1) + self.set_resizable(False) + self.set_position(Gtk.WindowPosition.MOUSE) + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + self.set_keep_above(True) + self.set_decorated(True) + self.connect("focus-out-event", lambda *_: self.destroy()) + + self._apply_css() + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + vbox.set_margin_top(16) + vbox.set_margin_bottom(16) + vbox.set_margin_start(20) + vbox.set_margin_end(20) + + # Header + title = Gtk.Label(label="Claude Usage") + title.get_style_context().add_class("title-label") + title.set_halign(Gtk.Align.START) + vbox.pack_start(title, False, False, 0) + + # Render each account + for acct in accounts_data: + self._add_account_section(vbox, acct, thresholds, burn_rate_cfg) + + # Footer + sep = Gtk.Separator() + sep.get_style_context().add_class("separator") + vbox.pack_start(sep, False, False, 4) + + footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + updated = Gtk.Label(label=f"Updated: {last_updated}") + updated.get_style_context().add_class("metric-sub") + footer.pack_start(updated, False, False, 0) + + refresh_btn = Gtk.Button(label="Refresh") + refresh_btn.set_relief(Gtk.ReliefStyle.NONE) + btn_css = Gtk.CssProvider() + btn_css.load_from_data(b""" + button { color: #8888aa; background: transparent; border: none; padding: 2px 8px; } + button:hover { color: #e0e0ff; } + """) + refresh_btn.get_style_context().add_provider(btn_css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + refresh_btn.connect("clicked", lambda _: (self.destroy(), on_refresh())) + footer.pack_end(refresh_btn, False, False, 0) + vbox.pack_start(footer, False, False, 0) + + ver = Gtk.Label(label=f"v{version}") + ver.get_style_context().add_class("reset-label") + ver.set_halign(Gtk.Align.CENTER) + vbox.pack_start(ver, False, False, 0) + + self.add(vbox) + self.show_all() + + def _apply_css(self): + css = Gtk.CssProvider() + css.load_from_data(b""" + window { background-color: #1a1a2e; } + .title-label { color: #e0e0ff; font-size: 16px; font-weight: bold; } + .account-label { color: #c0c0ff; font-size: 14px; font-weight: bold; } + .section-label { color: #a0a0c0; font-size: 11px; font-weight: bold; letter-spacing: 2px; } + .col-header { color: #a0a0c0; font-size: 12px; font-weight: bold; letter-spacing: 1px; border-bottom: 1px solid #2a2a4a; } + .metric-sub { color: #8888aa; font-size: 11px; } + .reset-label { color: #6b7280; font-size: 11px; } + .status-ok { color: #22c55e; font-size: 11px; } + .status-err { color: #ef4444; font-size: 11px; } + .separator { background-color: #2a2a4a; } + """) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def _add_account_section(self, vbox: Gtk.Box, acct: dict, thresholds: dict, + burn_rate_cfg: dict): + """Add one account's usage section to the popup.""" + label = acct["label"] + usage = acct.get("usage_data") + error = acct.get("error") + + sep = Gtk.Separator() + sep.get_style_context().add_class("separator") + vbox.pack_start(sep, False, False, 4) + + # Account header row + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + name = Gtk.Label(label=f"Account: {label}") + name.get_style_context().add_class("account-label") + header.pack_start(name, False, False, 0) + + if error: + st = Gtk.Label(label=f" {error}") + st.get_style_context().add_class("status-err") + header.pack_end(st, False, False, 0) + elif usage: + st = Gtk.Label(label=" Connected") + st.get_style_context().add_class("status-ok") + header.pack_end(st, False, False, 0) + vbox.pack_start(header, False, False, 0) + + # Subscription info + sub_info = acct.get("subscription_info") + if sub_info and sub_info.get("subscription_type"): + sub_lbl = Gtk.Label(label=f"Plan: {sub_info['subscription_type']}") + sub_lbl.get_style_context().add_class("metric-sub") + sub_lbl.set_halign(Gtk.Align.START) + vbox.pack_start(sub_lbl, False, False, 0) + + if not usage: + if not error: + err_lbl = Gtk.Label(label="No data available") + err_lbl.get_style_context().add_class("status-err") + err_lbl.set_halign(Gtk.Align.START) + vbox.pack_start(err_lbl, False, False, 2) + return + + # Two-column table layout: one column per usage window + grid = Gtk.Grid() + grid.set_column_spacing(24) + grid.set_row_spacing(4) + grid.set_column_homogeneous(True) + + windows = [("five_hour", "5h"), ("seven_day", "7d")] + for col, (key, window_label) in enumerate(windows): + bucket = usage.get(key) + + # Row 0: column header + hdr = Gtk.Label(label=window_label) + hdr.get_style_context().add_class("col-header") + hdr.set_halign(Gtk.Align.CENTER) + grid.attach(hdr, col, 0, 1, 1) + + if not bucket: + placeholder = Gtk.Label(label="—") + placeholder.get_style_context().add_class("reset-label") + placeholder.set_halign(Gtk.Align.CENTER) + grid.attach(placeholder, col, 1, 1, 1) + continue + + pct, decimal = parse_utilization(bucket.get("utilization", 0)) + color = get_color_for_pct(pct, thresholds) + resets_at = bucket.get("resets_at") + if acct.get("disable_polling", False): + reset_str = format_reset_clock_7d(resets_at) if key == "seven_day" else format_reset_clock(resets_at) + else: + reset_str = format_reset_time(resets_at) + + # Row 1: progress bar + bar = Gtk.LevelBar() + bar.set_min_value(0) + bar.set_max_value(1.0) + bar.set_value(min(decimal, 1.0)) + bar.set_size_request(-1, 8) + bar.remove_offset_value("low") + bar.remove_offset_value("high") + bar.remove_offset_value("full") + bar_css = Gtk.CssProvider() + bar_css.load_from_data(f""" + levelbar trough {{ background-color: #2a2a4a; border-radius: 4px; min-height: 8px; }} + levelbar trough block.filled {{ background-color: {color}; border-radius: 4px; min-height: 8px; }} + """.encode()) + bar.get_style_context().add_provider(bar_css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + grid.attach(bar, col, 1, 1, 1) + + # Row 2: "72% — ↺ 2h 15m" inline + val = Gtk.Label() + val.set_markup( + f'{pct}%' + f' \u2014 \u21ba {reset_str}' + ) + val.set_halign(Gtk.Align.CENTER) + grid.attach(val, col, 2, 1, 1) + + # Row 3: burn rate — only shown for 7d column + if key == "seven_day": + burn_rate = compute_burn_rate(bucket) + if burn_rate is not None: + arrow = "\u2191" if burn_rate >= 1.0 else "\u2193" + multiplier = burn_rate_cfg.get("multiplier", 1.5) + # Colour: red if over multiplier, green if under 1×, muted otherwise + if burn_rate >= multiplier: + pace_color = "#ef4444" + elif burn_rate < 1.0: + pace_color = "#22c55e" + else: + pace_color = "#8888aa" + pace_lbl = Gtk.Label() + pace_lbl.set_markup( + f'' + f'pace {arrow}{burn_rate:.1f}\u00d7' + ) + pace_lbl.set_halign(Gtk.Align.CENTER) + grid.attach(pace_lbl, col, 3, 1, 1) + + vbox.pack_start(grid, False, False, 4)