A Claude Code status line that tells you when the diff is too big to review
Fifteen minutes to a two-line status bar showing unreviewed lines, the prompt-cache clock and how much context is left, so you stop the agent before the diff gets out of hand.

Somewhere around the third hour of a good agent session, the diff stops being something you can review. It started as a three-file change. Now it’s forty files, one of them renamed twice, and the pull request you were going to open at lunch needs an afternoon of reading before anyone else sees it. Nothing went wrong at any single step. Nobody was watching the number.
Claude Code already has a place to show that number: the status line, a bar at the bottom of the terminal that runs any script you give it and prints the result. This Playbook builds a two-line one. The first line shows the branch, the open pull request and how many lines are sitting uncommitted. The second shows context used, how long the prompt cache stays warm and the session cost. It turns amber and then red as the diff grows, and past a threshold it tells you to commit a slice.
It’s one Python file and one settings entry. It runs locally and, per the docs, doesn’t consume API tokens. Setup takes about ten minutes, and the rest of this piece is why each signal is on the bar and how it breaks.
What goes on the bar, and why
A status line is cheap, so the temptation is to cram it. Resist that. Each item here earns its place by prompting a specific action.
Uncommitted lines changed is the one that matters most. It’s the closest cheap proxy for “how much would a reviewer have to read if I opened a PR now”. An agent doesn’t have to read its own diff, so nothing in its loop pushes back when the change grows. When the number goes red, the move is to stop, commit what’s coherent and carry on in slices. That’s opinion, and the thresholds are yours to tune: they’re environment variables in the script below.
Branch, with main in red. Agents are quite happy to edit on whatever branch you happened to leave checked out. A red main on a line you glance at every prompt is a better guard than remembering.
The pull request and its review state. Claude Code puts pr.number and pr.review_state (one of approved, pending, changes_requested or draft) into the JSON when the current branch has an open PR, so the bar can tell you that the branch you’re piling more changes onto is already in review.
The prompt-cache clock. Claude Code reports prompt_cache.warm and prompt_cache.expires_at (epoch seconds). While the cache is warm, the next turn reads your long conversation cheaply. When it goes cold, the next request re-caches the whole thing, and recache_tokens_if_cold tells you how large that is. Seeing “cache warm 4m” before you wander off for a coffee is more useful than learning about it on the bill. For why this matters over long sessions, see the idle compaction piece
.
Context percentage and cost. context_window.used_percentage is calculated from input tokens only, and cost.total_cost_usd is estimated client-side at list price, so it can differ from your actual bill. Both are good enough for steering and neither is an invoice.
Build it
1. Save the script
Save this as ~/.claude/stopline.py. It uses only the Python standard library and git.
#!/usr/bin/env python3
"""Status line: branch, uncommitted diff size, prompt-cache clock, context, cost."""
import json, os, subprocess, sys, tempfile, time
WARN = int(os.environ.get("STOPLINE_WARN", "250")) # lines changed: amber
STOP = int(os.environ.get("STOPLINE_STOP", "500")) # lines changed: red
PROTECTED = {"main", "master"}
NOISE = [":(exclude,glob)**/*.lock", ":(exclude,glob)**/package-lock.json",
":(exclude,glob)**/pnpm-lock.yaml"]
RED, AMBER, GREEN, DIM, OFF = "\033[31m", "\033[33m", "\033[32m", "\033[2m", "\033[0m"
def git(cwd, *args):
return subprocess.run(["git", "-C", cwd, *args], capture_output=True,
text=True, timeout=3).stdout
def git_state(cwd, session_id):
"""Branch and diff size, cached for 5 seconds per session."""
cache = os.path.join(tempfile.gettempdir(), f"stopline-{session_id}")
if os.path.exists(cache) and time.time() - os.path.getmtime(cache) < 5:
return json.load(open(cache))
state = {"branch": git(cwd, "branch", "--show-current").strip(), "lines": 0, "new": 0}
if state["branch"] or git(cwd, "rev-parse", "--git-dir"):
for row in git(cwd, "diff", "HEAD", "--numstat", "--", ".", *NOISE).splitlines():
added, removed, _ = row.split("\t", 2)
if added != "-":
state["lines"] += int(added) + int(removed)
state["new"] = len(git(cwd, "ls-files", "--others", "--exclude-standard").splitlines())
json.dump(state, open(cache, "w"))
return state
def main():
data = json.load(sys.stdin)
cwd = data["workspace"]["current_dir"]
sid = data.get("session_id", "none")
try:
g = git_state(cwd, sid)
except Exception:
g = {"branch": "", "lines": 0, "new": 0}
# Line 1: where am I, and how big is the unreviewed diff?
parts = []
if g["branch"]:
parts.append(f"{RED}{g['branch']}{OFF}" if g["branch"] in PROTECTED else g["branch"])
pr = data.get("pr")
if pr:
parts.append(f"PR #{pr['number']} {pr.get('review_state', '')}".strip())
if g["branch"]:
n = g["lines"]
colour = RED if n >= STOP else AMBER if n >= WARN else GREEN
hint = " commit a slice" if n >= STOP else ""
parts.append(f"{colour}{n} lines{OFF}, {g['new']} new files{hint}")
print(" | ".join(parts) or "no git")
# Line 2: how much runway is left?
ctx = data.get("context_window", {}).get("used_percentage") or 0
out = [f"ctx {ctx:.0f}%"]
cache = data.get("prompt_cache")
if cache and cache.get("caching_observed"):
left = (cache.get("expires_at") or 0) - time.time()
if cache.get("warm") and left > 0:
out.append(f"cache warm {int(left // 60)}m")
else:
cold = cache.get("recache_tokens_if_cold")
out.append(f"{AMBER}cache cold{OFF}" + (f" ({cold // 1000}k to rebuild)" if cold else ""))
cost = data.get("cost", {}).get("total_cost_usd")
if cost is not None:
out.append(f"${cost:.2f}")
print(f"{DIM}{' | '.join(out)}{OFF}")
main()
Some details are deliberate. Lockfiles are excluded from the line count with git’s :(exclude,glob) pathspecs, because a regenerated package-lock.json can add thousands of lines that nobody reviews line by line. Binary files show - in --numstat and are skipped. Untracked files don’t appear in git diff, so they’re counted separately and shown as “new files” rather than lines.
Git runs at most once every five seconds per session. The docs recommend this pattern for the same reason: the script runs on every update, and a slow git diff in a large repository makes the bar lag. The cache filename uses the session id rather than a process id, which changes on every invocation and would defeat the cache.
2. Make it executable and register it
chmod +x ~/.claude/stopline.py
Add this to ~/.claude/settings.json, merging with whatever is already there:
{
"statusLine": {
"type": "command",
"command": "python3 ~/.claude/stopline.py",
"refreshInterval": 60
}
}
refreshInterval re-runs the command every N seconds on top of the event-driven updates (a new assistant message, /compact finishing, a permission mode change, a vim mode toggle). The docs say the cache expiry also triggers a run when a warm cache reaches its expires_at. The one-minute timer keeps “cache warm 40m” counting down while you’re reading rather than sitting on a stale number.
Claude Code reloads settings automatically, so the bar appears as soon as you save. If it doesn’t, see “What goes wrong” below.
3. Test it without a session
The docs suggest piping mock JSON into the script before trusting it. Make a throwaway repository and feed it a realistic payload:
git init -q -b main demo && cd demo
seq 1 100 > a.txt && echo '{}' > package-lock.json
git add . && git commit -qm init && git checkout -qb feature/x
jq -n --arg d "$PWD" --argjson exp $(( $(date +%s) + 2460 )) '{
session_id: "t1",
workspace: {current_dir: $d},
context_window: {used_percentage: 41.3},
cost: {total_cost_usd: 2.4871},
pr: {number: 57, review_state: "pending"},
prompt_cache: {caching_observed: true, warm: true,
expires_at: $exp, recache_tokens_if_cold: 312000}
}' > in.json
python3 ~/.claude/stopline.py < in.json
On a clean branch it prints (colour codes stripped here):
feature/x | PR #57 pending | 0 lines, 0 new files
ctx 41% | cache warm 40m | $2.49
Now make the kind of change an agent makes in one long turn: rewrite a file, regenerate the lockfile, add two new files.
seq 1 400 > a.txt; echo '{"a":1}' > package-lock.json; touch new1.ts new2.ts
python3 ~/.claude/stopline.py < in.json
feature/x | PR #57 pending | 300 lines, 2 new files
ctx 41% | cache warm 40m | $2.49
300 lines, in amber. The lockfile change isn’t counted. Keep going until the rewrite reaches 900 lines and flip warm to false in the payload:
feature/x | PR #57 pending | 800 lines, 2 new files commit a slice
ctx 41% | cache cold (312k to rebuild) | $2.49
The 800 is red, and the cache note is amber. Check out main and the branch name turns red too. Outside a git repository the first line says no git, and a payload with no prompt_cache and no cost prints just the context figure.
A run takes about 30 to 40 milliseconds on a small repository, uncached included.
Using it day to day
The bar gives you cues. Here’s what to do with each.
When the line goes amber (250 lines by default), look at what the agent has done so far and decide whether it’s still one coherent change. If it is, carry on. If it has drifted into a second concern, this is the moment to say so, while the two are still easy to pull apart.
When it goes red (500), stop prompting for new work. Ask the agent to commit in slices:
Stop adding features. Look at the uncommitted changes and split them into
commits that each build and pass tests on their own, in the order a reviewer
would want to read them. Show me the plan before you commit anything.
Asking for the plan first matters. An agent told to “commit this” will often produce one commit called “implement feature”. If you want an enforced gate rather than a nudge, pair this with the pre-commit setup in the commit gate Playbook , and consider stacking the slices as separate PRs, which fit agent-sized diffs well .
When the cache note flips to cold, the next prompt costs more than the ones before it. If you’re about to step away, that’s fine. If you’re mid-task and you only paused to read, it’s a reminder that long pauses have a price. Newer Claude Code versions will also compact an idle 200K+ token conversation before its one-hour cache expires (added in 2.1.299), which changes the maths for long breaks, so check your own version’s behaviour.
When context passes roughly 60 to 70 percent on a task that isn’t nearly finished, that’s the time to commit, write a short handover and start a fresh session rather than let auto-compaction decide what to forget. The exact threshold is a judgement call; the point is to see it coming.
Make it yours
Different teams will want different knobs.
Thresholds. STOPLINE_WARN and STOPLINE_STOP are read from the environment, so export them in your shell profile. A service repo with tight PRs might use 150 and 300. A repo full of generated fixtures might need more exclusions in NOISE.
Windows. Claude Code runs status line commands through Git Bash if it’s installed, and through PowerShell otherwise. Per the docs, write paths in command with forward slashes, because Git Bash eats unquoted backslashes and the command fails without a visible error. The script uses tempfile.gettempdir(), so the cache works there. On most Windows machines the interpreter is python rather than python3:
{
"statusLine": {
"type": "command",
"command": "python C:/Users/username/.claude/stopline.py",
"refreshInterval": 60
}
}
That configuration is from the documented Windows behaviour and wasn’t run.
Subscription limits. rate_limits.five_hour.used_percentage and rate_limits.seven_day.used_percentage are present for Pro and Max subscribers after the first API response. Add them to line two with .get("rate_limits", {}) and treat each window as optional, because the docs say each can be absent on its own.
Sharing it. The statusLine setting also works in project settings, so a team can commit the same bar. The script then has to live somewhere every contributor has it, such as a path inside the repo. Keep in mind that a status line is a shell command, so it falls under the same workspace trust rule as hooks in settings files: until someone accepts the trust dialog for the folder, the bar stays blank.
Different signals. The JSON has more than this script reads: effort.level, fast_mode, worktree.name, workspace.git_worktree, prompt_cache.hit_ratio and prompt_cache.last_miss_cause. If you run several worktrees, the worktree name is worth a slot on line one. If your sessions feel expensive, hit_ratio and the last miss cause show whether something in your setup keeps invalidating the cache.
What goes wrong
The bar is blank. The status line only shows output after the script exits with code 0, and a non-zero exit or empty output blanks it. Run the script by hand with mock input first. claude --debug logs the script’s stderr on each invocation and its exit code on the first one in a session. Also check the workspace trust point above. If your organisation sets allowManagedHooksOnly in managed settings, a custom status line disappears without warning, and only one from managed settings is used.
Scripts that hang. Slow scripts block updates, and if a new update arrives while one is running, the in-flight script is cancelled. The timeout=3 on each git call is there for this. In a very large monorepo, git diff HEAD --numstat can still be slow enough that you’d want to raise the cache interval or limit the pathspec to the directories you actually work in.
Lines changed is a crude measure. A rename with edits can count as a large deletion plus a large addition. Reformatting a file counts in full. A 600-line diff that is all mechanical might be quicker to review than 80 lines of tangled logic. The number is a prompt to look, not a verdict.
Staged and unstaged changes are measured together, against HEAD, which is deliberate: the question is how much hasn’t been committed. The corollary is that after a commit the number drops to zero, which is the point.
Detached HEAD. The branch name comes from git branch --show-current, which is empty on a detached HEAD, so the script prints no git there. Check out a tag or a bare commit and the bar drops to no git.
Hidden at the worst moment. Per the docs, the status line temporarily hides during certain UI interactions, including permission prompts. It’s a glanceable cue between prompts, not a gate on a tool call. If you need something that blocks, use a hook.
The cost figure is not your bill. It’s computed client-side at list price and resets when /clear starts a new session.
Requirements. The prompt_cache object needs Claude Code v2.1.251 or later, and the script’s cache line is skipped quietly when the object is absent, for example on a provider that doesn’t report cache tokens (caching_observed is false).
When not to bother. If you already commit every few minutes and open PRs from short-lived branches, the diff meter will sit green all day and you’ll stop seeing it. The cache clock and context figure may still pay for themselves in long sessions. For a throwaway script or a spike you plan to discard, none of this is worth the screen row.
The first fifteen minutes
- Copy the script to
~/.claude/stopline.pyand runchmod +xon it. - Add the
statusLineblock to~/.claude/settings.json. - Pipe the mock payload from the test section through it and compare the output with the examples above.
- Open a session in a real repository and ask for a small change. Watch the first line move as files are edited.
- Lower
STOPLINE_WARNto something your reviewers would actually be happy with, and adopt one rule: when it’s red, nothing new gets prompted until the diff has been sliced into commits.