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.

Updated Jun 26, 2026

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).

FieldWhat it holds
model.display_namethe model, for example Opus 4.8
workspace.current_dir (or cwd)the working directory
versionthe Claude Code version
effort.levelreasoning effort (lowmax); may be absent
context_window.remaining_percentagecontext left; may be null early in a session
rate_limits.five_hour.used_percentage / .resets_at5-hour usage window
rate_limits.seven_day.used_percentage / .resets_at7-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, not tput. Claude Code sets $COLUMNS (and $LINES) for the script as of v2.1.153; tput cols reads 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 status on a large repo can also lag; cache it keyed by session_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 .py file and call that instead.
  • Read fields defensively. effort and rate_limits can be absent, and the context percentages can be null before the first API call — the g() helper returns None and the segment is skipped, rather than crashing the line.

Where to go next

Source: content/guides/customize-the-status-line.md · maintained in the nuilab-agenticai repository.