statuslin.es

Width-Aware Monitor

python

Keeps itself inside a width budget (80 columns by default) by dropping the lowest-priority segments first. Model, project, context gauge with token counts, quota bars and duration; pace arrows and a cost readout are opt-in.

MIT licensed · original source

Preview

Clean repo
◆ Opus 4.8 │ app/main ▰▰▰▰▱ 78% │ ↑44k ↓1.4k │ 5h: no token │ 7d: no token10m12s
New session
◆ Opus 4.8 │ app/main ▰▰▰▰▰ 100% │ 5h: no token │ 7d: no token10m12s
Dirty branch
◆ Sonnet 4.6 │ app/feat/auth ▰▰▰▱▱ 52% │ ↑96k ↓1.4k │ 5h: no token │ 7d: no token10m12s
Near-full
◆ Opus 4.8 │ app/main ▱▱▱▱▱ 9% │ ↑182k ↓1.4k │ 5h: no token │ 7d: no token10m12s
1M context
◆ Fable 5 │ app/main ▰▰▱▱▱ 36% │ ↑640k ↓1.4k │ 5h: no token │ 7d: no token10m12s
Post-compact
◆ Haiku 4.5 │ app/main ▰▰▰▰▰ 100% │ 5h: no token │ 7d: no token10m12s
Worktree
◆ Opus 4.8 │ app/worktree-feature ▰▰▰▱▱ 63% │ ↑74k ↓1.4k │ 5h: no token │ 7d: no token10m12s
Non-git
◆ Opus 4.8 │ scratch ▰▰▰▰▱ 78% │ ↑44k ↓1.4k │ 5h: no token │ 7d: no token10m12s

Source

#!/usr/bin/env python3
"""
Claude Code statusline with 5h/7d quota tracking.

Shows: model, context gauge, tokens, git branch, 5h remaining%, 7d remaining%,
pace indicator, and reset countdown.

Designed for Claude Code on Windows, macOS, and Linux. Caches API responses to
the system temp directory for 5 minutes.
"""

import json
import os
import re
import subprocess
import sys
import tempfile
import time
import threading
from pathlib import Path

# Force UTF-8 output on Windows
if sys.stdout.encoding != "utf-8":
    sys.stdout.reconfigure(encoding="utf-8")

# ── Configuration (env vars) ─────────────────────────────────────
# Set these in your shell profile or in Claude Code's settings.json env block.
# Values: "1" = show, "0" = hide
SHOW_CONTEXT_SIZE = os.environ.get("CQB_CONTEXT_SIZE", "0") == "1"
SHOW_TOKENS = os.environ.get("CQB_TOKENS", "1") == "1"
SHOW_PACE = os.environ.get("CQB_PACE", "0") == "1"
SHOW_RESET = os.environ.get("CQB_RESET", "1") == "1"
SHOW_DURATION = os.environ.get("CQB_DURATION", "1") == "1"
SHOW_BRANCH = os.environ.get("CQB_BRANCH", "1") == "1"
SHOW_COST = os.environ.get("CQB_COST", "0") == "1"
SHOW_REMAINING = os.environ.get("CQB_REMAINING", "1") == "1"
SHOW_BAR = os.environ.get("CQB_BAR", "1") == "1"
MAX_WIDTH = int(os.environ.get("CQB_MAX_WIDTH", "80"))

# ── Read stdin ──────────────────────────────────────────────────
raw = sys.stdin.read().strip()
if not raw:
    print("Claude")
    sys.exit(0)

try:
    d = json.loads(raw)
except json.JSONDecodeError:
    print("Claude")
    sys.exit(0)

# ── ANSI colors ─────────────────────────────────────────────────
C = "\033[36m"   # cyan
G = "\033[32m"   # green
Y = "\033[33m"   # yellow
R = "\033[31m"   # red
D = "\033[2m"    # dim
N = "\033[0m"    # reset

_ANSI_RE = re.compile(r"\033\[[0-9;]*m")


def strip_ansi(text):
    """Remove ANSI escape sequences to measure visible width."""
    return _ANSI_RE.sub("", text)


def color_pct(used_pct):
    """Color based on how much quota is USED (high = bad)."""
    if used_pct >= 90:
        return R
    if used_pct >= 70:
        return Y
    return G


# ── Parse session data ──────────────────────────────────────────
model = "Opus"
try:
    model = d["model"]["display_name"]
except (KeyError, TypeError):
    pass

ctx_pct_used = 0
ctx_size = 0
try:
    ctx_pct_used = int(d["context_window"]["used_percentage"] or 0)
    ctx_size = int(d["context_window"]["context_window_size"] or 0)
except (KeyError, TypeError, ValueError):
    pass

in_tok = 0
out_tok = 0
try:
    in_tok = d["context_window"]["total_input_tokens"] or 0
except (KeyError, TypeError):
    pass
try:
    out_tok = d["context_window"]["total_output_tokens"] or 0
except (KeyError, TypeError):
    pass

cost_usd = 0.0
duration_ms = 0
try:
    cost_usd = float(d["cost"]["total_cost_usd"] or 0)
except (KeyError, TypeError, ValueError):
    pass
try:
    duration_ms = int(d["cost"]["total_duration_ms"] or 0)
except (KeyError, TypeError, ValueError):
    pass

proj_dir = ""
proj_name = ""
try:
    proj_dir = d["workspace"]["project_dir"] or ""
    proj_name = os.path.basename(proj_dir)
except (KeyError, TypeError):
    pass

# ── Git branch ──────────────────────────────────────────────────
branch = ""
cwd = os.getcwd()
candidate_dirs = []
if proj_dir:
    candidate_dirs.append(proj_dir)
if cwd and cwd not in candidate_dirs:
    candidate_dirs.append(cwd)

for try_dir in candidate_dirs:
    if not try_dir:
        continue
    try:
        r = subprocess.run(
            ["git", "-C", try_dir, "rev-parse", "--abbrev-ref", "HEAD"],
            capture_output=True, text=True, timeout=2,
        )
        if r.returncode == 0:
            branch = r.stdout.strip()
            if not proj_name:
                proj_name = os.path.basename(try_dir)
            break
    except Exception:
        pass

# ── Helpers ─────────────────────────────────────────────────────
def compact(n):
    n = float(n)
    if n >= 1_000_000:
        return f"{n / 1_000_000:.1f}m".replace(".0m", "m")
    if n >= 1_000:
        return f"{n / 1_000:.1f}k".replace(".0k", "k")
    return str(int(n))


def format_duration(ms):
    if ms >= 3_600_000:
        return f"{ms // 3_600_000}h{(ms // 60_000) % 60}m"
    if ms >= 60_000:
        return f"{ms // 60_000}m{(ms // 1000) % 60}s"
    return f"{ms // 1000}s"


def format_reset(minutes):
    """Format reset countdown."""
    if minutes is None:
        return ""
    m = int(minutes)
    if m >= 1440:
        return f" {D}({m // 1440}d){N}"
    if m >= 60:
        return f" {D}({m // 60}h){N}"
    return f" {D}({m}m){N}"



def used_pct_str(used_pct):
    """Format used or remaining % with color."""
    if used_pct is None or used_pct == "--":
        return "--"
    used = int(used_pct)
    c = color_pct(used)
    val = 100 - used if SHOW_REMAINING else used
    if SHOW_BAR:
        filled = round(min(100, max(0, val)) / 100.0 * 5)
        filled_chars = "\u25b0" * filled
        empty_chars = "\u25b1" * (5 - filled)
        bar = f"{c}{filled_chars}{empty_chars}{N} "
    else:
        bar = ""
    return f"{bar}{c}{val}%{N}"


def pace_indicator(used_pct, remain_min, window_min):
    """Show pace: positive = ahead (green), negative = over pace (red). Suppress within +/-10%."""
    if used_pct is None or remain_min is None:
        return ""
    try:
        used = int(used_pct)
        rmin = int(remain_min)
    except (ValueError, TypeError):
        return ""
    if rmin > window_min:
        return ""
    elapsed = window_min - rmin
    if elapsed <= 0:
        return ""
    expected = (elapsed * 100) // window_min
    delta = expected - used
    if delta > 10:
        return f" {G}+{delta}%{N}"
    if delta < -10:
        return f" {R}{delta}%{N}"
    return ""


# ── Quota API ───────────────────────────────────────────────────
CACHE_FILE = os.environ.get(
    "CQB_CACHE_PATH",
    os.path.join(tempfile.gettempdir(), "claude-sl-usage.json"),
)
CACHE_TTL = 300  # 5 minutes
LOCK_FILE = CACHE_FILE + ".lock"


def get_oauth_token():
    """Read OAuth token from Claude Code's credential store."""
    # Env var override
    tok = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
    if tok:
        return tok
    # Credentials file
    cred_path = Path.home() / ".claude" / ".credentials.json"
    if cred_path.exists():
        try:
            creds = json.loads(cred_path.read_text(encoding="utf-8"))
            return creds.get("claudeAiOauth", {}).get("accessToken")
        except Exception:
            pass
    return None


def fetch_usage_sync():
    """Call Anthropic usage API and write cache. Run in background thread."""
    try:
        token = get_oauth_token()
        if not token:
            return

        import urllib.request

        req = urllib.request.Request(
            "https://api.anthropic.com/api/oauth/usage",
            headers={
                "Authorization": f"Bearer {token}",
                "anthropic-beta": "oauth-2025-04-20",
                "Content-Type": "application/json",
            },
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode())

        def parse_reset_minutes(iso_str):
            if not iso_str:
                return None
            try:
                from datetime import datetime, timezone
                # Handle various ISO formats
                iso_str = iso_str.replace("+00:00", "+0000").replace("Z", "+0000")
                # Strip fractional seconds
                if "." in iso_str:
                    base, rest = iso_str.split(".", 1)
                    tz_part = ""
                    for sep in ["+", "-"]:
                        if sep in rest:
                            idx = rest.index(sep)
                            tz_part = rest[idx:]
                            break
                    iso_str = base + tz_part
                dt = datetime.strptime(iso_str, "%Y-%m-%dT%H:%M:%S%z")
                now = datetime.now(timezone.utc)
                diff = (dt - now).total_seconds() / 60
                return max(0, int(diff))
            except Exception:
                return None

        cache_data = {
            "five_hour_used": data.get("five_hour", {}).get("utilization", 0),
            "seven_day_used": data.get("seven_day", {}).get("utilization", 0),
            "five_hour_reset_min": parse_reset_minutes(data.get("five_hour", {}).get("resets_at")),
            "seven_day_reset_min": parse_reset_minutes(data.get("seven_day", {}).get("resets_at")),
            "extra_enabled": data.get("extra_usage", {}).get("is_enabled", False),
            "extra_used": data.get("extra_usage", {}).get("used_credits", 0),
            "extra_limit": data.get("extra_usage", {}).get("monthly_limit", 0),
            "fetched_at": time.time(),
        }

        tmp = CACHE_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(cache_data, f)
        os.replace(tmp, CACHE_FILE)

    except Exception:
        pass  # Intentional: statusline must never fail visibly
    finally:
        try:
            os.unlink(LOCK_FILE)
        except OSError:
            pass


_fetch_thread = None

def read_cached_usage():
    """Read cached usage data, trigger background refresh if stale."""
    global _fetch_thread
    cache = None
    if os.path.exists(CACHE_FILE):
        try:
            with open(CACHE_FILE) as f:
                cache = json.load(f)
        except Exception:
            pass

    # Check if cache is stale
    now = time.time()
    fetched_at = (cache or {}).get("fetched_at", 0)
    is_stale = (now - fetched_at) > CACHE_TTL

    if is_stale:
        # Try to acquire lock (non-blocking)
        try:
            fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
            os.write(fd, str(os.getpid()).encode())
            os.close(fd)
            # Fetch in background thread; joined at end of script
            t = threading.Thread(target=fetch_usage_sync)
            t.start()
            _fetch_thread = t
        except FileExistsError:
            # Another process is fetching; check if lock is stale (>30s)
            try:
                lock_age = now - os.path.getmtime(LOCK_FILE)
                if lock_age > 30:
                    os.unlink(LOCK_FILE)
            except OSError:
                pass

    if cache:
        # Adjust reset minutes for time elapsed since fetch
        elapsed_min = (now - cache.get("fetched_at", now)) / 60
        r5 = cache.get("five_hour_reset_min")
        r7 = cache.get("seven_day_reset_min")
        if r5 is not None:
            r5 = max(0, int(r5 - elapsed_min))
        if r7 is not None:
            r7 = max(0, int(r7 - elapsed_min))
        return {
            "u5": cache.get("five_hour_used"),
            "u7": cache.get("seven_day_used"),
            "r5": r5,
            "r7": r7,
            "extra_enabled": cache.get("extra_enabled", False),
            "extra_used": cache.get("extra_used", 0),
            "extra_limit": cache.get("extra_limit", 0),
        }

    return None


# ── Build output ────────────────────────────────────────────────
SEP = " \u2502 "  # │
DIAMOND = "\u25c6"  # ◆

# Context gauge (5 blocks)
ctx_remaining = 100 - ctx_pct_used
filled = round(min(100, max(0, ctx_remaining)) / 100.0 * 5)
gauge = "\u25b0" * filled + "\u25b1" * (5 - filled)  # ▰▱

# Context size label
if ctx_size >= 1_000_000:
    ctx_label = f"{ctx_size // 1_000_000}M"
else:
    ctx_label = f"{ctx_size // 1000}K"

# Line 1: model, project, branch
line1_parts = [f"{C}{DIAMOND} {model}{N}"]

if proj_name:
    loc = f"{proj_name}/{branch}" if (branch and SHOW_BRANCH) else proj_name
    if len(loc) > 40:
        loc = loc[:39] + "\u2026"
    line1_parts.append(loc)

line1 = SEP.join(line1_parts)

# Line 2: context gauge, quota, duration
# Each segment is (text, priority) - lower priority number = more important (dropped last).
# When the joined line exceeds MAX_WIDTH, the highest-numbered priorities are dropped first.
ctx_color = color_pct(ctx_pct_used)
ctx_str = f"{ctx_color}{gauge}{N} {ctx_remaining}%"
if SHOW_CONTEXT_SIZE:
    ctx_str += f" of {ctx_label}"

line2_segments = []  # list of (text, priority)
line2_segments.append((ctx_str, 2))

# Token counts
if SHOW_TOKENS and (in_tok or out_tok):
    line2_segments.append((f"\u2191{compact(in_tok)} \u2193{compact(out_tok)}", 4))

# Quota
usage = read_cached_usage()
if usage:
    u5 = usage["u5"]
    u7 = usage["u7"]
    r5 = usage["r5"]
    r7 = usage["r7"]

    pace5 = pace_indicator(u5, r5, 300) if SHOW_PACE else ""
    pace7 = pace_indicator(u7, r7, 10080) if SHOW_PACE else ""
    reset5 = format_reset(r5) if SHOW_RESET else ""
    reset7 = format_reset(r7) if SHOW_RESET else ""

    line2_segments.append((f"5h: {used_pct_str(u5)}{pace5}{reset5}", 1))
    line2_segments.append((f"7d: {used_pct_str(u7)}{pace7}{reset7}", 1))
else:
    if not get_oauth_token():
        line2_segments.append((f"5h: {D}no token{N}", 1))
        line2_segments.append((f"7d: {D}no token{N}", 1))
    else:
        line2_segments.append((f"5h: {D}--{N}", 1))
        line2_segments.append((f"7d: {D}--{N}", 1))

# Cost
if SHOW_COST and cost_usd > 0:
    line2_segments.append((f"{D}${cost_usd:.2f}{N}", 4))

# Duration
if SHOW_DURATION:
    line2_segments.append((f"{D}{format_duration(duration_ms)}{N}", 3))

# Drop lowest-priority segments until line fits within MAX_WIDTH
def build_line(segments):
    return SEP.join(text for text, _ in segments)

line2 = build_line(line2_segments)
while len(strip_ansi(line2)) > MAX_WIDTH and line2_segments:
    worst = max(range(len(line2_segments)), key=lambda i: line2_segments[i][1])
    line2_segments.pop(worst)
    line2 = build_line(line2_segments)

print(line1)
print(line2)

# Wait for background fetch to finish (max 8s) so cache gets written
if _fetch_thread is not None:
    _fetch_thread.join(timeout=8)

What it shows

  • Active model's display name on the first line
  • Project directory name, plus the current git branch when one is found
  • A five-block gauge and percentage for remaining context window space
  • Session input and output token totals in compact form (e.g. ↑44k ↓1.4k)
  • 5-hour and 7-day quota percentages fetched from the Anthropic usage API, each with an optional five-block bar
  • A countdown until each quota window resets, shown when quota data is available (CQB_RESET, on by default)
  • Total session duration (CQB_DURATION, on by default)
  • Optional context window size label such as 200K or 1M (CQB_CONTEXT_SIZE, off by default)
  • Optional pace indicator comparing quota used against time elapsed in the window (CQB_PACE, off by default)
  • Optional session cost in USD (CQB_COST, off by default)

Requirements

  • Python 3 (standard library only, no third-party packages)
  • The git command on PATH for the branch segment
  • Network access to api.anthropic.com for quota data
  • A Claude Code OAuth token, read from ~/.claude/.credentials.json or the CLAUDE_CODE_OAUTH_TOKEN environment variable; without it the quota segments show 'no token'
  • A terminal that renders ANSI colors and Unicode glyphs (◆ │ ▰ ▱ ↑ ↓); no Nerd Font needed
  • A writable system temp directory for the 5-minute API response cache

Behavior notes

  • Output is always two lines: model/project/branch on top, gauges and quota below
  • The context gauge is colored by usage: green in most scenarios, red with an empty bar at 91% used (thresholds are 70% yellow, 90% red)
  • When context used_percentage is null (brand-new session, just after /compact), the gauge shows a full bar at 100% remaining
  • The token segment disappears when both token counts are zero, as in the new-session and post-compact scenarios
  • The branch comes from running git in the project directory, not from the session JSON; in the non-git scratch directory only the project name appears
  • In the worktree scenario the project name comes from project_dir while the branch comes from the worktree checkout (app/worktree-feature)
  • The rate_limits block in the stdin JSON is ignored; every preview shows 'no token' for the quota segments because no OAuth token was present, even when stdin reported 88% five-hour usage
  • Cost never appears in the previews, including the $4.12 session, because CQB_COST defaults to off
  • Effort level, thinking, vim mode, PR info, output style, session name, and agent name from the JSON are never displayed
  • If the second line exceeds 80 visible columns (CQB_MAX_WIDTH), segments are dropped highest-priority-number first: tokens and cost go first, then duration, then the context gauge; quota segments are dropped last
  • The project/branch label is truncated to 40 characters with an ellipsis
  • On empty or unparseable stdin the script prints just 'Claude' and exits 0
  • Quota data is cached for 5 minutes; a stale cache triggers a background refresh guarded by a lock file, and cached reset countdowns are aged down by the time since fetch

More status lines

app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0h
Opus 4.8 | main | ██░░░░░░░░ 22.0% | 44k / 200k | 02:07 26% | Wed 7%
Opus 4.8 main app 44k/200k $0.41
app ⏱ 10m +128 -34 ░░░░░░░ 22% $0.41 ◆ Opus
󰚩 Opus 4.8󰍛 ▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 22%󰄉 $0.41 󰝰 app󰘬 main
Opus 4.8 high ctx 22% app (main) PR #1287 $0.41 · 10m +128 -34 Session 74% left Resets in 2h 7m Weekly 93% left Resets in 2d 1h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions

New to Claude Code status lines? Read the setup guide.