How-toAI

Your Claude Code status line is a script. Don't freeze it.

Configure a Claude Code status line: JSON on stdin, settings.json, and the fields that matter. A builder copies a prompt, not a frozen script.

Manuel Hedinger
8 min read

I compact too late. I notice the five-hour limit when the next reply is a paywall. I forget which model I switched to three messages ago. All of that is already in the session. Claude Code just doesn't put it where I look.

That's what the status line is for.

The short answer

The status line is a row above the built-in footer badges. Claude Code runs a command, feeds it the current session as JSON on stdin, and renders whatever the command prints to stdout. It does not spend API tokens. It does not replace the badges. It does hide most of the keyboard hints — esc to interrupt, ? for shortcuts, the hold-space-to-speak prompt — so if you live on those, that is the trade.

You get there three ways. Describe the line with /statusline and let Claude write the script into ~/.claude/. Put a statusLine object in ~/.claude/settings.json yourself. Or compose the layout in the status line builder and paste the prompt. The builder never emits a script. A script published in a blog post is stale the next time Anthropic adds a field. A prompt is read against the docs Claude Code already has.

The official walkthrough is Customize your status line. What follows is the version I wish I'd had: what to show, what to leave alone, and the bits that fail silently.

You can get a status line three ways: describe it, write settings.json, or compose a prompt in the builder.

Ask /statusline

Type something like:

/statusline show model name and context percentage with a progress bar

Claude generates a script under ~/.claude/ and writes the settings for you. Approve the file-edit prompts if it asks. This is the right first move if you have never had a status line and don't care how it works yet.

To take it out later, /statusline delete (or clear, or remove it) works. Or delete the statusLine field from settings.json yourself.

Write the settings

User settings live in ~/.claude/settings.json. Project settings work too. The shape is small:

{
  "statusLine": {
    "type": "command",
    "command": "~/.claude/statusline.sh",
    "padding": 2
  }
}

type is "command". command is a script path or an inline shell snippet. Settings reload on save; you don't restart Claude Code.

padding is extra horizontal space in characters, on top of the built-in gutter. Default 0.

refreshInterval re-runs the command every N seconds on top of the event-driven updates. Minimum 1. Set it for a clock, or when background subagents change git state while the main session sits idle. Leave it unset if you only care about replies.

hideVimModeIndicator hides the built-in -- INSERT -- under the prompt. Set it when your script already prints vim.mode, so you don't see the mode twice.

The command runs in a shell, so a one-liner is enough for a first check:

{
  "statusLine": {
    "type": "command",
    "command": "jq -r '\"[\\(.model.display_name)] \\(.context_window.used_percentage // 0)% context\"'"
  }
}

You will need jq. On Windows, write the path with forward slashes (C:/Users/you/.claude/statusline.sh). Git Bash treats backslashes as escapes and the command fails with nothing on screen.

Compose it, then let Claude write it

I don't keep a gist of statusline.sh. The stdin JSON grows. Last year's script doesn't know about rate_limits, or pr.kind for GitLab, or COLUMNS. The follow-up I actually want is "now make the bar wider" or "add the open PR".

That's why the status line builder copies a prompt, not a file. You drag the segments you want — directory, branch, model, context, the 5-hour and 7-day limits, cost, a clock, lines changed, the PR, the worktree — pick a palette, and paste the English prompt into a session. Claude writes the script, points settings.json at it, and is still there for the next tweak.

It runs in the browser. Nothing you arrange is uploaded.

How the data moves

Claude Code serializes the live session and pipes it in. Your command prints a line. That line is the row.

Claude Code writes the session as JSON, your command prints a line, the row appears above the footer. No tokens.

The command runs once when a session starts, including a resume. After that it runs again when:

  • a new assistant message arrives
  • /compact finishes
  • the permission mode changes
  • vim mode toggles
  • you change command in settings
  • a refreshInterval timer fires, if you set one

Updates are debounced at 300ms. A change to command itself skips the debounce and runs immediately. If a new update arrives while the script is still running, the in-flight run is cancelled. Edit the script on disk and the next trigger picks it up; there is no extra reload.

Event-driven updates go quiet when the main session is idle, for example while a coordinator waits on background subagents. That's the case for refreshInterval.

You can print several lines. You can use ANSI colours. You can wrap text in OSC 8 sequences to make it clickable (Cmd-click on macOS, Ctrl-click elsewhere) in iTerm2, Kitty or WezTerm. Terminal.app does not do clickable links. If the sequences show up as literal \e]8;;, use printf '%b', not echo -e.

Do not call tput cols inside the script. Claude Code captures stdout instead of attaching you to the terminal, so width detection from inside the process is blind. Read COLUMNS and LINES. They are set before the command runs, from version 2.1.153.

The row hides during autocomplete, the help menu and permission prompts. Outside fullscreen, notifications share the same row and will truncate you on a narrow terminal.

What is worth showing

The JSON is large. Most of it is noise on an 80-column row.

Context. context_window.used_percentage is the field to use. It is calculated from input tokens only (input_tokens + cache_creation_input_tokens + cache_read_input_tokens). Output tokens are not in it. If you compute the percentage yourself from current_usage, use the same formula or you will disagree with /context. current_usage is null before the first API call, and again after /compact until the next reply fills it. Same for the percentage fields early in a session. Fallback with // 0 in jq.

Default window is 200k tokens, or 1M on extended-context models. exceeds_200k_tokens is a fixed threshold, not "the window is full".

Cost. cost.total_cost_usd is a client-side estimate. It is not your bill. It resets to $0 on /clear (before v2.1.211 it carried over, which was confusing). total_duration_ms is wall-clock since the session started; total_api_duration_ms is time spent waiting on the API.

Rate limits. rate_limits.five_hour and rate_limits.seven_day exist for Claude.ai Pro/Max after the first API response. Each window may be missing on its own. Treat absence as "don't draw the segment", not as 0%.

Git. There is no git.branch in the JSON. You run git yourself. That is slow in a large repo, and the script runs often, so cache it. Use session_id in the cache filename, not $$ or os.getpid(): those change on every invocation and the cache never hits. Concurrent sessions in different repos must not share a file.

workspace.git_worktree is set when you're in a linked worktree. worktree.* is a different object, present only during a Claude Code worktree session.

The rest I actually use. model.display_name plus effort.level. workspace.current_dir (same value as cwd; prefer the nested field). pr.number and pr.review_state when a PR or GitLab merge request is open on the branch. session_name if you named the session; the default my-app-3f style name does not populate it. vim.mode if you use vim mode.

A field that is absent is not the same as a field that is null. Handle both.

A first script

Bash, on macOS and Linux. Make it executable (chmod +x ~/.claude/statusline.sh) and point command at it.

#!/bin/bash
input=$(cat)
 
MODEL=$(echo "$input" | jq -r '.model.display_name')
DIR=$(echo "$input" | jq -r '.workspace.current_dir')
PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
 
echo "[$MODEL] ${DIR##*/} | ${PCT}% context"

Test it without opening Claude Code:

echo '{"model":{"display_name":"Opus"},"workspace":{"current_dir":"/home/user/project"},"context_window":{"used_percentage":25},"session_id":"test"}' | ./statusline.sh

Keep the printed line short. The bar is not wide, and on a narrow terminal the notifications on the right will eat it.

When it stays blank

The usual list, in the order I check:

  1. Is the script executable, and does it print to stdout rather than stderr?
  2. Did you accept the workspace-trust dialog? Until you do, the row stays empty and claude --debug logs that the command was skipped.
  3. On Windows, did a backslash in the path get eaten by Git Bash?
  4. Are you looking at nulls before the first reply? Fallbacks, then wait one turn.
  5. Did someone set disableAllHooks? Outside managed settings that disables a user status line. allowManagedHooksOnly in org-managed settings silently ignores yours.

claude --debug logs the exit code and stderr of the first invocation in a session. Asking Claude to run the statusLine command against your settings file also surfaces the error faster than staring at a blank row.

If you see -- or empty values after several messages, restart once. If OSC 8 links render as text, check the terminal, then try FORCE_HYPERLINK=1 claude.

What I actually run

Three rows. Directory and branch on the left, a clock on the right. Model on the left of row two, context on the right. The 5-hour limit on row three, the 7-day limit opposite. That is the "Mine" preset in the builder. I care about compacting before the window is full, and about the weekly cap before Friday afternoon.

I still don't check in the script. When I want a different segment I paste a new prompt. The builder is where I decide the layout. Claude Code writes the file.

If you want the footer to grow clickable badges when an ID shows up in the conversation, that is a different setting: footerLinksRegexes. No script involved.

If a status line is fighting you and you would rather not spend the evening on ANSI codes, write to me. Sometimes the answer is a 40-character jq line.