All widgets
AI Usage
Comes with Chopstick
How much of your coding agents' plan limits you've used (Claude Code and Codex, whichever you have), with reset times. Pops up as a limit passes 50%, 75% and 90%.
Already installed? It's in your widget library.


Type it in the notch's search
- Refresh AI usage
- Show AI usage
What it runs
A widget is a folder of plain files. Here is all of its code.
widget.json
{
"id": "ai-usage",
"name": "AI Usage",
"icon": "icon.png",
"description": "How much of your coding agents' plan limits you've used (Claude Code and Codex, whichever you have), with reset times. Pops up as a limit passes 50%, 75% and 90%.",
"watch": [
{
"script": "usage.sh",
"every": 60,
"as": "usage"
}
],
"initial": "glance",
"anytime": true,
"states": {
"glance": {
"compact": {
"leading": [
{
"image": "{{usage.top.logo | logos/claude.png}}"
},
{
"ring": {
"value": "{{usage.top.pct | 0}}",
"max": "100",
"tint": "{{usage.top.tint | gray}}"
}
}
]
},
"expanded": {
"size": "compact",
"header": {
"symbol": "gauge.with.dots.needle.50percent",
"tint": "{{usage.top.tint | gray}}",
"title": "AI usage"
},
"blocks": [
{
"blocks_from": "usage.panel"
}
]
},
"keys": [
{
"key": "cmd+r",
"label": "Refresh",
"do": [
{
"refresh": "usage"
}
]
}
]
},
"alert": {
"compact": {
"leading": [
{
"image": "{{usage.top.logo | logos/claude.png}}"
},
{
"ring": {
"value": "{{usage.top.pct | 0}}",
"max": "100",
"tint": "{{usage.top.tint | gray}}"
}
}
]
},
"expanded": {
"size": "compact",
"header": {
"symbol": "gauge.with.dots.needle.50percent",
"tint": "{{usage.top.tint | gray}}",
"title": "{{usage.top.name}} at {{usage.top.pct}}%"
},
"blocks": [
{
"blocks_from": "usage.panel"
}
]
},
"keys": [
{
"key": "cmd+r",
"label": "Refresh",
"do": [
{
"refresh": "usage"
}
]
}
],
"open": true,
"dismiss_after": 10
}
},
"commands": {
"refresh": {
"description": "Fetch the latest usage now.",
"phrase": "Refresh AI usage",
"do": [
{
"refresh": "usage"
}
]
},
"show": {
"description": "Show plan usage for Claude Code and Codex.",
"phrase": "Show AI usage", "preview": { "state": "glance" },
"do": [
{
"go": "glance"
}
]
}
},
"preview": {
"states": [
"glance",
"alert"
],
"data": {
"usage": {
"ok": true,
"top": {
"pct": 82,
"logo": "logos/claude.png",
"tint": "#D97757",
"name": "Claude Code"
},
"panel": {
"data": {},
"blocks": [
{
"text": "Claude Code",
"style": "caption"
},
{
"progress": {
"value": "82",
"max": "100",
"label": "5-hour session",
"tint": "#D97757"
}
},
{
"note": "Resets in 1h 12m (at 13:30)"
},
{
"progress": {
"value": "21",
"max": "100",
"label": "Week",
"tint": "#D97757"
}
},
{
"note": "Resets in 3d 22h (at Fri 01:00)"
},
{
"text": "Codex",
"style": "caption"
},
{
"progress": {
"value": "35",
"max": "100",
"label": "5-hour session",
"tint": "#10A37F"
}
},
{
"note": "Resets in 3h 40m (at 16:00)"
}
]
}
}
}
},
"triggers": [
{
"when": {
"usage.pop": true
},
"open": "alert"
}
]
}
usage.py
# Plan usage for the coding agents you use: Claude Code (the limits its /usage shows, asked with
# its stored login) and Codex (the limits it last reported, from its local session logs). Agents
# you don't have are left out. Prints the fields and a `panel` of bars for the card.
import glob, json, os, subprocess, time, urllib.error, urllib.request
from datetime import datetime, timezone
STORE = os.environ.get("CHOPSTICK_STORAGE", ".")
CACHE = os.path.join(STORE, "claude.json")
FRESH = 300 # seconds a good Claude reply is reused before asking again
BACKOFF = 900 # wait after a 429 when the server gives no Retry-After
def left(t):
"""Time until `t` (a datetime) as "2h 5m" or "3d 4h", and the clock time it happens."""
if t is None:
return "—", "—"
s = max(0, int((t - datetime.now(timezone.utc)).total_seconds()))
d, h, m = s // 86400, s % 86400 // 3600, s % 3600 // 60
return (f"{d}d {h}h" if d else f"{h}h {m}m"), t.astimezone().strftime("%a %H:%M" if d else "%H:%M")
def window_name(minutes):
return {300: "5-hour session", 10080: "Week", 43200: "Month"}.get(minutes, f"{round(minutes / 60)}-hour window")
def claude():
try:
cache = json.load(open(CACHE))
except Exception:
cache = {}
now, error = time.time(), None
if now - cache.get("fetched", 0) > FRESH and now > cache.get("retry_after", 0):
raw = subprocess.run(["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
capture_output=True, text=True, timeout=5).stdout.strip()
if not raw and "data" not in cache:
return None # no Claude Code login here
try:
tok = json.loads(raw)["claudeAiOauth"]["accessToken"]
req = urllib.request.Request("https://api.anthropic.com/api/oauth/usage", headers={
"Authorization": "Bearer " + tok, "anthropic-beta": "oauth-2025-04-20", "User-Agent": "claude-code"})
cache["data"] = json.loads(urllib.request.urlopen(req, timeout=10).read())
cache["fetched"] = now
except urllib.error.HTTPError as e:
if e.code == 429:
wait = e.headers.get("Retry-After")
cache["retry_after"] = now + (int(wait) if wait and wait.isdigit() else BACKOFF)
error = "Rate limited, trying again at " + datetime.fromtimestamp(cache["retry_after"]).strftime("%H:%M")
else:
error = f"Couldn't ask (HTTP {e.code})"
except Exception as e:
error = str(e)[:100]
os.makedirs(STORE, exist_ok=True)
with open(CACHE, "w") as f:
json.dump(cache, f)
d = cache.get("data")
if not d:
return {"name": "Claude Code", "logo": "logos/claude.png", "tint": "#D97757", "limits": [], "error": error or "No usage yet"}
limits = []
for key, name in (("five_hour", "5-hour session"), ("seven_day", "Week")):
part = d.get(key) or {}
if part.get("utilization") is None:
continue
at = datetime.fromisoformat(part["resets_at"]) if part.get("resets_at") else None
# The reset time jitters by a fraction of a second between replies, so the period is named
# by its nearest minute; otherwise every fetch looks like a new period and pops up again.
limits.append({"name": name, "pct": round(part["utilization"]), "resets": left(at),
"window": at and round(at.timestamp() / 60) * 60})
return {"name": "Claude Code", "logo": "logos/claude.png", "tint": "#D97757", "limits": limits,
"error": error and error + " · showing the last numbers"}
def codex():
files = sorted(glob.glob(os.path.expanduser("~/.codex/sessions/*/*/*/*.jsonl")), key=os.path.getmtime, reverse=True)
if not files:
return None
for path in files[:5]:
last = None
with open(path, errors="ignore") as f:
for line in f:
if '"rate_limits"' in line:
last = line
if not last:
continue
found = None
def walk(x):
nonlocal found
if isinstance(x, dict):
if "rate_limits" in x and isinstance(x["rate_limits"], dict):
found = x["rate_limits"]
for v in x.values():
walk(v)
elif isinstance(x, list):
for v in x:
walk(v)
walk(json.loads(last))
if not found:
continue
limits = []
for key in ("primary", "secondary"):
part = found.get(key)
if not part:
continue
at = datetime.fromtimestamp(part["resets_at"], timezone.utc) if part.get("resets_at") else None
limits.append({"name": window_name(part.get("window_minutes") or 0), "pct": round(part.get("used_percent") or 0),
"resets": left(at), "window": part.get("resets_at")})
return {"name": "Codex", "logo": "logos/codex.svg", "tint": "#10A37F", "limits": limits,
"note": "As of " + datetime.fromtimestamp(os.path.getmtime(path)).strftime("%a %H:%M")}
return {"name": "Codex", "logo": "logos/codex.svg", "tint": "#10A37F", "limits": [], "error": "No usage reported yet"}
agents = [a for a in (claude(), codex()) if a]
blocks = []
for a in agents:
blocks.append({"text": a["name"], "style": "caption"})
for lim in a["limits"]:
blocks.append({"progress": {"value": str(lim["pct"]), "max": "100", "label": lim["name"], "tint": a["tint"]}})
blocks.append({"note": f"Resets in {lim['resets'][0]} (at {lim['resets'][1]})"})
for extra in (a.get("error"), a.get("note")):
if extra:
blocks.append({"note": extra})
if not agents:
blocks.append({"note": "No Claude Code or Codex found on this Mac."})
# The fullest limit across agents picks the ring.
top = max(((lim["pct"], a) for a in agents for lim in a["limits"][:1]), default=(0, None), key=lambda x: x[0])
# Pop up once as a limit passes 50, 75 and 90%: `pop` is true for one run per threshold, per limit
# period (remembered, so a restart doesn't announce it again).
NOTIFIED = os.path.join(STORE, "notified.json")
try:
notified = json.load(open(NOTIFIED))
except Exception:
notified = {}
pop = False
for a in agents:
for lim in a["limits"][:1]:
key = f"{a['name']}|{lim.get('window')}"
level = max([t for t in (50, 75, 90) if lim["pct"] >= t], default=0)
if level > notified.get(key, 0):
notified[key], pop = level, True
if pop:
os.makedirs(STORE, exist_ok=True)
with open(NOTIFIED, "w") as f:
json.dump(notified, f)
print(json.dumps({
"ok": bool(agents),
"pop": pop,
"top": {"pct": top[0], "logo": top[1]["logo"] if top[1] else "logos/claude.png",
"tint": top[1]["tint"] if top[1] else "gray", "name": top[1]["name"] if top[1] else ""},
"panel": {"blocks": blocks, "data": {}},
}))
usage.sh
cd "$(dirname "$0")" 2>/dev/null exec /usr/bin/env python3 -I usage.py