Docs / Guides / Customize the Claude Code status line
Guides
Customize the Claude Code status line
Replace the default status line with your own script — show the machine, project, model, context left, and usage — and have it shrink to fit the terminal.
The line at the bottom of a Claude Code session is configurable: you point it at a script, Claude Code hands that script a JSON description of the session on standard input, and whatever the script prints becomes the line. That is the whole contract — read some JSON, print a string.
It earns its keep once you run more than one session. In the multi-machine stack you might have agents going on three machines at once, and a glance at the status line should tell you which machine, which project, which model, and how much context and quota are left — without switching windows to check. The example below does exactly that, and shrinks itself when the terminal is narrow.
Point Claude Code at a script
Add a statusLine block to ~/.claude/settings.json (user scope) or a project’s
.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline.sh"
}
}
Two optional fields are worth knowing: padding (horizontal spacing) and refreshInterval (seconds
— refresh on a timer even while idle, useful for the countdowns below). Otherwise the line updates
on its own after each reply.
What the script gets on stdin
Claude Code passes one JSON object. These are the fields the example uses; the full schema (opens in new tab) has more (cost, git/worktree, PR, session name, and so on).
| Field | What it holds |
|---|---|
model.display_name | the model, for example Opus 4.8 |
workspace.current_dir (or cwd) | the working directory |
version | the Claude Code version |
effort.level | reasoning effort (low…max); may be absent |
context_window.remaining_percentage | context left; may be null early in a session |
rate_limits.five_hour.used_percentage / .resets_at | 5-hour usage window |
rate_limits.seven_day.used_percentage / .resets_at | 7-day usage window (Pro/Max; may be absent) |
Because several fields are conditionally present, read each one defensively and simply leave its
segment off when it is missing — that is what the helper g() below does.
A minimal version
Start small to see the contract work. This prints the model and the project folder:
#!/bin/bash
# Minimal Claude Code status line: model · project folder. Reads the JSON on stdin.
exec python3 -c '
import sys, json, os
d = json.load(sys.stdin)
cwd = d.get("workspace", {}).get("current_dir") or d.get("cwd", "")
model = d.get("model", {}).get("display_name", "")
print(f"{model} · {os.path.basename(cwd)}")
'
Save it as ~/.claude/statusline.sh, chmod +x it, point settings.json at it, and the next reply
shows Opus 4.8 · myapp.
The version we use
This is the same idea with five segments — machine:project, model + effort, context left, 7-day usage (with time until it resets), and 5-hour usage — colored by how much headroom is left, and built to shrink. The one genuinely useful trick is the last part: each segment carries a priority, and when the assembled line is wider than the terminal, the lowest-priority segments drop off until it fits. The machine and project never drop, so even in a tiny pane you still know where you are.
#!/bin/bash
# Claude Code status line: machine:project · model effort · ctx% · 7d%(reset) · 5h%
# Colors go green->yellow->red as context/quota run low. A per-machine color tells
# sessions on different machines apart at a glance. If the line is wider than $COLUMNS,
# the lowest-priority segment is dropped until it fits; machine:project always survives.
# Reads Claude Code's JSON status context on stdin.
exec python3 -c '
import sys, json, os, time
try:
d = json.load(sys.stdin)
except Exception:
d = {}
def g(*ks): # safe nested lookup: g("rate_limits","five_hour","used_percentage")
cur = d
for k in ks:
if not isinstance(cur, dict): return None
cur = cur.get(k)
return cur
cwd = g("workspace","current_dir") or d.get("cwd") or os.getcwd()
proj = os.path.basename(cwd.rstrip("/")) or cwd
host = os.uname().nodename.split(".")[0]
mc = 17 + (sum(ord(c) for c in host) % 214) # stable per-machine 256-color
model = g("model","display_name") or ""
effort = g("effort","level") or ""
ctxL = g("context_window","remaining_percentage")
d7 = g("rate_limits","seven_day","used_percentage")
d7at = g("rate_limits","seven_day","resets_at")
d5 = g("rate_limits","five_hour","used_percentage")
def cdown(ts): # "2h" / "15m" until a reset timestamp, or "" if none
if not ts: return ""
s = int(ts - time.time())
if s <= 0: return ""
if s >= 3600: return f"{s//3600}h"
return f"{max(1, s//60)}m"
def col(code, s): return f"\033[{code}m{s}\033[0m"
DIM = "2;37"; SEP = " · "
segs = [] # each: [priority, plain_text, colored_text]; higher priority kept longer
def add(prio, text, code): segs.append([prio, text, col(code, text)])
add(100, f"{host}:{proj}", f"1;38;5;{mc}") # never dropped
me = f"{model} {effort}".strip()
if me: add(20, me, DIM)
if ctxL is not None:
c = "38;5;46" if ctxL > 50 else "38;5;226" if ctxL > 20 else "38;5;196"
add(50, f"ctx {ctxL:.0f}%", c)
if d7 is not None:
c = "38;5;46" if d7 < 70 else "38;5;226" if d7 < 90 else "38;5;196"
r = cdown(d7at)
add(30, f"7d {d7:.0f}%" + (f" {r}" if r else ""), c)
if d5 is not None:
add(10, f"5h {d5:.0f}%", DIM)
# Responsive: Claude Code sets $COLUMNS (v2.1.153+). While the joined line is wider than
# the terminal, drop the lowest-priority segment (5h -> effort -> 7d) until it fits.
try: cols = int(os.environ.get("COLUMNS", "0"))
except ValueError: cols = 0
def width(ss): return sum(len(s[1]) for s in ss) + len(SEP)*(len(ss)-1) if ss else 0
while cols > 0 and len(segs) > 1 and width(segs) > cols:
segs.pop(max(range(len(segs)), key=lambda i: (-segs[i][0], i))) # lowest prio, rightmost on ties
sys.stdout.write(col(DIM, SEP).join(s[2] for s in segs))
'
A wide terminal shows the whole line — laptop:myapp · Opus xhigh · ctx 78% · 7d 41% 2h · 5h 14%.
Narrow it, and the trailing pieces fall away in priority order while laptop:myapp stays put.
Four things that will bite you
- Width comes from
$COLUMNS, nottput. Claude Code sets$COLUMNS(and$LINES) for the script as of v2.1.153;tput colsreads the wrong thing here because the script has no terminal. - Keep it fast. The line runs after every reply (debounced ~300 ms), so a slow script makes the
whole session feel sluggish. Never block on the network. If you want a “an update is available”
marker, run that check detached and throttle it — for example, refresh at most once every two
hours via a timestamp file — and just read the cached result here. A
git statuson a large repo can also lag; cache it keyed bysession_id. - The apostrophe trap. The example embeds Python with
python3 -c '...'inside single quotes, so the program must contain no'characters — note that every dict key uses double quotes. If you need apostrophes, put the Python in its own.pyfile and call that instead. - Read fields defensively.
effortandrate_limitscan be absent, and the context percentages can be null before the first API call — theg()helper returnsNoneand the segment is skipped, rather than crashing the line.
Where to go next
- The agent this configures: Claude Code .
- Run several at once, where the per-machine color pays off: Run agents on one machine .
Source: content/guides/customize-the-status-line.md · maintained in the nuilab-agenticai repository.