Fransys

Tech blog — Architecture, Cloud & DevOps

BlogServicesContactAbout

Follow me

githubGitHublinkedinLinkedinmailMail

© 2026 Fransys • Fransys

Fransys

Categories

  • All posts
  • Tags
  • productivity10
  • nas10
  • ai8
  • security7
  • self-hosting7
  • linux6
  • claude-code6
  • neovim5
  • docker5
  • editor4
  • networking4
  • mcp3
  • vpn3
  • lua2
  • terminal2
claude-codeaiproductivitybash

Claude Code status line: displaying real-time API usage

Published on
February 27, 2026·10 min read·Updated on March 9, 2026
Avatar François GUERLEZFrançois GUERLEZ

The problem: how much quota is actually left?

Claude Code is cool, but honestly? Zero visibility into my consumption. Start a session, chat for 30 minutes, then: DING! "Usage limit reached." Annoying.

I wanted real-time visibility. Not just a number - a real dashboard: current model, context used, 5h and 7d limits, time until reset. All in an elegant status line at the terminal bottom.

After weeks of experimenting, I built something that works. Here's how.

Architecture: dynamic status line

Claude Code has a feature called statusLine that runs a custom shell command. This command:

  1. Gets JSON on stdin (session, context, model)
  2. Calls the Anthropic API for real consumption
  3. Returns ANSI-colored text

Simple but powerful.

Configuration: enable the status line

First, in ~/.claude/settings.json:

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

What it does:

  • type: "command" : Runs a shell script (not static text)
  • command : Script path (make executable: chmod +x)
  • padding: 0 : No extra space (I like lots of text)

The script: ~/.claude/statusline.sh

Here's the complete script. Bash with smart caching:

#!/bin/bash

# Status line script for Claude Code
# Displays: model + context + real-time API usage
# Receives context data as JSON on stdin

CACHE_FILE="$HOME/.claude/usage_cache.json"
CACHE_TTL=600  # 10 minutes (the API has strict rate limits)

# ANSI colors
INDIGO="\033[38;5;54m"
CYAN="\033[38;5;51m"
VIOLET="\033[38;5;141m"
MAGENTA="\033[38;5;201m"
RESET="\033[0m"
BOLD="\033[1m"

# Read JSON data from stdin
input=$(cat)
model=$(echo "$input" | jq -r '.model.display_name // "Unknown"')
context_pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0')
work_dir=$(echo "$input" | jq -r '.workspace.current_dir // "~"' | xargs basename)

# Function: return color based on percentage
get_color() {
    local pct=$1
    if (( $(echo "$pct < 50" | bc -l) )); then
        echo -e "$CYAN"
    elif (( $(echo "$pct < 80" | bc -l) )); then
        echo -e "$VIOLET"
    else
        echo -e "$MAGENTA"
    fi
}

# Function: display progress bar (5 blocks)
progress_bar() {
    local pct=$1
    local blocks=5
    local filled=$(( pct / 20 ))
    [[ $filled -gt $blocks ]] && filled=$blocks

    local bar=""
    for (( i=0; i<filled; i++ )); do
        bar+="▰"
    done
    for (( i=filled; i<blocks; i++ )); do
        bar+="▱"
    done
    echo "$bar"
}

# Function: format time
format_time() {
    local seconds=$1
    local hours=$(( seconds / 3600 ))
    local minutes=$(( (seconds % 3600) / 60 ))
    echo "${hours}h${minutes}m"
}

# Function: fetch API usage
get_api_usage() {
    # Check cache (based on timestamp in the JSON)
    if [[ -f "$CACHE_FILE" ]]; then
        local cache_time=$(jq -r '.timestamp // 0' "$CACHE_FILE" 2>/dev/null)
        local cache_age=$(( $(date +%s) - cache_time ))
        if [[ $cache_age -lt $CACHE_TTL ]]; then
            cat "$CACHE_FILE"
            return 0
        fi
    fi

    # Call Anthropic API
    local token=$(jq -r '.claudeAiOauth.accessToken // empty' "$HOME/.claude/.credentials.json" 2>/dev/null)

    if [[ -z "$token" ]]; then
        echo '{"five_hour": 0, "seven_day": 0, "reset_time": ""}'
        return 1
    fi

    # Check HTTP status code to detect errors (429, 401, etc.)
    local http_response=$(curl -s -w "\n%{http_code}" --max-time 5 \
        -H "Authorization: Bearer $token" \
        -H "anthropic-beta: oauth-2025-04-20" \
        "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)

    local http_code=$(echo "$http_response" | tail -1)
    local response=$(echo "$http_response" | sed '$d')

    if [[ "$http_code" != "200" ]] || [[ -z "$response" ]]; then
        # API error (rate limit, etc.): reuse stale cache
        if [[ -f "$CACHE_FILE" ]]; then
            # Extend the TTL to avoid retrying too quickly
            local old_data=$(cat "$CACHE_FILE")
            echo "$old_data" | jq --arg ts "$(date +%s)" '.timestamp = ($ts | tonumber)' > "$CACHE_FILE"
            cat "$CACHE_FILE"
        else
            echo '{"five_hour": 0, "seven_day": 0, "reset_time": ""}'
        fi
        return 1
    fi

    # Extract values
    local five_h=$(echo "$response" | jq -r '.five_hour.utilization // 0')
    local seven_d=$(echo "$response" | jq -r '.seven_day.utilization // 0')
    local reset_ts=$(echo "$response" | jq -r '.five_hour.resets_at // ""')

    local result='{"five_hour": '"$five_h"', "seven_day": '"$seven_d"', "reset_time": "'"$reset_ts"'", "timestamp": '"$(date +%s)"'}'
    echo "$result" > "$CACHE_FILE"
    echo "$result"
}

# Fetch usage
usage=$(get_api_usage)
five_h=$(echo "$usage" | jq -r '.five_hour // 0')
seven_d=$(echo "$usage" | jq -r '.seven_day // 0')
reset_time=$(echo "$usage" | jq -r '.reset_time // ""')

# Calculate time until reset
time_until_reset=""
if [[ -n "$reset_time" && "$reset_time" != "null" ]]; then
    reset_epoch=$(date -d "$reset_time" +%s 2>/dev/null || echo 0)
    now_epoch=$(date +%s)
    diff=$(( reset_epoch - now_epoch ))
    if [[ $diff -gt 0 ]]; then
        time_until_reset=$(format_time $diff)
    fi
fi

# Build the status line
status=""

# 1. Model (bold indigo)
status+="${BOLD}${INDIGO}◆${RESET} "
status+="${model}"
status+=" │ "

# 2. Context used
ctx_color=$(get_color "$context_pct")
ctx_bar=$(progress_bar "$context_pct")
status+="${ctx_color}Ctx: ${ctx_bar} ${context_pct}%${RESET} │ "

# 3. Time until reset (if available)
if [[ -n "$time_until_reset" ]]; then
    status+="${INDIGO}⏱ ${time_until_reset}${RESET} │ "
fi

# 4. 5h usage
five_color=$(get_color "$five_h")
five_bar=$(progress_bar "$five_h")
status+="${five_color}5h: ${five_bar} ${five_h}%${RESET} │ "

# 5. 7d usage
seven_color=$(get_color "$seven_d")
seven_bar=$(progress_bar "$seven_d")
status+="${seven_color}7d: ${seven_bar} ${seven_d}%${RESET} │ "

# 6. Current directory
status+="${INDIGO}⌂ ${work_dir}${RESET}"

echo -e "$status"

How it works: breaking down the script

Reading input data

input=$(cat)
model=$(echo "$input" | jq -r '.model.display_name // "Unknown"')
context_pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0')
work_dir=$(echo "$input" | jq -r '.workspace.current_dir // "~"' | xargs basename)

Claude Code sends JSON with context. We extract:

  • Model ("Claude Opus 4.6")
  • Context percentage used (0-100)
  • Current directory

Calling the Anthropic API

get_api_usage() {
    local token=$(jq -r '.claudeAiOauth.accessToken // empty' "$HOME/.claude/.credentials.json" 2>/dev/null)

    local http_response=$(curl -s -w "\n%{http_code}" --max-time 5 \
        -H "Authorization: Bearer $token" \
        -H "anthropic-beta: oauth-2025-04-20" \
        "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)

    local http_code=$(echo "$http_response" | tail -1)
    local response=$(echo "$http_response" | sed '$d')

    if [[ "$http_code" != "200" ]]; then
        # Rate limited or error: reuse stale cache
        return 1
    fi

    local five_h=$(echo "$response" | jq -r '.five_hour.utilization // 0')
    local seven_d=$(echo "$response" | jq -r '.seven_day.utilization // 0')
    local reset_ts=$(echo "$response" | jq -r '.five_hour.resets_at // ""')
}

Four important things:

  1. Auth token: stored in ~/.claude/.credentials.json under the claudeAiOauth.accessToken key (generated by Claude Code at login). OAuth token.

  2. Special header: anthropic-beta: oauth-2025-04-20 - a "beta" API for OAuth. Still current with Claude Code 2.1.x.

  3. HTTP status code check: curl -w "\n%{http_code}" captures the status. Without this, curl returns exit code 0 even on 429 (rate limit), and you end up caching zeros.

  4. Real limits:

    • five_hour.utilization: percentage for last 5h (0-100)
    • seven_day.utilization: percentage for last 7d (0-100)
    • five_hour.resets_at: ISO timestamp when 5h window resets

Smart caching

local cache_time=$(jq -r '.timestamp // 0' "$CACHE_FILE" 2>/dev/null)
local cache_age=$(( $(date +%s) - cache_time ))
if [[ $cache_age -lt $CACHE_TTL ]]; then
    cat "$CACHE_FILE"
    return 0
fi

API called once every 10 minutes max (CACHE_TTL=600). This matters: the /api/oauth/usage endpoint has strict rate limits. With a TTL that's too short (like 60s), you'll get rate-limited in a loop and end up showing 0% forever.

Cache stored in ~/.claude/usage_cache.json with a timestamp in the JSON. Key point: on API errors (429, timeout...), we reuse stale cache instead of overwriting it with zeros. Without this, a single rate limit is enough to show 0% until the next successful call.

Progress bars

progress_bar() {
    local pct=$1
    local filled=$(( pct / 20 ))

    local bar=""
    for (( i=0; i<filled; i++ )); do
        bar+="▰"
    done
    for (( i=filled; i<5; i++ )); do
        bar+="▱"
    done
    echo "$bar"
}

Each bar = 5 blocks, so each block = 20%.

▰▰▱▱▱  = 40%
▰▰▰▱▱  = 60%
▰▰▰▰▰  = 100%

Color coding by threshold

get_color() {
    local pct=$1
    if (( $(echo "$pct < 50" | bc -l) )); then
        echo -e "$CYAN"      # Green: safe
    elif (( $(echo "$pct < 80" | bc -l) )); then
        echo -e "$VIOLET"    # Violet: warning
    else
        echo -e "$MAGENTA"   # Pink: danger
    fi
}
  • < 50%: Cyan (relaxed)
  • 50-80%: Violet (getting tight)
  • 80%: Magenta (red alert!)

Example output

Here's what you see at the bottom of your terminal:

◆ Claude Opus 4.6 │ Ctx: ▰▰▱▱▱ 35% │ ⏱ 3h42m │ 5h: ▰▰▰▱▱ 52% │ 7d: ▰▱▱▱▱ 18% │ ⌂ fransys-blog

Decoding:

  • ◆ Claude Opus 4.6: I'm on full Opus
  • Ctx: ▰▰▱▱▱ 35%: Used 35% of context window (good, not saturated)
  • ⏱ 3h42m: Reset in 3h42m
  • 5h: ▰▰▰▱▱ 52%: 52% of 5h limit used
  • 7d: ▰▱▱▱▱ 18%: 18% of 7d limit used (very comfortable)
  • ⌂ fransys-blog: In the fransys-blog directory

Lots of headroom. Good, continue.

Customization: adapt the colors

ANSI color codes:

  • \033[38;5;54m = Indigo (my primary)
  • \033[38;5;51m = Cyan
  • \033[38;5;141m = Violet
  • \033[38;5;201m = Magenta

256 ANSI colors vary by terminal. Test first:

for i in {0..255}; do echo -e "\033[38;5;${i}m█\033[0m"; done

You'll see each code visually. Modify INDIGO, CYAN, etc. based on your taste.

Common troubleshooting

Status line doesn't appear:

  • Check script is executable: chmod +x ~/.claude/statusline.sh
  • Test manually: echo '{"model": {"display_name": "test"}, "context_window": {"used_percentage": 50}}' | ~/.claude/statusline.sh

API values are 0:

  • Check token: jq '.claudeAiOauth.accessToken' ~/.claude/.credentials.json
  • If empty, re-auth: claude auth login
  • Test the API manually to check for rate limiting:
TOKEN=$(jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json)
curl -s -w "\nHTTP:%{http_code}" \
  -H "Authorization: Bearer $TOKEN" \
  -H "anthropic-beta: oauth-2025-04-20" \
  "https://api.anthropic.com/api/oauth/usage"

If you see HTTP:429, that's a rate limit. Wait a few minutes and increase CACHE_TTL. See "The rate limit trap" below.

The rate limit trap (permanent 0%):

The most common issue. The /api/oauth/usage endpoint has strict rate limits. If your script calls it too often:

  1. API returns HTTP 429 (rate limited)
  2. curl still returns exit code 0 (the connection itself succeeded)
  3. jq parses the error response, .five_hour.utilization doesn't exist -> falls back to 0
  4. The 0 gets cached -> displayed for the entire TTL
  5. Cache expires -> retry -> rate-limited again -> infinite loop of 0%

The fix: check the HTTP status code (curl -w "%{http_code}"), never cache error responses, and keep a long enough TTL (600s minimum)

Color isn't right:

  • Terminal might lack 256 colors. Test: echo $TERM. Should say xterm-256color or better.
  • Old terminal? Replace 38;5;XX codes with classics: 31 (red), 32 (green), etc.

Advanced use cases

Logging consumption:

echo "$(date) - 5h: $(echo "$usage" | jq '.five_hour')% | 7d: $(echo "$usage" | jq '.seven_day')%" >> ~/.claude/usage.log

Add to your Stop hook for a history.

Alerts:

if [[ $five_h -gt 85 ]]; then
    # Send a notification
    notify-send "Claude Code" "5h usage at ${five_h}% - slow down!"
fi

Useful if you're tight on limits.

Show more info:

# Also show sub-agent model
status+=" [subagent: ${CYAN}haiku${RESET}]"

That's it

This status line transformed my relationship with Claude Code. No more fear: I see exactly where I stand, how much time before reset, and can make smart decisions ("10,000 thinking tokens on this problem or keep it simple?").

The script isn't perfect - could be more robust, faster than bash - but it's a solid start. Copy it, adapt colors to your terminal theme, adjust alert thresholds to your limits.

Previous post

← Managing photos with self-hosted Immich

Next post

NAS monitoring: watching disks, services and logs at a glance→
← Back to blog

Table of Contents

  • The problem: how much quota is actually left?
  • Architecture: dynamic status line
  • Configuration: enable the status line
  • The script: ~/.claude/statusline.sh
  • How it works: breaking down the script
  • Reading input data
  • Calling the Anthropic API
  • Smart caching
  • Progress bars
  • Color coding by threshold
  • Example output
  • Customization: adapt the colors
  • Common troubleshooting
  • Advanced use cases
  • That's it