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 /medium 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
On a pay-per-use model (e.g. Claude Fable 5) there are no rate-limit quotas, so the d-/w- segments are hidden and the $ sign turns red to signal that cost is the only consumption gauge:
20:23 Fable 5 /medium ctx:12% (880k of 1M) $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)./medium: Current reasoning effort (effort.level), the value set by the/effortcommand. Shown right after the model name and updated on each refresh. Hidden entirely if the payload carries noeffortfield.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. The remaining time showsXd Yhwhen a day or more is left,Xh Ymwhen under a day, andYmwhen under an hour. Both quota segments are hidden entirely when the session runs a pay-per-use model (the payload carries norate_limitsat all).$5.34: Session cost estimate (cost.total_cost_usd) — Claude Code computes it from the token usage of each API call at the model's list prices (input, output, cache read, cache write). It is per-session and resets to $0.00 in every new session. On a subscription plan with quotas it is not money billed to you — a relative gauge of how heavy the session is; your actual limits are thed-andw-quotas. On a pay-per-use model (no quotas) the$sign is rendered red: cost is then the real consumption metric (e.g. credit burn on Fable 5).~/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. Showscleanwhen 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 raworganizationTypefrom your account (for exampleclaude_pro,claude_max,claude_team).
A zero-polling, native-JSON status line. It is a single Python file with no shell wrapper.
statusline.py: The core formatter. It reads the JSON that Claude Code provides natively onstdinand 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.
- 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 rate_limits is present but five_hour is missing, the script falls back to ~/.claude/rate_limit_state.json for the reset time; if neither is available it shows d-noquota. If the payload has no rate_limits key at all — the case for pay-per-use models like Claude Fable 5, which have no subscription quotas — both segments are hidden and the $ sign turns red instead.
If you are the AI agent reading this Gist to fulfill an installation request, follow this protocol:
- Prerequisite check: Verify Python 3 is installed (
python3 --version, orpython --versionon Windows). If Python is missing, abort and ask the user to install Python 3 first. - Analyze & adapt: Review the
statusline.pybelow. Check your knowledge for any recent changes to the Claude Code status line payload (the stdin JSON passed to thestatusLinecommand) and adapt the parsing if fields have moved or been renamed. - 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, andrate_limits.five_hour/seven_daywith futureresets_atepochs. - Install: Save the script to
~/.claude/statusline.py. Edit~/.claude/settings.jsonto add or replace thestatusLineblock — merge, do not overwrite the user's other settings. Use the interpreter and absolute path correct for the user's OS (see section 2). - Confirmation: Tell the user the status line is installed and will appear on the next refresh (within
refreshIntervalseconds) or when they submit a message. If it does not appear, suggest restarting Claude Code, and ask them to report back.
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-<pct>%with no reset time after it — the reset time has passed; this is the last known percentage, shown without a countdown until the payload refreshes with the new quota window.d-noquota— the payload has arate_limitsobject but nofive_hourdata, and no fallback file either.- (no
d-/w-segments, red$) — the payload has norate_limitskey at all: a pay-per-use model (e.g. Fable 5). Not an error; cost is the consumption metric. 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 filerate_limit_state.jsonexists but is unreadable or corrupt.!slow(in the git slot) —gitexceeded its 3-second timeout.ctx:?/cost:?— no context-window / cost data.- account
?/ tier?—~/.claude.jsonhas nooauthAccount. - model
?— nomodel.display_nameand noCLAUDE_MODELenvironment variable.
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', '?')
# Effort (/effort) — reflects the current setting, refreshes with the status line
effort = (data.get('effort') or {}).get('level')
effort_str = f' {P}/{effort}{R}' if effort else ''
# 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_digits = f'{cost_val:.2f}' if cost_val is not None else None
# 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}{pct_text}{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}{pct_text}{R}'
total_mins = int(diff.total_seconds() // 60)
d, rem = divmod(total_mins, 60 * 24)
h, m = divmod(rem, 60)
if d:
dur = f'{d}d {h}h'
elif h:
dur = f'{h}h {m}m'
else:
dur = f'{m}m'
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 not rl:
# No rate_limits key at all: price/credit-billed model (e.g. Fable 5) — hide quota
# segments; red "$" sign marks that cost is the only consumption signal
day_str = ''
week_str = ''
dollar_color = LD
else:
dollar_color = W
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
day_part = f' {day_str}' if day_str else ''
week_part = f' {week_str}' if week_str else ''
cost_part = f'{dollar_color}${R}{W}{cost_digits}{R}' if cost_digits is not None else f'{W}cost:?{R}'
print(f'{TM}{now_str}{R} {C}{model}{R}{effort_str} {Y}{ctx_str}{R}{day_part}{week_part} {cost_part} {path_part} {G}{user}{R} {P}{tier}{R}')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.
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
}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.