Skip to content

Instantly share code, notes, and snippets.

@it3xl
Last active June 23, 2026 05:59
Show Gist options
  • Select an option

  • Save it3xl/884a5747b9d691cd0e2b2b981f512d4b to your computer and use it in GitHub Desktop.

Select an option

Save it3xl/884a5747b9d691cd0e2b2b981f512d4b to your computer and use it in GitHub Desktop.
Claude Code Custom Status Line Guide

Claude Code Custom Status Line Guide

This guide sets up a comprehensive custom status line for Claude Code. On a single line it shows your session at a glance: time, active model, context-window usage, your 5-hour and 7-day rate-limit quotas, session cost, working directory, git branch and status, your account, and your subscription tier.

Installation: Ask Claude Code to install it for you. Paste the gist URL into the prompt and say install (replace the URL with this gist's URL after you publish it):

install https://gist.github.com/it3xl/884a5747b9d691cd0e2b2b981f512d4b

Preview:

20:23  Model  ctx:12% (880k of 1M)  d-6% 22:16 1h 59m  w-13% Sat 08:16 4d 11h  $5.34  ~/my  main +1 ~2  account  subscription

What each item means:

  • 20:23: Current local time (re-rendered whenever the status line refreshes).
  • Model: Active model (model.display_name).
  • ctx:12% (880k of 1M): Percentage of the context window used, with tokens remaining out of the total window size.
  • d-6% 22:16 1h 59m: 5-hour rate-limit quota consumed, local reset time, and time remaining.
  • w-13% Sat 08:16 4d 11h: 7-day rate-limit quota consumed, day and time of reset, and time remaining.
  • $5.34: Session cost estimate (cost.total_cost_usd). On a subscription plan this is not money billed to you — it is a relative gauge of how heavy the session is (tokens, output volume, model tier). Your actual limits are the d- and w- quotas.
  • ~/my: Working directory, shortened relative to your home folder. Shows ~ when you are exactly at home, and the full path when the folder is outside home.
  • main +1 ~2: Git branch and file status — counts of staged / modified / untracked files. Shows clean when there are no uncommitted changes. This block disappears entirely if the folder is not a git repository.
  • account: Authenticated account email.
  • subscription: Subscription tier — the raw organizationType from your account (for example claude_pro, claude_max, claude_team).

Approach Description

A zero-polling, native-JSON status line. It is a single Python file with no shell wrapper.

  1. statusline.py: The core formatter. It reads the JSON that Claude Code provides natively on stdin and extracts the model, context-window usage, rate-limit quotas, session cost, and workspace path. It reads your account email and subscription tier from ~/.claude.json — the only two values not present in the stdin payload.
    • Zero-Polling: Uses the rate-limit statistics supplied directly in the Claude Code JSON. No background cache polling, no CPU overhead.
    • Cross-Platform: Works on macOS, Windows, and Linux. UTF-8 input/output is forced so non-ASCII paths or branch names do not break the bar on Windows (where pipes default to a locale codepage). No bash wrapper required.
    • Git Status: Fast git checks with a 3-second timeout so the prompt never hangs in large repositories. The git block is omitted entirely outside a repository.
  2. Configuration: A single edit to ~/.claude/settings.json.

The script only reads; it never writes. The files it touches: the stdin payload, ~/.claude.json (account email and tier), an optional ~/.claude/rate_limit_state.json fallback for the 5-hour reset time, and the git command for branch and status.

Note

Rate-limit data: Claude Code includes rate_limits in the stdin payload, so the d- and w- segments populate immediately. If a payload arrives without rate_limits.five_hour, the script falls back to ~/.claude/rate_limit_state.json for the reset time. If neither is available it shows d-??.

🤖 Instructions for Claude Code (AI Agent)

If you are the AI agent reading this Gist to fulfill an installation request, follow this protocol:

  1. Prerequisite check: Verify Python 3 is installed (python3 --version, or python --version on Windows). If Python is missing, abort and ask the user to install Python 3 first.
  2. Analyze & adapt: Review the statusline.py below. Check your knowledge for any recent changes to the Claude Code status line payload (the stdin JSON passed to the statusLine command) and adapt the parsing if fields have moved or been renamed.
  3. Pre-flight test: Write the script to a temporary folder, feed it a mock Claude Code status JSON on stdin, and run it. Confirm it prints exactly one line and does not raise. Use a mock with model.display_name, workspace.project_dir, context_window (used_percentage, context_window_size, remaining_percentage), cost.total_cost_usd, and rate_limits.five_hour/seven_day with future resets_at epochs.
  4. Install: Save the script to ~/.claude/statusline.py. Edit ~/.claude/settings.json to add or replace the statusLine block — merge, do not overwrite the user's other settings. Use the interpreter and absolute path correct for the user's OS (see section 2).
  5. Confirmation: Tell the user the status line is installed and will appear on the next refresh (within refreshInterval seconds) or when they submit a message. If it does not appear, suggest restarting Claude Code, and ask them to report back.

Diagnostic Codes

When data is missing, malformed, or transitioning, the affected segment shows a specific code instead of crashing. (The w- weekly codes mirror the d- ones.)

  • d-syncing — the reset time has passed; the shown percentage is stale until the payload refreshes.
  • d-noquota — no rate-limit data in the payload (and, for the day, no fallback file either).
  • d-??% <time> — reset time known, percentage missing.
  • d-<pct>% ??:?? — percentage known, reset time missing.
  • d-<pct>% ?format — reset value present but unparseable.
  • d-?calc — the percentage value is non-numeric.
  • d-?cache — the day fallback file rate_limit_state.json exists but is unreadable or corrupt.
  • !slow (in the git slot) — git exceeded its 3-second timeout.
  • ctx:? / cost:? — no context-window / cost data.
  • account ? / tier ?~/.claude.json has no oauthAccount.
  • model ? — no model.display_name and no CLAUDE_MODEL environment variable.

1. Setup the Formatter (statusline.py)

Create a file at ~/.claude/statusline.py and paste the following Python script:

import json, sys, datetime, os, subprocess

# Source: https://gist.github.com/it3xl/884a5747b9d691cd0e2b2b981f512d4b
# utf-8 I/O on Windows too (consoles default to a locale codec)
for _stream in (sys.stdin, sys.stdout):
    try:
        _stream.reconfigure(encoding='utf-8', errors='replace')
    except Exception:
        pass

def fmt_num(n):
    n = int(n)
    if n >= 1_000_000:
        return f'{n / 1_000_000:.1f}'.replace('.0', '') + 'M'
    if n >= 1000:
        return f'{n // 1000}k'
    return str(n)

# Colors
R   = '\033[0m'
G   = '\033[32m'   # green   - user
C   = '\033[36m'   # cyan    - model, branch
DC  = '\033[38;5;66m'  # dim cyan - git status
Y   = '\033[33m'   # yellow  - ctx
M   = '\033[35m'   # magenta - path
W   = '\033[37m'   # white   - cost
P   = '\033[2;36m' # dim cyan      - tier
TM  = '\033[97m'   # bright white  - time
LD  = '\033[91m'   # bright red    - day limit pct
LW  = '\033[93m'   # bright yellow - week limit pct
DLD = '\033[38;5;174m' # muted red    - day reset time (middle, slightly dimmed)
DLW = '\033[38;5;179m' # muted yellow - week reset time (middle, slightly dimmed)

try:
    data = json.load(sys.stdin)
except Exception:
    data = {}

config_dir = os.environ.get('CLAUDE_CONFIG_DIR', os.path.expanduser('~/.claude'))
if config_dir.startswith('~'):
    config_dir = os.path.expanduser(config_dir)

# Logged-in user + tier
# .claude.json may live in config_dir or in ~ — and a stub may exist in one
# without oauthAccount. Try both, prefer whichever actually has the account.
candidates = [os.path.join(config_dir, '.claude.json'), os.path.expanduser('~/.claude.json')]
seen = set()
d = {}
for path in candidates:
    if path in seen or not os.path.exists(path):
        continue
    seen.add(path)
    try:
        with open(path, encoding='utf-8') as f:
            loaded = json.load(f)
    except Exception:
        continue
    if 'oauthAccount' in loaded:
        d = loaded
        break
    if not d:
        d = loaded
try:
    acc = d.get('oauthAccount', {})
    user = acc.get('emailAddress', '?')
    tier = acc.get('organizationType') or '?'
except Exception:
    user = '?'
    tier = '?'

# Model
model = (data.get('model') or {}).get('display_name') or os.environ.get('CLAUDE_MODEL', '?')

# Directory — pin to the project root (where Claude Code was started),
# not the rolling current_dir that shifts as the agent cd's around.
ws = data.get('workspace') or {}
cwd = ws.get('project_dir') or data.get('cwd') or ws.get('current_dir') or os.getcwd()

# Context window
ctx_obj = data.get('context_window') or {}
used_pct = ctx_obj.get('used_percentage')
ctx_size = ctx_obj.get('context_window_size')
remaining_pct = ctx_obj.get('remaining_percentage')
if used_pct is not None:
    ctx_str = f'ctx:{used_pct}%'
    if remaining_pct is not None and ctx_size:
        left = int(ctx_size * remaining_pct / 100)
        ctx_str += f' ({fmt_num(left)} of {fmt_num(ctx_size)})'
else:
    ctx_str = 'ctx:?'

# Cost
cost_obj = data.get('cost') or {}
cost_val = cost_obj.get('total_cost_usd') if isinstance(cost_obj, dict) else cost_obj
cost_str = f'${cost_val:.2f}' if cost_val is not None else 'cost:?'

# Rate limits from JSON
rl = data.get('rate_limits') or {}
fh = rl.get('five_hour') or {}
sd = rl.get('seven_day') or {}

fh_used = fh.get('used_percentage')
fh_reset_ts = fh.get('resets_at')
sd_used = sd.get('used_percentage')
sd_reset_ts = sd.get('resets_at')

def to_local_naive(reset_ts):
    if isinstance(reset_ts, (int, float)):
        return datetime.datetime.fromtimestamp(reset_ts / 1000.0 if reset_ts > 1e11 else reset_ts)
    return datetime.datetime.fromisoformat(str(reset_ts).replace('Z', '+00:00')).astimezone().replace(tzinfo=None)

def fmt_day_limit(reset_ts, used_pct):
    try:
        pct_text = f'd-{round(used_pct)}%' if used_pct is not None else 'd-??%'
    except (TypeError, ValueError):
        return f'{LD}d-?calc{R}'
    if not reset_ts:
        return f'{LD}{pct_text} ??:??{R}' if used_pct is not None else f'{LD}{pct_text}{R}'
    try:
        reset_dt = to_local_naive(reset_ts)
    except Exception:
        return f'{LD}{pct_text} ?format{R}'
    diff = reset_dt - datetime.datetime.now()
    if diff.total_seconds() <= 0:
        # reset passed: used_pct is stale until the JSON refreshes
        return f'{LD}d-syncing{R}'
    h, m = divmod(int(diff.total_seconds() // 60), 60)
    dur = f'{h}h {m}m' if h else f'{m}m'
    return f'{LD}{pct_text}{R} {DLD}{reset_dt.strftime("%H:%M")}{R} {LD}{dur}{R}'

def fmt_week_limit(reset_ts, used_pct):
    try:
        pct_text = f'w-{round(used_pct)}%' if used_pct is not None else 'w-??%'
    except (TypeError, ValueError):
        return f'{LW}w-?calc{R}'
    if not reset_ts:
        return f'{LW}{pct_text} ??:??{R}' if used_pct is not None else f'{LW}{pct_text}{R}'
    try:
        reset_dt = to_local_naive(reset_ts)
    except Exception:
        return f'{LW}{pct_text} ?format{R}'
    diff = reset_dt - datetime.datetime.now()
    if diff.total_seconds() <= 0:
        return f'{LW}w-syncing{R}'
    total_mins = int(diff.total_seconds() // 60)
    d, rem = divmod(total_mins, 60 * 24)
    h = rem // 60
    dur = f'{d}d {h}h' if d else f'{h}h'
    day_name = reset_dt.strftime('%a')
    return f'{LW}{pct_text}{R} {DLW}{day_name} {reset_dt.strftime("%H:%M")}{R} {LW}{dur}{R}'

if fh_reset_ts or fh_used is not None:
    day_str = fmt_day_limit(fh_reset_ts, fh_used)
else:
    try:
        with open(os.path.join(config_dir, 'rate_limit_state.json'), encoding='utf-8') as f:
            day_str = fmt_day_limit(json.load(f).get('reset_at'), None)
    except FileNotFoundError:
        day_str = f'{LD}d-noquota{R}'
    except Exception:
        day_str = f'{LD}d-?cache{R}'

week_str = fmt_week_limit(sd_reset_ts, sd_used) if (sd_reset_ts or sd_used is not None) else f'{LW}w-noquota{R}'

# Git info
try:
    branch = subprocess.check_output(
        ['git', '-C', cwd, 'branch', '--show-current'],
        stderr=subprocess.DEVNULL, timeout=3
    ).decode('utf-8', 'replace').strip() or '?'
    status_out = subprocess.check_output(
        ['git', '-C', cwd, 'status', '--porcelain'],
        stderr=subprocess.DEVNULL, timeout=3
    ).decode('utf-8', 'replace').strip()
    if status_out:
        lines = status_out.splitlines()
        def conflict(l): return 'U' in l[:2] or l[:2] in ('AA', 'DD')
        conflicted = sum(1 for l in lines if conflict(l))
        staged     = sum(1 for l in lines if not conflict(l) and l[0] in 'MADRCT')
        modified   = sum(1 for l in lines if not conflict(l) and l[1] in 'MDT')
        untracked  = sum(1 for l in lines if l.startswith('??'))
        parts = []
        if conflicted: parts.append(f'!{conflicted}')
        if staged:     parts.append(f'+{staged}')
        if modified:   parts.append(f'~{modified}')
        if untracked:  parts.append(f'?{untracked}')
        git_status = ' '.join(parts) if parts else 'clean'
    else:
        git_status = 'clean'
except subprocess.TimeoutExpired:
    branch = ''
    git_status = '!slow'
except Exception:
    branch = ''
    git_status = ''

now_str = datetime.datetime.now().strftime('%H:%M')

home = os.path.expanduser('~')
nc_cwd, nc_home = os.path.normcase(cwd), os.path.normcase(home)
if nc_cwd == nc_home:
    display_cwd = '~'
elif nc_cwd.startswith(nc_home + os.sep):
    display_cwd = cwd[len(home):].lstrip('/\\')
else:
    display_cwd = cwd

path_part = f'{M}{display_cwd}{R}'
if branch:
    gs = f' {DC}{git_status}{R}' if git_status and git_status != 'clean' else f' {DC}clean{R}'
    path_part += f'  {C}{branch}{R}{gs}'
elif git_status:
    path_part += f'  {DC}{git_status}{R}'

# day_str / week_str already carry their own color codes
week_part = f'  {week_str}' if week_str else ''

print(f'{TM}{now_str}{R}  {C}{model}{R}  {Y}{ctx_str}{R}  {day_str}{week_part}  {W}{cost_str}{R}  {path_part}  {G}{user}{R}  {P}{tier}{R}')

2. Configure Claude Code to Use the Status Line

The settings file is ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json if you set that variable). Add a statusLine block. Use an absolute interpreter-plus-script path so it works regardless of how the command is launched. refreshInterval is in seconds.

macOS / Linux

If you have jq, this safely merges the block while preserving your existing settings:

F="$HOME/.claude/settings.json"; [ -f "$F" ] || echo '{}' > "$F"
jq --arg cmd "python3 $HOME/.claude/statusline.py" \
   '.statusLine = {type:"command", command:$cmd, refreshInterval:30}' \
   "$F" > "$F.tmp" && mv "$F.tmp" "$F"

Or edit ~/.claude/settings.json by hand and add:

  "statusLine": {
    "type": "command",
    "command": "python3 /Users/<your-name>/.claude/statusline.py",
    "refreshInterval": 30
  }

Windows

python3 is usually not on PATH on Windows; use python (or py). Open C:\Users\<your-name>\.claude\settings.json and add:

  "statusLine": {
    "type": "command",
    "command": "python C:/Users/<your-name>/.claude/statusline.py",
    "refreshInterval": 30
  }

Important

On Windows, use forward slashes in the command path. Claude Code runs the command through Git Bash or PowerShell, which treat backslashes as escape characters. ~ also expands, so python ~/.claude/statusline.py works too.

That's it. Submit a message or wait up to refreshInterval seconds and the status line appears. If it does not show, restart Claude Code.

Note

Claude Code refreshes the status line itself, on its refreshInterval schedule and on events, so the script prints one line and exits. There is no need to add any polling or while loop.


Migration note: Do not manually copy the script to a new machine or OS login. APIs and formats change rapidly. Instead, always ask the agent to install or update the status line directly from this gist. The agent will pull the latest version and configure it for your current environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment