Add the agents usage bar widget

This commit is contained in:
SirBlob committed 2026-10-04 02:24:47 -04:00
1 parent 854ce5fe5a
commit 6c83f7089c
18 files changed
+1936 -4

No files matched your search

+728
View File
@@ -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())
+32
View File
@@ -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"
+1
View File
@@ -26,6 +26,7 @@
],
"right": [
{ "id": "blob.tray" },
{ "id": "blob.agents" },
{ "id": "blob.bluetooth" },
{ "id": "blob.network" },
{ "id": "blob.audio" },
+8 -1
View File
@@ -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.
+31 -3
View File
@@ -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.
+3
View File
@@ -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
+86
View File
@@ -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
}
+1
View File
@@ -197,6 +197,7 @@ unzip
yt-dlp
gum
less
python
python-gobject
# Containers
+1
View File
@@ -26,6 +26,7 @@
],
"right": [
{ "id": "blob.tray" },
{ "id": "blob.agents" },
{ "id": "blob.bluetooth" },
{ "id": "blob.network" },
{ "id": "blob.audio" },
+88
View File
@@ -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")
}
}
+233
View File
@@ -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
}
}
+385
View File
@@ -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
}
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+26
View File
@@ -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 }
]
}
}
@@ -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()
}
}
}
@@ -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
}
}
+162
View File
@@ -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
}
}
}
}
}
@@ -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
}
}