A long Claude Code session is easy to run blind. You lose track of how full the context window is, how much of your rate limit you've burned, which model you're on, and — if you keep more than one account — which account is even active. Then you hit a wall mid-thought: a context limit, a rate-limit lockout, or the sinking realization you've been working on the wrong login.
Claude Code has a fix built in: a custom status line, a strip of text at the bottom of the screen that you control completely. This post is about what's worth putting there, and how I built mine.
The problem: flying blind
Out of the box, the status line tells you very little. During a quick task that's fine. During a two-hour session it isn't, because the things that bite you are exactly the things you can't see:
- Context window. It fills up as you work. When it's full, Claude starts losing the earlier part of the conversation. If nothing shows you it's at 85%, the first sign of trouble is the trouble.
- Rate limits. On Pro and Max plans there's a 5-hour cap and a weekly cap. Nothing warns you as you approach them — you just get cut off. Knowing you're at 90% of your 5-hour window, and when it resets, changes how you pace the work.
- Model and effort. Did that last
/modelswitch actually take? Am I on a high-effort setting burning tokens fast, or a low one? Easy to forget, costly to guess wrong. - Which account. If you run a personal and a work account on one machine, two terminals look identical. Running a work task on your personal plan is a real mistake with no visual cue to prevent it.
- Is it even working? You kick off a task, tab away to read something, and come back — is Claude still thinking, or has it been waiting on you for five minutes?
None of this is visible by default. The status line is where you make it visible.
How the status line works
The mechanism is refreshingly simple. In settings.json you point Claude Code at a
command:
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"refreshInterval": 2
}
That's it. Every couple of seconds, Claude Code runs your script. It pipes a JSON blob to the script's standard input describing the current session, and whatever your script prints goes on the status line. No API, no plugin — just a script that reads JSON and echoes a string.
The JSON carries the useful stuff. A quick tour of the fields I use, pulled straight from my script:
input=$(cat) # the JSON arrives on stdin
model=$(echo "$input" | jq -r '.model.display_name')
task=$(echo "$input" | jq -r '.session_name')
pct=$(echo "$input" | jq -r '.context_window.used_percentage')
dir=$(echo "$input" | jq -r '.workspace.current_dir // .cwd')
sess=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage')
week=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage')
effort=$(echo "$input" | jq -r '.effort.level')
So the context percentage, both rate limits (with their reset times), the model, the effort level, the working directory, and the session's task name are all handed to you for free. Your job is just to lay them out well.
What's worth showing
Here's the line my script builds, left to right, and why each piece earns its space.
Context percentage — with a color that screams before it's too late. A raw number is easy to ignore; a color isn't. I paint it green, then yellow, then red as it fills:
p=$(printf '%.0f' "$pct")
if [ "$p" -ge 80 ]; then c=$RED
elif [ "$p" -ge 50 ]; then c=$YELLOW
else c=$GREEN
fi
ctx_seg="▪${c}${p}%"
Now "context is getting full" is a red number in the corner of my eye, not a
surprise. When it goes red, I know it's time to wrap up or /compact.
Rate limits — the percentage and the reset time. This is the segment that has saved me the most. Both the 5-hour and 7-day windows show how much I've used and when they reset:
s=$(printf '%.0f' "$sess")
reset_txt="($(date -d @"$sess_reset" +'%H:%M'))" # e.g. (16:30)
sess_seg="▪${sc}${s}%${reset_txt}"
At 88% with a reset at 16:30, I can decide to slow down or just wait it out — instead of getting blindsided by a lockout. The weekly window shows a weekday too, since its reset is days away, not hours.
Model and effort. The model name, with the noisy (1M context) shortened to
[1M]. The effort level, colored by intensity so a red max stands out from a gray
low:
case "$effort" in
low) ec=$GRAY ;;
medium) ec=$GREEN ;;
high) ec=$YELLOW ;;
xhigh|max) ec=$RED ;;
esac
Path and task. The current directory with $HOME collapsed to ~, and the
session's name (truncated so one long title can't eat the whole line).
The result is one dense, glanceable line: run-state, task, path, model, effort, org, context, session, week. Color does the heavy lifting — I don't read the status line so much as notice when part of it turns red.
The data that isn't in stdin
Two things I wanted weren't in the JSON. Both are solvable, and they're the most interesting part of the setup.
"Is it running?" — tracked with hooks
The stdin blob describes the session, but it doesn't say whether Claude is actively
working right now. So I track that myself with two hooks in settings.json. When I
submit a prompt, a UserPromptSubmit hook writes "running" to a temp file keyed by
session id; when Claude finishes, a Stop hook writes "idle":
"UserPromptSubmit": [{ "hooks": [{ "type": "command",
"command": "sid=$(cat | jq -r '.session_id'); echo running > /tmp/claude-sl-$sid.state" }]}],
"Stop": [{ "hooks": [{ "type": "command",
"command": "sid=$(cat | jq -r '.session_id'); echo idle > /tmp/claude-sl-$sid.state" }]}]
The status line then just reads that file:
state="idle"
[ -f "/tmp/claude-sl-$sid.state" ] && state=$(cat "/tmp/claude-sl-$sid.state")
A green ● running or a gray ✓ idle at the front of the line. Now a glance tells
me whether it's my turn or Claude's — even from across the room. The lesson
generalizes: if the data isn't in stdin, a hook can stash it somewhere your script
can read.
Which account — read from the account config
The organization name isn't in stdin either, but Claude Code stores it in a config
file. I read it from there — and, crucially, I respect CLAUDE_CONFIG_DIR so that a
second account resolves to its own config instead of the default one:
config_dir="${CLAUDE_CONFIG_DIR:-$HOME}"
org=$(jq -r '.oauthAccount.organizationName' "$config_dir/.claude.json")
That one line is what makes the status line honest when you run multiple accounts: the personal terminal shows the personal org, the work terminal shows the work org. No more guessing which login you're on.
A note on refresh and speed
refreshInterval controls how often your script runs — I use 2 seconds. Because it
runs that often, keep the script fast. Mine only does string formatting and a
couple of jq reads on data it already has; it makes no network calls. A slow status
line makes the whole terminal feel sluggish, so resist the urge to shell out to
anything heavy in there.
Wrapping up
The default status line assumes you don't need to know much. On a long session, you do — how full your context is, how close you are to a rate limit, which model and account you're driving, and whether it's your turn to type. All of it is one script and a JSON blob away.
Start small: print the context percentage with a color threshold. That alone pays for itself the first time it turns red before you hit the wall. Then add the pieces that match how you work — rate limits if you live near your caps, the account name if you juggle logins, a run-state indicator if you tab away a lot. The status line is cheap to change, so tune it until a single glance tells you everything you were otherwise finding out the hard way.
Comments
Be the first to comment.