diff --git a/bin/blob-agent-usage-claude b/bin/blob-agent-usage-claude new file mode 100755 index 0000000..02169aa --- /dev/null +++ b/bin/blob-agent-usage-claude @@ -0,0 +1,728 @@ +#!/usr/bin/python3 +# blob:summary=Print the Claude Code usage record as JSON +# blob:args=[--force] [--limits-only] +# blob:hidden=true + +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +AGENT_ID = "claude" +AGENT_NAME = "Claude Code" +AUTH_HELP = "Run `claude auth login` to restore usage limits." +USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage" +PROBE_MIN_INTERVAL_SECONDS = 15 +CURRENT_ENOUGH_SECONDS = 900 +INDEX_VERSION = 4 +INDEX_HEAD_BYTES = 4096 + + +def config_dir() -> Path: + return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude") + + +def expand_path(value: str) -> Path: + return Path(os.path.expandvars(os.path.expanduser(value))).resolve() + + +def cache_root() -> Path: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "blob" / "agent-usage" + root.mkdir(parents=True, exist_ok=True) + return root + + +def date_string(value: dt.date) -> str: + return value.strftime("%Y-%m-%d") + + +def recent_date_strings() -> list[str]: + today = dt.datetime.now().date() + return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)] + + +def local_date_string() -> str: + return date_string(dt.datetime.now().date()) + + +def local_date_from_timestamp(value: Any) -> str: + if value is None: + return local_date_string() + + if isinstance(value, (int, float)): + try: + seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) + return date_string(dt.datetime.fromtimestamp(seconds).date()) + except Exception: + return local_date_string() + + raw = str(value).strip() + if not raw: + return local_date_string() + + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.astimezone() + return date_string(parsed.date()) + except Exception: + return local_date_string() + + +def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int: + value = usage.get(snake_key, usage.get(camel_key, 0)) + try: + return round(float(value or 0)) + except Exception: + return 0 + + +def number(value: Any) -> int: + try: + n = float(value or 0) + return round(n) if n == n else 0 + except Exception: + return 0 + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +def read_json(path: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: + if max_age_seconds <= 0 or not path.exists(): + return None + try: + age = time.time() - path.stat().st_mtime + if 0 <= age <= max_age_seconds: + return read_json(path) + except Exception: + return None + return None + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + handle_fd, temporary_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + temporary_path = Path(temporary_name) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n") + temporary_path.chmod(0o644) + temporary_path.replace(path) + except BaseException: + temporary_path.unlink(missing_ok=True) + raise + + +def index_zone() -> list[Any]: + return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone] + + +def load_index(index_file: Path) -> dict[str, Any]: + data = read_json(index_file) + if ( + isinstance(data, dict) + and data.get("version") == INDEX_VERSION + and data.get("zone") == index_zone() + and isinstance(data.get("files"), dict) + ): + return data["files"] + return {} + + +def read_digest(path: Path, length: int) -> str: + digest = hashlib.sha1() + with path.open("rb") as handle: + digest.update(handle.read(min(length, INDEX_HEAD_BYTES))) + handle.seek(max(0, length - INDEX_HEAD_BYTES)) + digest.update(handle.read(min(length, INDEX_HEAD_BYTES))) + return digest.hexdigest() + + +def parse_usage_records(path: Path, entry: dict[str, Any] | None) -> dict[str, Any]: + stat = path.stat() + size = stat.st_size + mtime = stat.st_mtime_ns + + if entry and entry.get("size") == size and entry.get("mtime") == mtime: + return entry + + if ( + entry + and size > number(entry.get("size")) + and entry.get("file") == [stat.st_dev, stat.st_ino] + and entry.get("head") == read_digest(path, number(entry.get("offset"))) + ): + records = list(entry.get("records") or []) + offset = number(entry.get("offset")) + line_number = number(entry.get("lines")) + else: + records = [] + offset = 0 + line_number = 0 + + positions = {record[0]: index for index, record in enumerate(records)} + with path.open("rb") as handle: + handle.seek(offset) + for raw in handle: + if not raw.endswith(b"\n"): + break + offset += len(raw) + line_number += 1 + + if b'"usage":' not in raw: + continue + + try: + parsed = json.loads(raw.decode("utf-8", errors="replace")) + except Exception: + continue + + message = parsed.get("message") if isinstance(parsed.get("message"), dict) else {} + if parsed.get("type") != "assistant" and message.get("role") != "assistant": + continue + + usage = message.get("usage") or parsed.get("usage") + if not isinstance(usage, dict): + continue + + message_id = message.get("id") or parsed.get("messageId") or "" + key = str(message_id) if message_id else f"{path}:{parsed.get('uuid') or parsed.get('requestId') or line_number}" + + input_tokens = usage_token(usage, "input_tokens", "inputTokens") + output_tokens = usage_token(usage, "output_tokens", "outputTokens") + cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens") + cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens") + if input_tokens + output_tokens + cache_read + cache_write <= 0: + continue + + held = positions.get(key) + if held is not None and output_tokens < records[held][4]: + continue + + timestamp = parsed.get("timestamp") or message.get("timestamp") + record = [ + key, + str(message.get("model") or parsed.get("model") or "claude"), + local_date_from_timestamp(timestamp) if timestamp is not None else None, + input_tokens, + output_tokens, + cache_read, + cache_write, + str(parsed.get("sessionId") or path), + ] + if held is None: + positions[key] = len(records) + records.append(record) + else: + records[held] = record + + return { + "size": size, + "file": [stat.st_dev, stat.st_ino], + "mtime": mtime, + "head": read_digest(path, offset), + "offset": offset, + "lines": line_number, + "records": records, + } + + +def scan_projects(projects_path: Path, index_file: Path) -> dict[str, Any]: + previous = load_index(index_file) + current: dict[str, Any] = {} + order: list[str] = [] + unreadable_count = 0 + + files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else [] + for path in files: + key = str(path) + try: + current[key] = parse_usage_records(path, previous.get(key)) + except Exception: + unreadable_count += 1 + if key in previous: + current[key] = previous[key] + if key in current: + order.append(key) + + if unreadable_count: + print(f"Ignoring {unreadable_count} unreadable Claude project files", file=sys.stderr) + + if current != previous: + try: + write_json(index_file, {"version": INDEX_VERSION, "zone": index_zone(), "files": current}) + except Exception as error: + print(f"Could not write Claude scan index {index_file}: {error}", file=sys.stderr) + + return summarize_records(current, order) + + +def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, Any]: + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + + seen: dict[str, list[Any]] = {} + sessions: set[str] = set() + active_days: set[str] = set() + today_sessions: set[str] = set() + today_tokens: dict[str, int] = {} + usage_by_model: dict[str, dict[str, int]] = {} + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + + for key in order: + for record in indexed[key].get("records") or []: + held = seen.get(record[0]) + if held is not None and record[4] < held[4]: + continue + seen[record[0]] = record + + for _, model, day, input_tokens, output_tokens, cache_read, cache_write, session_key in seen.values(): + if day is None: + day = today + total = input_tokens + output_tokens + cache_read + cache_write + + sessions.add(session_key) + active_days.add(day) + prompts += 1 + + bucket = usage_by_model.setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + recent[day]["messageCount"] += total + + if day == today: + today_prompt_count += 1 + today_sessions.add(session_key) + today_token_total += total + today_tokens[model] = today_tokens.get(model, 0) + total + + return { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "modelUsage": usage_by_model, + "totalPrompts": prompts, + "totalSessions": len(sessions), + "activeDays": len(active_days), + } + + +def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool) -> dict[str, Any]: + digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16] + root = cache_root() + cache_file = root / f"claude-scan-{digest}.json" + lock_file = root / f"claude-scan-{digest}.lock" + index_file = root / f"claude-index-{digest}.json" + + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + if not use_index: + index_file.unlink(missing_ok=True) + summary = scan_projects(projects_path, index_file) + write_json(cache_file, summary) + return summary + + +def stats_cache_fallback(claude_dir: Path) -> dict[str, Any] | None: + data = read_json(claude_dir / "stats-cache.json") + if data is None: + return None + + today = local_date_string() + today_tokens = {} + for entry in data.get("dailyModelTokens") or []: + if isinstance(entry, dict) and entry.get("date") == today: + today_tokens = entry.get("tokensByModel") or {} + break + + daily_activity = [day for day in (data.get("dailyActivity") or []) if isinstance(day, dict)] + active_dates = {str(day.get("date")) for day in daily_activity if number(day.get("messageCount")) > 0 and day.get("date")} + today_prompts, today_sessions = today_prompts_from_history(claude_dir) + + return { + "todayPrompts": today_prompts, + "todaySessions": today_sessions, + "todayTotalTokens": sum(number(value) for value in today_tokens.values()), + "todayTokensByModel": today_tokens, + "recentDays": daily_activity[-7:], + "modelUsage": data.get("modelUsage") or {}, + "totalPrompts": number(data.get("totalMessages")), + "totalSessions": number(data.get("totalSessions")), + "activeDays": len(active_dates), + } + + +def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]: + prompts = 0 + sessions: set[str] = set() + start_of_day = dt.datetime.combine(dt.datetime.now().date(), dt.time.min).timestamp() * 1000 + try: + with (claude_dir / "history.jsonl").open("r", encoding="utf-8", errors="replace") as handle: + lines = handle.readlines() + except Exception: + return 0, 0 + + for line in reversed(lines): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except Exception: + continue + if number(entry.get("timestamp")) < start_of_day: + break + prompts += 1 + if entry.get("sessionId"): + sessions.add(str(entry.get("sessionId"))) + return prompts, len(sessions) + + +def oauth_login(claude_dir: Path) -> tuple[str, int, int, str]: + data = read_json(claude_dir / ".credentials.json") or {} + login = data.get("claudeAiOauth") + if not isinstance(login, dict): + return "", 0, 0, "" + tier = profile_tier(claude_dir) + if not re.search(r"max_(\d+x)", tier, re.IGNORECASE): + tier = str(login.get("rateLimitTier") or "") + plan = plan_label(tier, str(login.get("subscriptionType") or "")) + return ( + str(login.get("accessToken") or ""), + number(login.get("expiresAt")), + number(login.get("refreshTokenExpiresAt")), + plan, + ) + + +def profile_tier(claude_dir: Path) -> str: + profile = read_json(profile_path(claude_dir)) or {} + account = profile.get("oauthAccount") + if not isinstance(account, dict): + return "" + for key in ("userRateLimitTier", "organizationRateLimitTier"): + tier = str(account.get(key) or "") + if tier: + return tier + return "" + + +def profile_path(home: Path) -> Path: + legacy = home / ".config.json" + if legacy.exists(): + return legacy + configured = os.environ.get("CLAUDE_CONFIG_DIR") + if home == expand_path("~/.claude") and not (configured and expand_path(configured) == home): + return Path.home() / ".claude.json" + return home / ".claude.json" + + +def plan_label(tier: str, subscription: str) -> str: + plan = subscription[0].upper() + subscription[1:] if subscription else "" + match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) if tier else None + if not match: + return plan + return (plan or "Max") + " " + match.group(1) + + +def parse_utilization(value: Any) -> float: + try: + return float(str(value).strip().replace("%", "")) + except Exception: + return float("nan") + + +def normalize_utilization(value: Any, percent_scale: bool) -> float: + n = parse_utilization(value) + if not (n >= 0): + return -1.0 + if percent_scale or n > 1: + return min(1.0, n / 100.0) + return min(1.0, n) + + +def normalize_reset_at(value: Any) -> str: + if value is None: + return "" + raw = str(value).strip() + if raw == "": + return "" + if raw.isdigit(): + timestamp = int(raw) + if timestamp < 1e12: + timestamp *= 1000 + try: + return dt.datetime.fromtimestamp(timestamp / 1000, dt.timezone.utc).isoformat() + except Exception: + return raw + try: + return dt.datetime.fromisoformat(raw.replace("Z", "+00:00")).isoformat() + except Exception: + return raw + + +def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None: + bucket = payload.get(key) + return bucket if isinstance(bucket, dict) else None + + +def scoped_window(kind: str) -> str: + text = kind.lower() + if "month" in text: + return "Monthly" + if "week" in text or "day" in text: + return "Weekly" + if "hour" in text or "session" in text: + return "Session" + return "" + + +def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]: + entries = payload.get("limits") + if not isinstance(entries, list): + return [] + scoped: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for entry in entries: + if not isinstance(entry, dict): + continue + scope = entry.get("scope") + model = scope.get("model") if isinstance(scope, dict) else None + if not isinstance(model, dict): + continue + name = str(model.get("display_name") or model.get("id") or "").strip() + kind = str(entry.get("kind") or "").strip() + if name == "" or (name, kind) in seen: + continue + percent = normalize_utilization(entry.get("percent"), percent_scale) + if percent < 0: + continue + seen.add((name, kind)) + window = scoped_window(kind) + title = name + " " + window if window else name + scoped.append({ + "label": title, + "title": title, + "percent": percent, + "resetsAt": normalize_reset_at(entry.get("resets_at")), + }) + return scoped + + +def probe_limits(access_token: str) -> dict[str, Any]: + request = urllib.request.Request( + USAGE_ENDPOINT, + headers={ + "Authorization": "Bearer " + access_token, + "anthropic-beta": "oauth-2025-04-20", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as error: + retry_after = error.headers.get("retry-after", "") if error.headers else "" + if error.code == 429: + help_text = "Anthropic's usage endpoint is rate limiting checks right now" + ( + f" (retry after {retry_after}s)" if retry_after else "" + ) + "." + else: + help_text = f"Anthropic's usage endpoint returned status {error.code}." + return {"ok": False, "helpText": help_text} + except Exception: + return { + "ok": False, + "transport": True, + "helpText": "Couldn't reach Anthropic's usage endpoint. Retrying shortly.", + } + + weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day") + session = usage_bucket(payload, "five_hour") + raw_values = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None] + entries = payload.get("limits") + if isinstance(entries, list): + raw_values += [entry.get("percent") for entry in entries if isinstance(entry, dict)] + percent_scale = any(parse_utilization(value) >= 1 for value in raw_values) + + limits = [] + if session is not None: + percent = normalize_utilization(session.get("utilization"), percent_scale) + if percent >= 0: + limits.append({"label": "Session (5-hour)", "percent": percent, "resetsAt": normalize_reset_at(session.get("resets_at"))}) + if weekly is not None: + percent = normalize_utilization(weekly.get("utilization"), percent_scale) + if percent >= 0: + limits.append({"label": "Weekly (7-day)", "percent": percent, "resetsAt": normalize_reset_at(weekly.get("resets_at"))}) + limits.extend(scoped_limits(payload, percent_scale)) + + if not limits: + return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits."} + return {"ok": True, "limits": limits} + + +def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: + raw = str(entry.get("resetsAt") or "") + if raw == "": + return True + try: + resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return True + if resets_at.tzinfo is None: + resets_at = resets_at.replace(tzinfo=dt.timezone.utc) + return resets_at > now + + +def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: + entries = cached.get("limits") + if not isinstance(entries, list): + return [] + now = dt.datetime.now(dt.timezone.utc) + return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] + + +def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool) -> dict[str, Any]: + result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0} + + probe_cache = cache_root() / "claude-limits.json" + cached = read_json(probe_cache) or {} + fallback = usable_cached_limits(cached) + result["fetchedAtMs"] = number(cached.get("fetchedAtMs")) + now_ms = time.time() * 1000 + + if access_token == "": + result["limits"] = fallback + result["usageStatusText"] = "Waiting for sign-in" + return result + + if expires_at_ms > 0 and expires_at_ms <= now_ms: + result["limits"] = fallback + shown = " Showing the last known limits." if fallback else "" + if refresh_expires_at_ms > now_ms: + result["usageStatusText"] = "Limits paused" + result["authHelpText"] = "Claude Code's access token has lapsed." + shown + " Start Claude Code to refresh it." + else: + result["usageStatusText"] = "Sign-in expired" + result["authHelpText"] = "Claude Code's saved sign-in expired." + shown + " Run `claude auth login` to sign in again." + return result + + fetched_age = time.time() - number(cached.get("fetchedAtMs")) / 1000 + if fallback and not force and 0 <= fetched_age < PROBE_MIN_INTERVAL_SECONDS: + result["limits"] = fallback + result["live"] = True + result["authHelpText"] = "" + return result + + probe = probe_limits(access_token) + if probe["ok"]: + result["limits"] = probe["limits"] + result["live"] = True + result["authHelpText"] = "" + result["fetchedAtMs"] = round(time.time() * 1000) + write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]}) + return result + + if probe.get("transport"): + result["retryAdvised"] = True + if fallback: + result["limits"] = fallback + fetched_age = time.time() - result["fetchedAtMs"] / 1000 + result["live"] = 0 <= fetched_age < CURRENT_ENOUGH_SECONDS + else: + result["usageStatusText"] = "Limits unavailable" + result["authHelpText"] = probe["helpText"] + return result + + +def local_stats(claude_dir: Path, scan_age: float, force: bool) -> dict[str, Any]: + stats = cached_scan(claude_dir / "projects", scan_age, use_index=not force) + if number(stats.get("totalPrompts")) > 0: + return stats + + fallback = stats_cache_fallback(claude_dir) + if fallback is not None: + return fallback + + today_prompts, today_sessions = today_prompts_from_history(claude_dir) + if today_prompts or today_sessions: + return dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions) + return stats + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true") + parser.add_argument("--limits-only", action="store_true") + parser.add_argument("--cache-seconds", type=float, default=20) + args = parser.parse_args() + + claude_dir = config_dir() + scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds) + stats = local_stats(claude_dir, scan_age, args.force) + + access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(claude_dir) + limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, args.force) + + record = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), + "tierLabel": plan, + "usageStatusText": limits["usageStatusText"], + "authHelpText": limits["authHelpText"], + "limits": limits["limits"], + "limitsStale": not limits["live"], + "limitsFetchedAt": limits["fetchedAtMs"], + } + if limits.get("retryAdvised"): + record["retryAdvised"] = True + record.update(stats) + print(json.dumps(record, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/blob-claude-usage b/bin/blob-claude-usage new file mode 100755 index 0000000..e61aae2 --- /dev/null +++ b/bin/blob-claude-usage @@ -0,0 +1,32 @@ +#!/bin/bash + +# blob:summary=Print Claude Code session and weekly usage limits +# blob:group=claude +# blob:hidden=true + +set -euo pipefail + +credentials_file="$HOME/.claude/.credentials.json" +usage_url="https://api.anthropic.com/api/oauth/usage" + +[[ -r $credentials_file ]] || { echo "Sign in to Claude Code first" >&2; exit 1; } + +access_token=$(jq -r '.claudeAiOauth.accessToken // empty' "$credentials_file") +[[ -n $access_token ]] || { echo "Sign in to Claude Code first" >&2; exit 1; } + +response=$(printf 'Authorization: Bearer %s\nanthropic-beta: oauth-2025-04-20\n' "$access_token" | + curl -fsS --max-time 10 -H @- "$usage_url" 2>/dev/null) || { + echo "Could not load usage, open Claude Code to refresh sign-in" >&2 + exit 1 +} + +jq -r ' + def limit_rows(name; limit): + if limit then + "\(name)_percent\t\((limit.utilization // 0) | floor)", + "\(name)_resets\t\(limit.resets_at // "")" + else empty end; + limit_rows("session"; .five_hour), + limit_rows("weekly"; .seven_day), + limit_rows("weekly_opus"; .seven_day_opus) +' <<<"$response" diff --git a/config/blob/shell.json b/config/blob/shell.json index f066c33..27420dd 100644 --- a/config/blob/shell.json +++ b/config/blob/shell.json @@ -26,6 +26,7 @@ ], "right": [ { "id": "blob.tray" }, + { "id": "blob.agents" }, { "id": "blob.bluetooth" }, { "id": "blob.network" }, { "id": "blob.audio" }, diff --git a/docs/install.md b/docs/install.md index 0923b05..a49eb53 100644 --- a/docs/install.md +++ b/docs/install.md @@ -16,6 +16,7 @@ with `--check`. | `install/steps/directories.sh` | the directories the shell and scripts expect | | `install/steps/packages.sh` | repo and AUR packages, and the yay bootstrap | | `install/steps/config.sh` | the `BLOB_PATH` symlink, everything under `~/.config`, and the login shell profile | +| `install/steps/shell.sh` | the executable bit on `bin/` scripts, and new bar widgets added to an existing `shell.json` | | `install/steps/services.sh` | system services, systemd drop-ins, user units | | `install/steps/boot.sh` | the Plymouth boot splash, GRUB, and other-OS detection | | `install/steps/theme.sh` | the wallpaper link, the first theme, the first background | @@ -42,7 +43,8 @@ with `--check`. 2. **`BLOB_PATH`** is symlinked to the checkout at `~/.local/share/blob`. Nothing under `bin/`, `shell/`, `themes/` or `default/` is ever copied. `ln` does not create the parent of a link, so this step makes `~/.local/share` itself rather - than assuming step 1 ran. + than assuming step 1 ran. Any script in `bin/` that lost its executable bit + gets it back. 3. **Packages** come from `packages/blob.packages` and `packages/blob-aur.packages`. Repo packages go in with a full `pacman -Syu`, never a partial upgrade. A machine with no AUR helper gets @@ -59,6 +61,11 @@ with `--check`. panels come up full of blanks. `fonts.conf` binds `monospace` to `JetBrainsMono Nerd Font`; `blob font set` rewrites the same file, and the installer then reports it as a local change and keeps your choice. + A `shell.json` you already have is kept, but widgets listed in + `bar_widgets` in `install/steps/shell.sh` are added to it when missing, each + after the widget it names (or at the start of the right section), with a + `.bak` of the previous file. That is how `blob.agents` reaches an existing + bar. Launcher entries in `applications/desktop/` go to `~/.local/share/applications` and their icons in `applications/icons/` to `~/.local/share/icons/hicolor/32x32/apps`; that is how the Draw web app is installed. diff --git a/docs/shell.md b/docs/shell.md index 31d81d8..0d64257 100644 --- a/docs/shell.md +++ b/docs/shell.md @@ -17,6 +17,7 @@ shell/ menu/ both menus notifications/ daemon, popups, DND, history notification-center/ + agents/ Claude Code usage limits and token stats lock/ polkit/ osd/ background/ clipboard/ emojis/ image-picker/ reminders/ panels/ audio bluetooth clock disk-speedtest monitor network @@ -49,7 +50,7 @@ entirely rather than merging. | --- | --- | | left | `blob.menu`, `blob.workspaces` | | center | `blob.clock` (anchored), `blob.system-update` | -| right | `blob.tray`, `blob.bluetooth`, `blob.network`, `blob.audio`, `blob.monitor`, `blob.power`, `blob.notifications` | +| right | `blob.tray`, `blob.agents`, `blob.bluetooth`, `blob.network`, `blob.audio`, `blob.monitor`, `blob.power`, `blob.notifications` | `blob.clock` and `blob.notifications` are `type: "command"` modules, which is how they carry their own click actions: the clock opens quick settings on left click, @@ -61,6 +62,33 @@ notifications are silenced. The centre module named by `centerAnchor` cannot be dragged out of the centre. Every other widget still reorders. +## Agents + +`blob.agents` is Omarchy's agents panel (quattro branch), trimmed to Claude +Code. The panel shows the plan, a line per limit window (session, weekly, and +any model-scoped weekly allowance as a tick on the weekly meter) with the time +until it resets, and a rotating summary of local token stats: tokens this week +and today, the most used model, the busiest day, and today's prompts and +sessions. The exact percentage is on hover. + +| Input | Action | +| --- | --- | +| left click | open the panel | +| right click, Enter, the hero button | start Claude Code in a terminal | +| middle click, `r` | refresh | +| Tab | move to the neighboring bar panel | + +The icon turns urgent when a window reaches 90%. Records refresh every +`refreshIntervalSec` (900 by default) and the limits again whenever the panel +opens. + +`blob-agent-usage-claude` builds the record. Limits come from Anthropic's OAuth +usage endpoint with the token in `~/.claude/.credentials.json`; token stats +come from the transcripts in `~/.claude/projects`, with `stats-cache.json` and +`history.jsonl` as fallbacks. Scans and probes are cached in +`~/.cache/blob/agent-usage`. When the access token has lapsed, starting Claude +Code refreshes it. + ## Calendar The clock's calendar popup and the Quick Settings card share one component, @@ -103,8 +131,8 @@ so it cannot paint over the shell. This shell was forked from Omarchy 4.0.4 and is not tracked against it. The changes beyond renaming: -- `agents`, `tailscale`, `dropbox`, and `dev-gallery` plugins removed, about - 7,000 lines. +- `tailscale`, `dropbox`, and `dev-gallery` plugins removed. `agents` was + removed too, then ported back from the quattro branch with only Claude Code. - The `Dictation` bar indicator removed with voice typing. - Persistent workspaces 1-9 instead of 1-5. - Menu cards 440 wide instead of 300, with the Blob icon on the bar button. diff --git a/install.sh b/install.sh index 56d6314..40b0734 100755 --- a/install.sh +++ b/install.sh @@ -49,6 +49,7 @@ source "$repo_dir/install/steps/preflight.sh" source "$repo_dir/install/steps/directories.sh" source "$repo_dir/install/steps/packages.sh" source "$repo_dir/install/steps/config.sh" +source "$repo_dir/install/steps/shell.sh" source "$repo_dir/install/steps/services.sh" source "$repo_dir/install/steps/boot.sh" source "$repo_dir/install/steps/theme.sh" @@ -68,12 +69,14 @@ run_preflight section "Directories" create_directories link_blob_path +make_scripts_executable section "Packages" install_packages section "Config" install_shipped_config +install_bar_widgets remove_omarchy_font refresh_font_cache install_shell_profile diff --git a/install/steps/shell.sh b/install/steps/shell.sh new file mode 100644 index 0000000..cb5c25e --- /dev/null +++ b/install/steps/shell.sh @@ -0,0 +1,86 @@ +bar_widgets=( + "blob.agents:blob.tray" +) + +make_scripts_executable() { + local not_executable=() + local script + + while IFS= read -r script; do + not_executable+=("$script") + done < <(find "$repo_dir/bin" -maxdepth 1 -type f ! -perm -u+x | sort) + + if (( ${#not_executable[@]} == 0 )); then + say "ok bin/ scripts executable" + return 0 + fi + + note_change + if [[ $check == true ]]; then + say "would make ${#not_executable[@]} script(s) in bin/ executable" + return 0 + fi + + chmod +x "${not_executable[@]}" + say "chmod ${#not_executable[@]} script(s) in bin/" +} + +bar_has_widget() { + local shell_config="$1" widget_id="$2" + jq -e --arg id "$widget_id" '[.bar.layout[][]? | .id?] | index($id) != null' "$shell_config" >/dev/null 2>&1 +} + +add_bar_widget() { + local shell_config="$1" widget_id="$2" after_id="$3" + local updated_config + + updated_config="$(mktemp)" + jq --arg id "$widget_id" --arg after "$after_id" ' + if ([.bar.layout[][]? | .id?] | index($after)) != null then + .bar.layout |= map_values([.[] | if .id? == $after then ., {id: $id} else . end]) + else + .bar.layout.right = [{id: $id}] + (.bar.layout.right // []) + end + ' "$shell_config" >"$updated_config" || { + rm -f "$updated_config" + warn "could not add $widget_id to shell.json" + return 0 + } + + cp "$shell_config" "$shell_config.bak" + mv "$updated_config" "$shell_config" + say "add bar widget $widget_id" +} + +install_bar_widgets() { + local shell_config="$config_dir/blob/shell.json" + local entry widget_id after_id + + if [[ ! -f $shell_config ]]; then + say "SKIP bar widgets (no shell.json yet)" + return 0 + fi + + if ! command -v jq &>/dev/null; then + say "SKIP bar widgets (jq not installed)" + return 0 + fi + + for entry in "${bar_widgets[@]}"; do + widget_id="${entry%%:*}" + after_id="${entry#*:}" + + if bar_has_widget "$shell_config" "$widget_id"; then + say "ok bar widget $widget_id" + continue + fi + + note_change + if [[ $check == true ]]; then + say "would add bar widget $widget_id after $after_id" + continue + fi + + add_bar_widget "$shell_config" "$widget_id" "$after_id" + done +} diff --git a/packages/blob.packages b/packages/blob.packages index 021c363..bfa12e0 100644 --- a/packages/blob.packages +++ b/packages/blob.packages @@ -197,6 +197,7 @@ unzip yt-dlp gum less +python python-gobject # Containers diff --git a/shell.json b/shell.json index 6c875ff..f233bf9 100644 --- a/shell.json +++ b/shell.json @@ -26,6 +26,7 @@ ], "right": [ { "id": "blob.tray" }, + { "id": "blob.agents" }, { "id": "blob.bluetooth" }, { "id": "blob.network" }, { "id": "blob.audio" }, diff --git a/shell/plugins/agents/Main.qml b/shell/plugins/agents/Main.qml new file mode 100644 index 0000000..9c2d925 --- /dev/null +++ b/shell/plugins/agents/Main.qml @@ -0,0 +1,88 @@ +import QtQuick +import Quickshell.Io + +Item { + id: root + visible: false + + property var settings: ({}) + property var record: null + property int dataRevision: 0 + property string pendingUpdateKind: "" + + readonly property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)) || 900) + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function refresh() { runUpdate("force") } + function refreshLimits() { runUpdate("limits") } + + function updateCommand(kind) { + var command = ["blob-agent-usage-claude"] + if (kind === "force") command.push("--force") + if (kind === "limits") command.push("--limits-only") + return command + } + + function runUpdate(kind) { + if (collectorProcess.running) { + if (kind === "force" || pendingUpdateKind === "") pendingUpdateKind = kind + return + } + collectorProcess.command = updateCommand(kind) + collectorProcess.running = true + } + + function applyRecord(text) { + try { + var parsed = JSON.parse(String(text || "")) + if (!parsed || typeof parsed !== "object") return + record = parsed + dataRevision++ + if (parsed.retryAdvised === true) limitsRetry.restart() + else limitsRetry.stop() + } catch (error) { + console.warn("agents", "Ignoring bad usage record", error) + } + } + + Process { + id: collectorProcess + running: false + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyRecord(text) + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (text.trim() !== "") console.warn("agents", text.trim()) + } + + onExited: { + if (root.pendingUpdateKind === "") return + var kind = root.pendingUpdateKind + root.pendingUpdateKind = "" + root.runUpdate(kind) + } + } + + Timer { + interval: root.refreshIntervalSec * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.runUpdate("normal") + } + + Timer { + id: limitsRetry + interval: 30000 + repeat: false + onTriggered: root.runUpdate("limits") + } +} diff --git a/shell/plugins/agents/Model.js b/shell/plugins/agents/Model.js new file mode 100644 index 0000000..8358449 --- /dev/null +++ b/shell/plugins/agents/Model.js @@ -0,0 +1,233 @@ +function numberValue(value) { + var n = Number(value || 0) + return isFinite(n) ? Math.round(n) : 0 +} + +function clamp(value, low, high) { + return Math.max(low, Math.min(high, value)) +} + +function formatTokenCount(count) { + if (count === undefined || count === null) return "0" + if (count >= 1e9) return (count / 1e9).toFixed(1) + "B" + if (count >= 1e6) return (count / 1e6).toFixed(1) + "M" + if (count >= 1e3) return (count / 1e3).toFixed(1) + "K" + return String(count) +} + +function modelWordCase(word) { + if (word === "gpt") return "GPT" + return word.charAt(0).toUpperCase() + word.slice(1) +} + +function friendlyModelName(modelId) { + if (!modelId) return "Unknown" + var name = String(modelId).replace(/^claude-/, "").replace(/-\d{8}$/, "") + var parts = name.split("-") + var words = [] + var version = [] + for (var i = 0; i < parts.length; i++) { + var part = parts[i] + if (part === "") continue + if (/^\d/.test(part)) { + version.push(part) + continue + } + if (version.length > 0) { + words.push(version.join(".")) + version = [] + } + words.push(modelWordCase(part)) + } + if (version.length > 0) words.push(version.join(".")) + return words.length > 0 ? words.join(" ") : "Unknown" +} + +function dayName(date) { + var parsed = new Date(String(date || "") + "T00:00:00") + if (isNaN(parsed.getTime())) return String(date || "") + return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][parsed.getDay()] +} + +function formatDuration(milliseconds) { + if (!(milliseconds > 0)) return "now" + var minutes = Math.floor(milliseconds / 60000) + var hours = Math.floor(minutes / 60) + var days = Math.floor(hours / 24) + if (days > 0) return days + "d " + (hours % 24) + "h" + if (hours > 0) return hours + "h " + (minutes % 60) + "m" + return Math.max(1, minutes) + "m" +} + +function planLabel(record) { + var tier = String(record && record.tierLabel || "") + return tier === "" ? "" : tier.charAt(0).toUpperCase() + tier.slice(1) +} + +function needsSignIn(record) { + var status = String(record && record.usageStatusText || "") + return status === "Sign-in expired" || status === "Waiting for sign-in" +} + +function troubleText(record) { + if (!record || String(record.usageStatusText || "") === "") return "" + return String(record.authHelpText || record.usageStatusText) +} + +function windowIsLong(text) { + return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("month") >= 0 +} + +function windowTitle(label) { + var text = String(label || "").toLowerCase() + if (text.indexOf("month") >= 0) return "Monthly" + if (windowIsLong(text)) return "Weekly" + if (text.indexOf("session") >= 0 || text.indexOf("hour") >= 0) return "Session" + var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim() + return plain === "" ? "Limit" : plain +} + +function limitWindows(record) { + var windows = [] + var limits = record && record.limits ? record.limits : [] + for (var i = 0; i < limits.length; i++) { + var entry = limits[i] || {} + var percent = Number(entry.percent) + if (!(percent >= 0)) continue + windows.push({ + title: String(entry.title || "") !== "" ? String(entry.title) : windowTitle(entry.label), + percent: percent, + resetAt: String(entry.resetsAt || "") + }) + } + return windows +} + +function scopedPart(title) { + var match = String(title || "").match(/^(.+) (Session|Weekly|Monthly)$/) + return match ? { model: match[1], window: match[2] } : null +} + +function displayWindows(record) { + var windows = limitWindows(record) + var shown = [] + var byTitle = {} + + for (var i = 0; i < windows.length; i++) { + if (scopedPart(windows[i].title)) continue + windows[i].scoped = [] + shown.push(windows[i]) + byTitle[windows[i].title] = windows[i] + } + + for (var j = 0; j < windows.length; j++) { + var part = scopedPart(windows[j].title) + if (!part) continue + var base = byTitle[part.window] + if (base) { + base.scoped.push({ title: part.model, percent: windows[j].percent, resetAt: windows[j].resetAt }) + } else { + windows[j].scoped = [] + shown.push(windows[j]) + } + } + return shown +} + +function fullestWindow(record) { + var windows = limitWindows(record) + var fullest = null + for (var i = 0; i < windows.length; i++) { + if (!fullest || windows[i].percent > fullest.percent) fullest = windows[i] + } + return fullest +} + +function resetMillisecondsFor(window, nowMs) { + if (!window || window.resetAt === "") return -1 + var resetMs = new Date(window.resetAt).getTime() + return isFinite(resetMs) ? resetMs - nowMs : -1 +} + +function limitTooltip(window, stale, fetchedAt, nowMs) { + if (!window) return "" + var lines = [] + var resetMs = resetMillisecondsFor(window, nowMs) + lines.push(window.title + ": " + Math.round(window.percent * 100) + "% used" + + (resetMs > 0 ? " · resets in " + formatDuration(resetMs) : "")) + if (stale) { + lines.push(fetchedAt > 0 && nowMs - fetchedAt > 60000 + ? "As of " + formatDuration(nowMs - fetchedAt) + " ago" + : "Last known") + } + var scoped = window.scoped || [] + for (var i = 0; i < scoped.length; i++) { + lines.push(scoped[i].title + ": " + Math.round(scoped[i].percent * 100) + "% of its " + + window.title.toLowerCase() + " allowance") + } + return lines.join("\n") +} + +function summaryPhrases(record) { + if (!record) return [] + + var weekTokens = 0 + var tokensByDay = {} + var recentDays = record.recentDays || [] + for (var d = 0; d < recentDays.length; d++) { + var dayTokens = Number(recentDays[d].messageCount || 0) + weekTokens += dayTokens + tokensByDay[recentDays[d].date] = (tokensByDay[recentDays[d].date] || 0) + dayTokens + } + + var tokensByModel = {} + var modelUsage = record.modelUsage || {} + for (var modelId in modelUsage) { + var bucket = modelUsage[modelId] || {} + var total = Number(bucket.inputTokens || 0) + Number(bucket.outputTokens || 0) + + Number(bucket.cacheReadInputTokens || 0) + Number(bucket.cacheCreationInputTokens || 0) + var name = friendlyModelName(modelId) + tokensByModel[name] = (tokensByModel[name] || 0) + total + } + + var todayTokens = numberValue(record.todayTotalTokens) + var todayPrompts = numberValue(record.todayPrompts) + var todaySessions = numberValue(record.todaySessions) + + var phrases = [] + if (weekTokens > 0) phrases.push(formatTokenCount(weekTokens) + " tokens this week") + if (todayTokens > 0) phrases.push(formatTokenCount(todayTokens) + " tokens today") + + var topModel = "" + for (var model in tokensByModel) if (topModel === "" || tokensByModel[model] > tokensByModel[topModel]) topModel = model + if (topModel !== "" && tokensByModel[topModel] > 0) phrases.push("Mostly " + topModel) + + var busiestDay = "" + for (var date in tokensByDay) if (busiestDay === "" || tokensByDay[date] > tokensByDay[busiestDay]) busiestDay = date + if (busiestDay !== "" && tokensByDay[busiestDay] > 0) phrases.push("Busiest day: " + dayName(busiestDay)) + + if (todayPrompts > 0) phrases.push(todayPrompts + " prompt" + (todayPrompts === 1 ? "" : "s") + " today") + if (todaySessions > 0) phrases.push(todaySessions + " session" + (todaySessions === 1 ? "" : "s") + " today") + return phrases +} + +if (typeof module !== "undefined") { + module.exports = { + numberValue: numberValue, + clamp: clamp, + formatTokenCount: formatTokenCount, + friendlyModelName: friendlyModelName, + dayName: dayName, + formatDuration: formatDuration, + planLabel: planLabel, + needsSignIn: needsSignIn, + troubleText: troubleText, + windowTitle: windowTitle, + limitWindows: limitWindows, + displayWindows: displayWindows, + fullestWindow: fullestWindow, + resetMillisecondsFor: resetMillisecondsFor, + limitTooltip: limitTooltip, + summaryPhrases: summaryPhrases + } +} diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml new file mode 100644 index 0000000..04df65f --- /dev/null +++ b/shell/plugins/agents/Panel.qml @@ -0,0 +1,385 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Model.js" as Model + +Panel { + id: root + moduleName: "blob.agents" + ipcTarget: "blob.agents" + manageIpc: false + + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color urgent: Color.urgent + readonly property color dim: Qt.darker(foreground, 1.55) + readonly property color track: Style.selectedFillFor(foreground, Color.accent) + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + readonly property string barGlyph: "󱚣" + readonly property string launchCommand: "blob-launch-terminal claude" + + readonly property var record: usage.record + readonly property var windows: { + var revision = usage.dataRevision + return Model.displayWindows(record) + } + readonly property var phrases: { + var revision = usage.dataRevision + return Model.summaryPhrases(record) + } + readonly property bool alarming: { + var fullest = Model.fullestWindow(record) + return !!fullest && fullest.percent >= 0.9 + } + + property double nowMs: Date.now() + property int phraseIndex: 0 + property string shownPhrase: "" + + function showPhrase() { + shownPhrase = phrases.length > 0 ? phrases[phraseIndex % phrases.length] : "" + } + + function launchAgent() { + if (root.bar) root.bar.run(root.launchCommand) + root.close() + } + + onPhrasesChanged: if (shownPhrase === "" || phrases.length <= 1) showPhrase() + + onOpenedChanged: if (opened) { + nowMs = Date.now() + usage.refreshLimits() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Main { + id: usage + settings: root.settings + } + + Timer { + interval: 30000 + running: root.opened + repeat: true + onTriggered: root.nowMs = Date.now() + } + + Timer { + interval: 2800 + running: root.opened && root.phrases.length > 1 + repeat: true + onTriggered: phraseSwap.restart() + } + + SequentialAnimation { + id: phraseSwap + + PropertyAnimation { + target: hero + property: "metaOpacity" + to: 0.0 + duration: 180 + easing.type: Easing.OutQuad + } + + ScriptAction { + script: { + root.phraseIndex = (root.phraseIndex + 1) % Math.max(1, root.phrases.length) + root.showPhrase() + } + } + + PropertyAnimation { + target: hero + property: "metaOpacity" + to: 1.0 + duration: 260 + easing.type: Easing.InQuad + } + } + + IpcHandler { + target: root.ipcTarget + + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.toggle() } + function refresh(): void { usage.refresh() } + } + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.barGlyph + active: root.alarming + + onPressed: function(mouseButton) { + if (mouseButton === Qt.RightButton) root.launchAgent() + else if (mouseButton === Qt.MiddleButton) usage.refresh() + else root.toggle() + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(380)) + contentHeight: panel.fittedContentHeight(column.implicitHeight) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onActivateRequested: root.launchAgent() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(key) { if (key === "r" || key === "R") usage.refresh() } + + Column { + id: column + width: parent.width + spacing: Style.space(16) + + PanelHero { + id: hero + width: parent.width + title: "Claude Code" + meta: root.shownPhrase !== "" ? root.shownPhrase : "Usage" + detail: Model.planLabel(root.record) + foreground: root.foreground + fontFamily: root.fontFamily + + iconComponent: Component { + ClaudeMark { + size: Style.font.display + } + } + + trailingControl: Component { + HeroButton { + glyph: "󰞷" + tooltip: "Start Claude Code" + onClicked: root.launchAgent() + } + } + } + + PanelSeparator { + foreground: root.foreground + } + + Text { + visible: text !== "" + width: parent.width + textFormat: Text.PlainText + text: Model.troubleText(root.record) + color: root.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + + Column { + visible: root.windows.length > 0 + width: parent.width + spacing: Style.space(12) + + Repeater { + model: root.windows + + CompactLimit { + required property var modelData + width: column.width + window: modelData + stale: !!root.record && root.record.limitsStale === true + fetchedAt: root.record ? Number(root.record.limitsFetchedAt || 0) : 0 + } + } + } + + Text { + visible: !root.record + width: parent.width + text: "Loading usage..." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + } + } + + component ClaudeMark: Item { + id: mark + property real size: Style.font.heading + width: size + height: size + + Image { + id: markImage + anchors.fill: parent + source: Qt.resolvedUrl("assets/claude.svg") + sourceSize.width: mark.size * 2 + sourceSize.height: mark.size * 2 + fillMode: Image.PreserveAspectFit + } + + Text { + anchors.centerIn: parent + visible: markImage.status !== Image.Ready + textFormat: Text.PlainText + text: root.barGlyph + color: root.foreground + font.family: root.fontFamily + font.pixelSize: mark.size + } + } + + component HeroButton: Rectangle { + id: heroButton + signal clicked() + property string glyph: "" + property string tooltip: "" + implicitWidth: Style.space(34) + implicitHeight: implicitWidth + radius: Style.cornerRadius + color: Util.alpha(Color.accent, heroMouse.containsMouse ? 0.3 : 0.12) + border.width: heroMouse.containsMouse ? Math.max(1, Style.hoverBorderWidth) : 0 + border.color: Color.accent + + Text { + anchors.centerIn: parent + textFormat: Text.PlainText + text: heroButton.glyph + color: Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.heading + } + + MouseArea { + id: heroMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: heroButton.clicked() + } + + PanelToolTip { + visible: heroMouse.containsMouse + text: heroButton.tooltip + } + } + + component CompactLimit: Item { + id: compact + property var window: null + property bool stale: false + property real fetchedAt: 0 + readonly property real resetMs: Model.resetMillisecondsFor(window, root.nowMs) + opacity: stale ? 0.5 : 1.0 + implicitHeight: Math.max(compactTitle.implicitHeight, compactValue.implicitHeight) + + HoverHandler { id: compactHover } + + PanelToolTip { + visible: compactHover.hovered && !!compact.window + text: Model.limitTooltip(compact.window, compact.stale, compact.fetchedAt, root.nowMs) + } + + Text { + id: compactTitle + textFormat: Text.PlainText + width: parent.width * 0.3 + anchors.verticalCenter: parent.verticalCenter + text: compact.window ? compact.window.title : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + Meter { + anchors.left: compactTitle.right + anchors.right: compactValue.left + anchors.rightMargin: Style.space(20) + anchors.verticalCenter: parent.verticalCenter + value: compact.window ? compact.window.percent : -1 + alarming: !!compact.window && compact.window.percent >= 0.9 + markers: compact.window && compact.window.scoped ? compact.window.scoped : [] + } + + TextMetrics { + id: compactValueMetrics + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + text: "00h 00m" + } + + Text { + id: compactValue + width: Math.ceil(compactValueMetrics.advanceWidth) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText + horizontalAlignment: Text.AlignRight + text: compact.resetMs > 0 ? Model.formatDuration(compact.resetMs) : "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + + component Meter: Item { + id: meter + property real value: -1 + property bool alarming: false + property real thickness: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14)) + property var markers: [] + + implicitHeight: thickness + + Rectangle { + id: meterTrack + anchors.fill: parent + radius: height / 2 + color: root.track + } + + Rectangle { + anchors.left: meterTrack.left + anchors.verticalCenter: meterTrack.verticalCenter + height: meterTrack.height + radius: meterTrack.radius + width: meterTrack.width * Model.clamp(meter.value, 0, 1) + color: meter.alarming ? root.urgent : root.foreground + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + + Repeater { + model: meter.markers + + Rectangle { + required property var modelData + width: Math.max(2, Math.round(meter.thickness * 0.5)) + height: meter.thickness * 2.5 + radius: width / 2 + anchors.verticalCenter: meterTrack.verticalCenter + x: Model.clamp(meterTrack.width * Model.clamp(Number(modelData.percent), 0, 1) - width / 2, 0, meterTrack.width - width) + color: meter.alarming ? root.urgent : root.foreground + } + } + } +} diff --git a/shell/plugins/agents/assets/claude.svg b/shell/plugins/agents/assets/claude.svg new file mode 100644 index 0000000..a85b80f --- /dev/null +++ b/shell/plugins/agents/assets/claude.svg @@ -0,0 +1 @@ + diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json new file mode 100644 index 0000000..880b0dc --- /dev/null +++ b/shell/plugins/agents/manifest.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "blob.agents", + "name": "Agents", + "version": "1.0.0", + "author": "Blob", + "description": "Claude Code usage limits and token stats", + "kinds": [ + "bar-widget" + ], + "entryPoints": { + "barWidget": "Panel.qml" + }, + "barWidget": { + "displayName": "Agents", + "description": "Claude Code session and weekly limits, with token stats from local transcripts", + "category": "AI", + "allowMultiple": false, + "defaults": { + "refreshIntervalSec": 900 + }, + "schema": [ + { "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 30, "max": 3600, "step": 30, "defaultValue": 900 } + ] + } +} diff --git a/shell/plugins/panels/claude-usage/BarWidget.qml b/shell/plugins/panels/claude-usage/BarWidget.qml new file mode 100644 index 0000000..db59010 --- /dev/null +++ b/shell/plugins/panels/claude-usage/BarWidget.qml @@ -0,0 +1,71 @@ +import QtQuick +import qs.Commons +import qs.Ui + +BarWidget { + id: root + moduleName: "blob.claude-usage" + + readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false + readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false + + function injectPanel() { + var panel = panelLoader.item + if (!panel) return + panel.bar = root.bar + panel.settings = root.settings + panel.anchorItem = button + panel.hostWidget = root + } + + function open() { + if (panelLoader.item) panelLoader.item.open() + } + + function close() { + if (panelLoader.item) panelLoader.item.close() + } + + function togglePanel() { + if (panelLoader.item) panelLoader.item.toggle() + } + + function refresh() { + if (panelLoader.item) panelLoader.item.refresh() + } + + function closeForPopoutSwitch() { + if (panelLoader.item) panelLoader.item.closeForPopoutSwitch() + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onBarChanged: injectPanel() + onSettingsChanged: injectPanel() + + Loader { + id: panelLoader + active: true + source: Qt.resolvedUrl("Panel.qml") + visible: false + onLoaded: { + root.injectPanel() + Qt.callLater(root.injectPanel) + } + } + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + text: "󰚩" + tooltipText: "" + + onPressed: function(mouseButton) { + if (!root.bar) return + if (mouseButton === Qt.MiddleButton) root.refresh() + else root.togglePanel() + } + } +} diff --git a/shell/plugins/panels/claude-usage/Model.js b/shell/plugins/panels/claude-usage/Model.js new file mode 100644 index 0000000..5a63946 --- /dev/null +++ b/shell/plugins/panels/claude-usage/Model.js @@ -0,0 +1,59 @@ +function parseUsage(raw) { + var values = {} + var lines = String(raw || "").split("\n") + + for (var i = 0; i < lines.length; i++) { + var tabIndex = lines[i].indexOf("\t") + if (tabIndex === -1) continue + values[lines[i].substring(0, tabIndex)] = lines[i].substring(tabIndex + 1).trim() + } + + return { + session: limitFrom(values, "session"), + weekly: limitFrom(values, "weekly"), + weeklyOpus: limitFrom(values, "weekly_opus") + } +} + +function limitFrom(values, name) { + var percentText = values[name + "_percent"] + if (percentText === undefined) return null + + var percent = parseInt(percentText, 10) + return { + percent: isNaN(percent) ? 0 : Math.max(0, Math.min(100, percent)), + resetsAt: values[name + "_resets"] || "" + } +} + +function formatTimeUntil(resetsAt, nowMs) { + if (!resetsAt) return "" + + var resetMs = new Date(resetsAt).getTime() + if (isNaN(resetMs)) return "" + + var totalMinutes = Math.max(0, Math.round((resetMs - nowMs) / 60000)) + var days = Math.floor(totalMinutes / 1440) + var hours = Math.floor((totalMinutes % 1440) / 60) + var minutes = totalMinutes % 60 + + if (days > 0) return days + "d " + hours + "h" + if (hours > 0) return hours + "h " + minutes + "m" + return minutes + "m" +} + +function limitDetail(limit, nowMs) { + if (!limit) return "" + + var timeUntilReset = formatTimeUntil(limit.resetsAt, nowMs) + if (timeUntilReset === "") return limit.percent + "%" + return limit.percent + "% resets in " + timeUntilReset +} + +if (typeof module !== "undefined") { + module.exports = { + parseUsage: parseUsage, + formatTimeUntil: formatTimeUntil, + limitDetail: limitDetail + } +} diff --git a/shell/plugins/panels/claude-usage/Panel.qml b/shell/plugins/panels/claude-usage/Panel.qml new file mode 100644 index 0000000..5f4a776 --- /dev/null +++ b/shell/plugins/panels/claude-usage/Panel.qml @@ -0,0 +1,162 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Model.js" as Model + +Panel { + id: root + moduleName: "blob.claude-usage" + ipcTarget: "blob.claude-usage" + + property var anchorItem: null + property var hostWidget: null + readonly property var barIdentity: hostWidget || root + + property var usage: ({ session: null, weekly: null, weeklyOpus: null }) + property string errorMessage: "" + property bool loading: false + property double nowMs: Date.now() + + readonly property bool hasUsage: usage.session !== null || usage.weekly !== null + readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 5), 10) || 5) + + function open() { + root.controller.show() + root.refresh() + } + + function close() { + root.controller.hide() + } + + function toggle() { + if (root.opened) root.close() + else root.open() + } + + function switchPanel(direction) { + if (root.bar && typeof root.bar.switchPanelFrom === "function") + return root.bar.switchPanelFrom(root.barIdentity, direction) + return false + } + + function refresh() { + root.nowMs = Date.now() + if (usageProcess.running) return + root.loading = true + usageProcess.running = true + } + + Process { + id: usageProcess + command: ["blob-claude-usage"] + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var parsedUsage = Model.parseUsage(text) + if (parsedUsage.session !== null || parsedUsage.weekly !== null) { + root.usage = parsedUsage + root.errorMessage = "" + } + } + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: { + var message = String(text || "").trim() + if (message !== "") root.errorMessage = message + } + } + + onExited: function(exitCode) { + root.loading = false + if (exitCode !== 0 && root.errorMessage === "") root.errorMessage = "Could not load usage" + } + } + + Timer { + interval: root.refreshMinutes * 60 * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + Timer { + interval: 60 * 1000 + running: root.opened + repeat: true + onTriggered: root.nowMs = Date.now() + } + + KeyboardPanel { + id: panel + anchorItem: root.anchorItem + owner: root.barIdentity + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(340)) + contentHeight: panel.fittedContentHeight(usageColumn.implicitHeight) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(key) { if (key === "r") root.refresh() } + + Column { + id: usageColumn + width: parent.width + spacing: Style.space(14) + + PanelSectionHeader { + text: "CLAUDE CODE USAGE" + foreground: root.barForeground + } + + MetricBar { + width: parent.width + visible: root.usage.session !== null + icon: "󰅐" + name: "Session" + percent: root.usage.session ? root.usage.session.percent : 0 + detail: Model.limitDetail(root.usage.session, root.nowMs) + } + + MetricBar { + width: parent.width + visible: root.usage.weekly !== null + icon: "󰃰" + name: "Weekly" + percent: root.usage.weekly ? root.usage.weekly.percent : 0 + detail: Model.limitDetail(root.usage.weekly, root.nowMs) + } + + MetricBar { + width: parent.width + visible: root.usage.weeklyOpus !== null + icon: "󰃰" + name: "Weekly Opus" + percent: root.usage.weeklyOpus ? root.usage.weeklyOpus.percent : 0 + detail: Model.limitDetail(root.usage.weeklyOpus, root.nowMs) + } + + Text { + width: parent.width + visible: root.errorMessage !== "" || (!root.hasUsage && root.loading) + text: root.errorMessage !== "" ? root.errorMessage : "Loading usage..." + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: root.errorMessage !== "" ? Color.urgent : Qt.darker(root.barForeground, 1.5) + font.family: Style.font.family + font.pixelSize: Style.font.bodySmall + } + } + } + } +} diff --git a/shell/plugins/panels/claude-usage/manifest.json b/shell/plugins/panels/claude-usage/manifest.json new file mode 100644 index 0000000..7c236fb --- /dev/null +++ b/shell/plugins/panels/claude-usage/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "blob.claude-usage", + "name": "Claude Usage", + "version": "1.0.0", + "author": "Blob", + "description": "Claude Code session and weekly usage limits", + "kinds": [ + "bar-widget" + ], + "entryPoints": { + "barWidget": "BarWidget.qml" + }, + "barWidget": { + "displayName": "Claude Usage", + "description": "Claude Code session and weekly usage limits", + "category": "Info", + "allowMultiple": false + } +}