After a long Claude Code session, the questions I keep asking myself are mundane but real: which model am I running right now, how much context is left before the next compact, and what branch am I on? Claude Code has a feature designed exactly for this — the status line that sits below the prompt — but the default presentation is so understated that many people scroll past the setting without realizing how much it can do.
I split my time across four properties and constantly toggle between Sonnet and Opus, so an unmissable model indicator turns out to be more than cosmetic — it keeps me from quietly burning Opus pricing on conversational chitchat. In this article I want to share the recipes I've actually settled on: what's worth showing in the status line, and how to write the script so it never breaks the conversation. Both Bash and Node.js versions are included.
What the status line actually is
Claude Code's status line is, mechanically, very simple: you point it at any executable, and Claude Code calls it with session metadata as JSON on stdin. Whatever your script writes to stdout becomes the rendered line — including ANSI escape codes for color and weight. There's no plugin API to learn; you just write a one-shot program that reads JSON in and prints a string out.
You wire it up by adding a statusLine block to ~/.claude/settings.json (or a project-local .claude/settings.json):
{
"statusLine": {
"type": "command",
"command": "/Users/you/.claude/statusline.sh",
"padding": 0
}
}type is currently always command, the command itself should be an absolute path, and padding: 0 gives your script full control over surrounding whitespace.
Why bother customizing it at all
This sounds like the dotfile-fiddler territory of "make the prompt fancier," but in practice I found three concrete reasons it pays off.
The first is avoiding accidental model spend. If you flip between Opus for hard reasoning tasks and Sonnet for everything else, an Opus-shaped bill at the end of the month is a nasty surprise. A persistent on-screen reminder of which model is loaded almost completely eliminates the "wait, was I still on Opus?" moment. The second is earlier signals on context exhaustion: somewhere past 80% used, conversations get noticeably wobbly, and a percent indicator on the status line is much easier to glance at than counting tokens manually. The third is branch awareness, which matters a lot if you work in multiple worktrees of the same repo.
If none of these apply, a heavy custom status line can actually backfire. Too many widgets pull your eyes away from the conversation itself, which is the thing you actually want to focus on.
The minimum useful Bash recipe
Let's start with a single Bash file that has no external dependencies beyond jq. This is the fastest path to a working setup.
#!/usr/bin/env bash
# ~/.claude/statusline.sh
# Called by Claude Code; reads session JSON from stdin, writes one line to stdout.
set -eu
# 1) Read the JSON Claude Code feeds us
INPUT="$(cat)"
# 2) Pull out only the fields we need (use the Node.js version if jq is unavailable)
MODEL_ID="$(echo "$INPUT" | jq -r '.model.id // "unknown"')"
MODEL_NAME="$(echo "$INPUT" | jq -r '.model.display_name // .model.id // "unknown"')"
CTX_USED="$(echo "$INPUT" | jq -r '.context.input_tokens // 0')"
CTX_LIMIT="$(echo "$INPUT" | jq -r '.context.limit // 200000')"
CWD="$(echo "$INPUT" | jq -r '.workspace.current_dir // "."')"
# 3) Compute remaining context as a percentage
if [ "$CTX_LIMIT" -gt 0 ]; then
USED_PCT=$(( (CTX_USED * 100) / CTX_LIMIT ))
else
USED_PCT=0
fi
REMAIN_PCT=$(( 100 - USED_PCT ))
# 4) Current git branch (silently skipped if not available)
BRANCH=""
if command -v git >/dev/null 2>&1; then
BRANCH="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
fi
# 5) Decorate with ANSI and emit a single line
RESET=$'\033[0m'
DIM=$'\033[2m'
CYAN=$'\033[36m'
GREEN=$'\033[32m'
YELLOW=$'\033[33m'
RED=$'\033[31m'
# Color the percentage by remaining (red < 30%, yellow < 60%, otherwise green)
if [ "$REMAIN_PCT" -lt 30 ]; then COLOR="$RED";
elif [ "$REMAIN_PCT" -lt 60 ]; then COLOR="$YELLOW";
else COLOR="$GREEN"; fi
LINE="${CYAN}${MODEL_NAME}${RESET}"
LINE="${LINE} ${DIM}|${RESET} ctx ${COLOR}${REMAIN_PCT}%${RESET}"
[ -n "$BRANCH" ] && LINE="${LINE} ${DIM}|${RESET} ${BRANCH}"
printf '%s' "$LINE"Make it executable with chmod +x ~/.claude/statusline.sh, point settings.json at it, and you'll see a line like this:
Sonnet 4.5 | ctx 73% | feature/auth-refactor
In my experience, showing the percentage rather than the raw token count is what made this stick. A number like 47,231 doesn't trigger any reflexes, but a percentage maps directly to "I should probably /compact soon."
The Node.js version, for environments without jq
On corporate-issued machines and CI runners I sometimes don't have jq available, and adding it is a hassle. Node.js 18+ ships with everything needed, so a self-contained Node script is often easier to deploy.
#!/usr/bin/env node
// ~/.claude/statusline.mjs
// Reads session JSON from stdin and prints a single decorated line to stdout.
import { execSync } from "node:child_process";
async function main() {
// Drain stdin
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
let input = {};
try {
input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
} catch {
// Empty or malformed input: emit nothing rather than corrupt the bar
process.stdout.write("");
return;
}
const modelName = input?.model?.display_name || input?.model?.id || "unknown";
const used = Number(input?.context?.input_tokens || 0);
const limit = Number(input?.context?.limit || 200000);
const cwd = input?.workspace?.current_dir || process.cwd();
const usedPct = limit > 0 ? Math.floor((used * 100) / limit) : 0;
const remainPct = 100 - usedPct;
// Try git branch — never let a failure crash the script
let branch = "";
try {
branch = execSync("git symbolic-ref --short -q HEAD", {
cwd,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim();
} catch {
branch = "";
}
const c = (code, s) => `\x1b[${code}m${s}\x1b[0m`;
const cyan = (s) => c(36, s);
const dim = (s) => c(2, s);
const color =
remainPct < 30 ? 31 : remainPct < 60 ? 33 : 32; // red / yellow / green
let line = `${cyan(modelName)} ${dim("|")} ctx ${c(color, `${remainPct}%`)}`;
if (branch) line += ` ${dim("|")} ${branch}`;
process.stdout.write(line);
}
main().catch(() => {
// Swallow even unexpected failures so the status line never breaks rendering
process.stdout.write("");
});In settings.json, set command to something like node /Users/you/.claude/statusline.mjs. If you want to lean on the shebang, chmod +x the file and pass the absolute path directly.
Never let a failing script disturb the conversation
The detail that's easy to overlook in a recipe like this: a status-line script that fails should never propagate noise into Claude Code itself. Claude Code is pretty forgiving about non-zero exits and stderr, but a hung process or a slow external call will visibly stall the rendering, and that will feel like the editor itself is slow.
There are four habits I always keep in my scripts. First, wrap every external command in a try-style guard — your script should still emit a line on machines without git. Second, return an empty string on JSON parse failure instead of throwing; the very first invocation of a session sometimes carries an empty payload. Third, don't call remote APIs. A "current Anthropic credit" widget sounds nice until network latency makes the bar feel sluggish; have a separate cron-style script update a local file and just read that. Fourth, stay under a 50ms budget. Past that, the rendering starts to feel jittery in a way that's hard to articulate but easy to feel.
A small ASCII bar makes remaining context land faster
Once the percentage version becomes second nature, you may want a more visual cue. A 10-cell bar reads instantly without parsing digits.
# Convert a percentage into a 10-segment block bar
make_bar() {
local pct="$1"
local filled=$(( pct / 10 ))
local empty=$(( 10 - filled ))
local bar=""
for ((i=0; i<filled; i++)); do bar="${bar}█"; done
for ((i=0; i<empty; i++)); do bar="${bar}░"; done
printf '%s' "$bar"
}
BAR="$(make_bar "$REMAIN_PCT")"
LINE="${LINE} ${DIM}[${RESET}${COLOR}${BAR}${RESET}${DIM}]${RESET}"At 73% remaining you'd see something like ███████░░░. The compact decision becomes a glance instead of a calculation, which matters more than it sounds when you're deep in a long session.
Project-level overrides for sensitive repos
settings.json resolves in two layers: user-wide (~/.claude/settings.json) and per-project (<repo>/.claude/settings.json), with the project file winning when both are present. That makes it easy to install a louder status line in repos that need extra care.
The pattern I use is to add an environment-aware status line only in repositories that touch production. The script reads a project-level env var (something like CLAUDE_ENV) and tints the bar red whenever it sees prod, which has saved me a couple of "wait, am I about to do this on prod?" near-misses.
# ~/projects/your-app/.claude/settings.json (project-local)
{
"statusLine": {
"type": "command",
"command": "/Users/you/.claude/statusline-prod-aware.sh",
"padding": 0
}
}The principle here is keep the global default quiet, and let the projects that need attention shout. If every repo has a flashy status line, no status line is doing useful signaling anymore.
Pitfalls worth knowing about
Three pitfalls catch people out regularly when they first wire this up.
The first is path expansion. Setting command to ~/.claude/statusline.sh looks reasonable, but Claude Code does not always expand the tilde, and the script will silently fail to start. Use a full absolute path like /Users/you/.claude/statusline.sh.
The second is trailing newlines. A naive echo appends a newline that can break the inline rendering. Use printf '%s' in shell or process.stdout.write in Node, and never call a function that adds \n for you.
The third is lines that are too long. On narrow terminals, an over-long status line wraps to two rows and the layout collapses. Stay under 50–70 characters, and if a particular field could grow long (a long branch name, for instance), truncate with an ellipsis.
A 30-minute investment here repays itself surprisingly quickly across long sessions. My suggestion is to drop the Bash version above into ~/.claude/statusline.sh as-is, point settings.json at it, and then add fields one at a time as you notice yourself wanting them. That trial-and-adjust loop is what gets you to a layout you actually trust at a glance — far more reliably than designing the perfect line up front.