Contents
One of the quieter features in Claude Code is the status line — a configurable block that appears above the input prompt, updated after every response. By default there’s nothing there. But Claude Code passes a JSON payload to any script you point it at, and that script can render whatever you want.
It’s a small feature with a surprisingly high ceiling. Ours shows three lines of information covering the model, session, current voice, working directory, git state, token usage, session cost, and rate limit warnings. All of it live, all of it color-coded.
Here’s how it works and what we built.
How to Enable It
The status line is configured in ~/.claude/settings.json under the statusLine key:
{
"statusLine": {
"type": "command",
"command": "python3 ~/.claude/status_line.py"
}
}
That’s the entire setup. Claude Code will run your command after each turn, pipe a JSON object into it via stdin, and render whatever the script prints to stdout. ANSI escape codes work — colors, bold, dim — so you can build a genuinely styled display.
The quickest way to set this up is the /statusline-setup skill in Claude Code, which walks you through the configuration interactively.
What Claude Passes to Your Script
Every time the status line runs, Claude Code sends a JSON object on stdin with the current session state. The fields we use:
{
"model": {
"display_name": "Claude Sonnet 4.6"
},
"session_id": "abc12345-...",
"cwd": "/home/paul/myproject",
"context_window": {
"used_percentage": 34,
"total_input_tokens": 28400,
"total_output_tokens": 6100
},
"cost": {
"total_cost_usd": 0.42
},
"rate_limits": {
"five_hour": {
"used_percentage": 61
}
}
}
Your script reads this, does whatever computation it wants, and prints output. Whatever it prints becomes the status display.
What We Display
Our status line renders three lines, each broken into sections separated by |.
Line 1 — Identity
Ubuntu-rdp | ✨ Sonnet 4.6 | 🔑 abc12345 | 🎙️ Zoe-8 | 📻 laptop-win
- Machine name — hardcoded to
Ubuntu-rdpso it’s always clear which environment I’m in. Useful when you’re managing sessions across multiple servers. - Model with emoji —
🎭for Opus,✨for Sonnet,⚡for Haiku. At a glance I know what tier is active and whether a/fasttoggle changed it. - Session ID — the first 8 characters of the UUID. Short enough to be useful for matching log entries without taking up the whole line.
- Active voice — pulled from the Agent Vibes config file. If the project has its own
.agentvibes/config.json, that takes priority; otherwise it falls back to~/.agentvibes/config.json. Shows whatever voice is currently configured — in this caseZoe-8. - Audio destination — if Agent Vibes is routing TTS to a remote host, the SSH alias shows here. Since I develop on a headless server, audio gets routed to
laptop-winvia the AgentVibes Receiver. Seeing this in the status line confirms the audio pipeline is pointing where I think it is.
Line 2 — Working Directory
📁 ~/preibisch.biz
A single field: the current working directory, with ~ substituted for the home path and a truncation to 40 characters if the path is longer. Dedicated to its own line so it never gets crowded out when line 1 or 3 gets busy.
Line 3 — Session Health
🌿 main | 🧠 34.5k (34%) | 💵 $0.42 | ⏳ 5h:61%
This is the one I actually watch during a session.
Git branch — branch name with a * suffix and a 🔥 emoji if the working tree is dirty. A 🌿 means clean. Two quick subprocess calls — git rev-parse --abbrev-ref HEAD and git status --porcelain — cached to 2-second timeouts so they never block the display.
Context tokens — total tokens consumed (input + output) in this session, with the percentage of the context window used. Formatted as 34.5k or 1.2M depending on scale. When this hits 70%+ it’s a signal to think about /compact before the window fills. Watching this number grow over a long autonomous session is genuinely useful — it tells you how far the model is from needing a context summary.
Session cost — color-coded by amount. Green with 💵 under $50, yellow with 💰 at $50–100, red bold with 💸 above $100. The threshold coloring makes budget tracking passive: if you see red, you notice it without looking for it.
Rate limit warning — the 5-hour rolling usage percentage. Hidden entirely when below 50%. Appears in yellow with ⏳ between 50–80%, and red with ⚠️ above 80%. The warning only shows when you need to pay attention to it, which keeps the display clean during normal operation.
Reading Agent Vibes Config
The voice and audio destination fields come from the Agent Vibes config, not from Claude’s JSON payload. The script looks for the config in two places:
candidates = [
os.path.join(cwd, ".agentvibes", "config.json"), # project-level
os.path.expanduser("~/.agentvibes/config.json"), # global fallback
]
The project-level config wins if it exists, so you can have different voices for different projects and the status line reflects which one is active. The voice field stores a raw Piper voice ID like en_US-libritts-high::Zoe-8 — the script strips the prefix and shows just Zoe-8.
The Full Script
The complete ~/.claude/status_line.py:
#!/usr/bin/env python3
import json, sys, subprocess, os, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
R = '\033[0m'; B = '\033[1m'; DIM = '\033[2m'
RED = '\033[91m'; GRN = '\033[92m'; YEL = '\033[93m'
BLU = '\033[94m'; MAG = '\033[95m'; CYN = '\033[96m'
def fmt_tokens(t):
if t >= 1_000_000: return f"{t/1_000_000:.1f}M"
if t >= 1_000: return f"{t/1_000:.1f}k"
return str(t)
def git_info(cwd):
try:
br = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=cwd, capture_output=True, text=True, timeout=2)
st = subprocess.run(['git', 'status', '--porcelain'],
cwd=cwd, capture_output=True, text=True, timeout=2)
branch = br.stdout.strip() if br.returncode == 0 else ""
dirty = bool(st.stdout.strip()) if st.returncode == 0 else False
return branch, dirty
except Exception:
return "", False
def fmt_path(path):
if not path: return ""
home = os.path.expanduser("~")
if path.startswith(home): path = "~" + path[len(home):]
return path if len(path) <= 40 else "..." + path[-37:]
try:
data = json.load(sys.stdin)
except Exception:
print(f"{CYN}Claude Code{R}"); sys.exit()
model = data.get('model', {})
name = model.get('display_name', 'Claude')
short = name.replace('Claude ', '').strip()
emoji = '🎭' if 'Opus' in name else '✨' if 'Sonnet' in name else '⚡' if 'Haiku' in name else '🤖'
cwd = data.get('cwd', '')
session_id = data.get('session_id', '')
# AgentVibes
ssh_host = voice_name = ""
for av_path in [os.path.join(cwd, ".agentvibes/config.json"),
os.path.expanduser("~/.agentvibes/config.json")]:
try:
av = json.load(open(av_path))
if not ssh_host and av.get("audio_destination") == "remote":
ssh_host = av.get("audio_ssh_alias", "")
if not voice_name:
raw = av.get("voice", "")
voice_name = raw.split("::")[-1] if "::" in raw else raw
except Exception:
pass
# Line 1
line1 = [f"{GRN}Ubuntu-rdp{R}", f"{emoji} {CYN}{short}{R}"]
if session_id:
line1.append(f"🔑 {DIM}{session_id[:8]}{R}")
if voice_name:
line1.append(f"🎙️ {MAG}{voice_name}{R}")
if ssh_host:
line1.append(f"📻 {YEL}{ssh_host}{R}")
# Line 2
fp = fmt_path(cwd)
line2 = [f"📁 {YEL}{fp}{R}"] if fp else []
# Line 3
line3 = []
branch, dirty = git_info(cwd)
if branch:
line3.append(f"{'🔥' if dirty else '🌿'} {MAG}{branch}{'*' if dirty else ''}{R}")
ctx = data.get('context_window', {})
used = (ctx.get('total_input_tokens', 0) or 0) + (ctx.get('total_output_tokens', 0) or 0)
pct = ctx.get('used_percentage', 0) or 0
if used:
line3.append(f"🧠 {BLU}{fmt_tokens(used)} ({pct}%){R}")
cost = (data.get('cost', {}) or {}).get('total_cost_usd', 0) or 0
if cost:
c, sym = (RED+B, '💸') if cost > 100 else (YEL, '💰') if cost > 50 else (GRN, '💵')
line3.append(f"{sym} {c}${cost:.2f}{R}")
five_hr = (data.get('rate_limits', {}) or {}).get('five_hour', {}) or {}
rl_pct = five_hr.get('used_percentage', 0) or 0
if rl_pct > 80:
line3.append(f"⚠️ {RED}5h:{rl_pct:.0f}%{R}")
elif rl_pct > 50:
line3.append(f"⏳ {YEL}5h:{rl_pct:.0f}%{R}")
parts = [" | ".join(line1)]
if line2: parts.append(" | ".join(line2))
if line3: parts.append(" | ".join(line3))
print("\n".join(parts))
Drop this into ~/.claude/status_line.py, add the statusLine key to your settings, and it starts showing immediately on the next Claude Code response.
What to Add From Here
The status line isn’t limited to what Claude’s JSON provides. Because it’s a script, it can pull in anything: an HTTP call to check a deployment status, a database query, a file read for project-specific state. A few extensions worth considering:
- Active BMAD agent — read from the BMAD session state file and show which agent is currently active
- CI status — a quick GitHub API call showing whether your branch’s last run passed or failed
- Memory usage —
psutilto show how hard the machine is running during an intensive session - Custom project label — read a
.claude-projectfile in the repo root and display the project name
The key is keeping the script fast. Subprocess calls should have short timeouts. HTTP calls should be fire-and-forget or cached. The status line blocks between turns, so a slow script means a delay before the prompt is ready.
The status line is one of those features that becomes load-bearing once you have it. Sessions feel different when you can see context pressure building, know the cost before it surprises you, and confirm at a glance that git is clean before you push. Small information density, genuinely high utility.
The /statusline-setup skill in Claude Code handles the initial wiring if you’d rather not edit settings.json directly.
Comments
Loading comments…
Leave a comment