Add the agents usage bar widget
This commit is contained in:
1 parent
854ce5fe5a
commit
6c83f7089c
18 files changed
+1936
-4
No files matched your search
Executable
+728
@@ -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())
|
||||||
Executable
+32
@@ -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"
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
],
|
],
|
||||||
"right": [
|
"right": [
|
||||||
{ "id": "blob.tray" },
|
{ "id": "blob.tray" },
|
||||||
|
{ "id": "blob.agents" },
|
||||||
{ "id": "blob.bluetooth" },
|
{ "id": "blob.bluetooth" },
|
||||||
{ "id": "blob.network" },
|
{ "id": "blob.network" },
|
||||||
{ "id": "blob.audio" },
|
{ "id": "blob.audio" },
|
||||||
|
|||||||
+8
-1
@@ -16,6 +16,7 @@ with `--check`.
|
|||||||
| `install/steps/directories.sh` | the directories the shell and scripts expect |
|
| `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/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/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/services.sh` | system services, systemd drop-ins, user units |
|
||||||
| `install/steps/boot.sh` | the Plymouth boot splash, GRUB, and other-OS detection |
|
| `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 |
|
| `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
|
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
|
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
|
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
|
3. **Packages** come from `packages/blob.packages` and
|
||||||
`packages/blob-aur.packages`. Repo packages go in with a full
|
`packages/blob-aur.packages`. Repo packages go in with a full
|
||||||
`pacman -Syu`, never a partial upgrade. A machine with no AUR helper gets
|
`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
|
panels come up full of blanks. `fonts.conf` binds `monospace` to
|
||||||
`JetBrainsMono Nerd Font`; `blob font set` rewrites the same file, and the
|
`JetBrainsMono Nerd Font`; `blob font set` rewrites the same file, and the
|
||||||
installer then reports it as a local change and keeps your choice.
|
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`
|
Launcher entries in `applications/desktop/` go to `~/.local/share/applications`
|
||||||
and their icons in `applications/icons/` to `~/.local/share/icons/hicolor/32x32/apps`;
|
and their icons in `applications/icons/` to `~/.local/share/icons/hicolor/32x32/apps`;
|
||||||
that is how the Draw web app is installed.
|
that is how the Draw web app is installed.
|
||||||
|
|||||||
+31
-3
@@ -17,6 +17,7 @@ shell/
|
|||||||
menu/ both menus
|
menu/ both menus
|
||||||
notifications/ daemon, popups, DND, history
|
notifications/ daemon, popups, DND, history
|
||||||
notification-center/
|
notification-center/
|
||||||
|
agents/ Claude Code usage limits and token stats
|
||||||
lock/ polkit/ osd/ background/ clipboard/ emojis/
|
lock/ polkit/ osd/ background/ clipboard/ emojis/
|
||||||
image-picker/ reminders/
|
image-picker/ reminders/
|
||||||
panels/ audio bluetooth clock disk-speedtest monitor network
|
panels/ audio bluetooth clock disk-speedtest monitor network
|
||||||
@@ -49,7 +50,7 @@ entirely rather than merging.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| left | `blob.menu`, `blob.workspaces` |
|
| left | `blob.menu`, `blob.workspaces` |
|
||||||
| center | `blob.clock` (anchored), `blob.system-update` |
|
| 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
|
`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,
|
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.
|
The centre module named by `centerAnchor` cannot be dragged out of the centre.
|
||||||
Every other widget still reorders.
|
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
|
## Calendar
|
||||||
|
|
||||||
The clock's calendar popup and the Quick Settings card share one component,
|
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
|
This shell was forked from Omarchy 4.0.4 and is not tracked against it. The
|
||||||
changes beyond renaming:
|
changes beyond renaming:
|
||||||
|
|
||||||
- `agents`, `tailscale`, `dropbox`, and `dev-gallery` plugins removed, about
|
- `tailscale`, `dropbox`, and `dev-gallery` plugins removed. `agents` was
|
||||||
7,000 lines.
|
removed too, then ported back from the quattro branch with only Claude Code.
|
||||||
- The `Dictation` bar indicator removed with voice typing.
|
- The `Dictation` bar indicator removed with voice typing.
|
||||||
- Persistent workspaces 1-9 instead of 1-5.
|
- Persistent workspaces 1-9 instead of 1-5.
|
||||||
- Menu cards 440 wide instead of 300, with the Blob icon on the bar button.
|
- Menu cards 440 wide instead of 300, with the Blob icon on the bar button.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ source "$repo_dir/install/steps/preflight.sh"
|
|||||||
source "$repo_dir/install/steps/directories.sh"
|
source "$repo_dir/install/steps/directories.sh"
|
||||||
source "$repo_dir/install/steps/packages.sh"
|
source "$repo_dir/install/steps/packages.sh"
|
||||||
source "$repo_dir/install/steps/config.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/services.sh"
|
||||||
source "$repo_dir/install/steps/boot.sh"
|
source "$repo_dir/install/steps/boot.sh"
|
||||||
source "$repo_dir/install/steps/theme.sh"
|
source "$repo_dir/install/steps/theme.sh"
|
||||||
@@ -68,12 +69,14 @@ run_preflight
|
|||||||
section "Directories"
|
section "Directories"
|
||||||
create_directories
|
create_directories
|
||||||
link_blob_path
|
link_blob_path
|
||||||
|
make_scripts_executable
|
||||||
|
|
||||||
section "Packages"
|
section "Packages"
|
||||||
install_packages
|
install_packages
|
||||||
|
|
||||||
section "Config"
|
section "Config"
|
||||||
install_shipped_config
|
install_shipped_config
|
||||||
|
install_bar_widgets
|
||||||
remove_omarchy_font
|
remove_omarchy_font
|
||||||
refresh_font_cache
|
refresh_font_cache
|
||||||
install_shell_profile
|
install_shell_profile
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -197,6 +197,7 @@ unzip
|
|||||||
yt-dlp
|
yt-dlp
|
||||||
gum
|
gum
|
||||||
less
|
less
|
||||||
|
python
|
||||||
python-gobject
|
python-gobject
|
||||||
|
|
||||||
# Containers
|
# Containers
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
],
|
],
|
||||||
"right": [
|
"right": [
|
||||||
{ "id": "blob.tray" },
|
{ "id": "blob.tray" },
|
||||||
|
{ "id": "blob.agents" },
|
||||||
{ "id": "blob.bluetooth" },
|
{ "id": "blob.bluetooth" },
|
||||||
{ "id": "blob.network" },
|
{ "id": "blob.network" },
|
||||||
{ "id": "blob.audio" },
|
{ "id": "blob.audio" },
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user