Skip to content

Instantly share code, notes, and snippets.

@rw3iss
Created April 21, 2026 19:24
Show Gist options
  • Select an option

  • Save rw3iss/b43001f5694a267a42d0dbb5b18875cd to your computer and use it in GitHub Desktop.

Select an option

Save rw3iss/b43001f5694a267a42d0dbb5b18875cd to your computer and use it in GitHub Desktop.
Claude Code status line: model, git, tokens, org, rate limits, CWD — adaptive width tiers
#!/bin/bash
# Claude Code Status Line
# Based on https://github.com/daniel3303/ClaudeCodeStatusLine
# Enhanced: git branch/ahead-behind, token bar, caching, improved visuals
set -f # disable globbing
# ===== Config =====
SHOW_GIT=true # git branch, dirty status, ahead/behind
SHOW_TOKENS=true # token usage bar
SHOW_THINKING=false # extended thinking indicator
SHOW_ORG=true # organization name (takes the middle slot when thinking is off)
SHOW_COST=false # session API cost (wide/full tiers only)
SHOW_RATE_LIMITS=true # 5h / 7d rate limit bars
SHOW_EXTRA_USAGE=false # extra/overage credits bar ($used/$limit)
BRANCH_MAX_LEN=28 # truncate branch names longer than this
GIT_CACHE_SECS=10 # seconds to cache git status (git diff is slow on large repos)
TOKEN_BAR_WIDTH=8 # width of token progress bar
API_CACHE_SECS=60 # seconds to cache rate-limit API responses
RATE_LIMIT_BAR_WIDTH=6 # width of rate-limit progress bars
# Terminal width detection.
# Claude Code runs this script in a subprocess — COLUMNS is 0 and tput/stty
# may not see the real TTY. When detection fails we default to 80 (safe/compact)
# rather than wide, so the line never wraps.
#
# To show more on a wider terminal, set TERM_WIDTH in settings.json:
# "command": "TERM_WIDTH=160 ~/.claude/statusline.sh"
if [ "${TERM_WIDTH:-0}" -le 0 ] 2>/dev/null; then
if [ "${COLUMNS:-0}" -gt 0 ] 2>/dev/null; then
TERM_WIDTH=$COLUMNS
else
_w=$(stty size </dev/tty 2>/dev/null | awk '{print $2}')
[ "${_w:-0}" -gt 0 ] 2>/dev/null && TERM_WIDTH=$_w || TERM_WIDTH=80
unset _w
fi
fi
input=$(cat)
[ -z "$input" ] && printf "Claude" && exit 0
mkdir -p /tmp/claude
# ===== Colors =====
blue='\033[38;2;97;175;239m'
orange='\033[38;2;255;176;85m'
amber='\033[38;2;229;192;123m'
green='\033[38;2;80;200;120m'
cyan='\033[38;2;86;182;194m'
red='\033[38;2;235;87;87m'
yellow='\033[38;2;230;200;0m'
white='\033[38;2;220;220;220m'
magenta='\033[38;2;198;120;221m'
dim='\033[2m'
reset='\033[0m'
sep=" ${dim}│${reset} "
# ===== Helpers =====
format_tokens() {
local num=$1
if [ "$num" -ge 1000000 ]; then
awk "BEGIN {printf \"%.1fm\", $num / 1000000}"
elif [ "$num" -ge 1000 ]; then
awk "BEGIN {printf \"%.0fk\", $num / 1000}"
else
printf "%d" "$num"
fi
}
truncate_str() {
local str="$1" max="$2"
[ "${#str}" -gt "$max" ] \
&& printf "%s…" "${str:0:$((max-1))}" \
|| printf "%s" "$str"
}
# Colored progress bar using block chars
build_bar() {
local pct=$1 width=$2
[ "$pct" -lt 0 ] 2>/dev/null && pct=0
[ "$pct" -gt 100 ] 2>/dev/null && pct=100
local filled=$(( pct * width / 100 ))
local empty=$(( width - filled ))
local bar_color
if [ "$pct" -ge 90 ]; then bar_color="$red"
elif [ "$pct" -ge 70 ]; then bar_color="$yellow"
elif [ "$pct" -ge 50 ]; then bar_color="$orange"
else bar_color="$green"
fi
local f="" e=""
for ((i=0; i<filled; i++)); do f+="█"; done
for ((i=0; i<empty; i++)); do e+="░"; done
printf "${bar_color}${f}${dim}${e}${reset}"
}
# ===== Git info with per-directory caching =====
get_git_info() {
local dir="$1"
[ -z "$dir" ] && return
# Stable cache key per directory path
local dir_hash
dir_hash=$(printf '%s' "$dir" | cksum | awk '{print $1}')
local cache_file="/tmp/claude/git-${dir_hash}"
local needs_refresh=true
if [ -f "$cache_file" ]; then
local mtime now age
mtime=$(stat -f %m "$cache_file" 2>/dev/null || stat -c %Y "$cache_file" 2>/dev/null)
now=$(date +%s)
age=$(( now - mtime ))
[ "$age" -lt "$GIT_CACHE_SECS" ] && needs_refresh=false
fi
if $needs_refresh; then
# Branch name (or short hash for detached HEAD)
local branch
branch=$(git -C "$dir" symbolic-ref --short HEAD 2>/dev/null)
if [ -z "$branch" ]; then
branch=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null)
if [ -z "$branch" ]; then
: > "$cache_file" # not a git repo — write empty cache
return
fi
branch="(${branch})"
fi
# Dirty: any staged or unstaged changes
local dirty=""
[ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ] && dirty="dirty"
# Ahead / behind upstream (skip if no upstream set)
local ahead=0 behind=0
local upstream
upstream=$(git -C "$dir" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null)
if [ -n "$upstream" ]; then
local ab
ab=$(git -C "$dir" rev-list --left-right --count "HEAD...${upstream}" 2>/dev/null)
ahead=$(echo "$ab" | awk '{print $1}')
behind=$(echo "$ab" | awk '{print $2}')
fi
# Tab-delimited cache (branch names can't contain tabs per git spec)
printf '%s\t%s\t%s\t%s' "$branch" "$dirty" "${ahead:-0}" "${behind:-0}" > "$cache_file"
fi
cat "$cache_file" 2>/dev/null
}
# ===== OAuth token resolution =====
get_oauth_token() {
[ -n "$CLAUDE_CODE_OAUTH_TOKEN" ] && echo "$CLAUDE_CODE_OAUTH_TOKEN" && return 0
if command -v security >/dev/null 2>&1; then
local blob token
blob=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)
if [ -n "$blob" ]; then
token=$(echo "$blob" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
[ -n "$token" ] && [ "$token" != "null" ] && echo "$token" && return 0
fi
fi
local creds_file="${HOME}/.claude/.credentials.json"
if [ -f "$creds_file" ]; then
local token
token=$(jq -r '.claudeAiOauth.accessToken // empty' "$creds_file" 2>/dev/null)
[ -n "$token" ] && [ "$token" != "null" ] && echo "$token" && return 0
fi
if command -v secret-tool >/dev/null 2>&1; then
local blob token
blob=$(timeout 2 secret-tool lookup service "Claude Code-credentials" 2>/dev/null)
if [ -n "$blob" ]; then
token=$(echo "$blob" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
[ -n "$token" ] && [ "$token" != "null" ] && echo "$token" && return 0
fi
fi
echo ""
}
# ===== ISO 8601 → epoch (cross-platform) =====
iso_to_epoch() {
local iso_str="$1"
local epoch
epoch=$(date -d "${iso_str}" +%s 2>/dev/null)
[ -n "$epoch" ] && echo "$epoch" && return 0
local stripped="${iso_str%%.*}"
stripped="${stripped%%Z}"
stripped="${stripped%%+*}"
stripped="${stripped%%-[0-9][0-9]:[0-9][0-9]}"
if [[ "$iso_str" == *"Z"* ]] || [[ "$iso_str" == *"+00:00"* ]] || [[ "$iso_str" == *"-00:00"* ]]; then
epoch=$(env TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%S" "$stripped" +%s 2>/dev/null)
else
epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$stripped" +%s 2>/dev/null)
fi
[ -n "$epoch" ] && echo "$epoch" && return 0
return 1
}
format_remaining_time() {
local iso_str="$1"
[ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return
local epoch now diff
epoch=$(iso_to_epoch "$iso_str")
[ -z "$epoch" ] && return
now=$(date +%s)
diff=$(( epoch - now ))
[ "$diff" -le 0 ] && echo "now" && return
if [ "$diff" -lt 60 ]; then
echo "${diff}s"
elif [ "$diff" -lt 3600 ]; then
echo "$(( diff / 60 ))m"
else
local h=$(( diff / 3600 ))
local m=$(( (diff % 3600) / 60 ))
[ "$m" -gt 0 ] && echo "${h}h${m}m" || echo "${h}h"
fi
}
format_reset_time() {
local iso_str="$1" style="$2"
[ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return
local epoch
epoch=$(iso_to_epoch "$iso_str")
[ -z "$epoch" ] && return
case "$style" in
time)
date -j -r "$epoch" +"%l:%M%p" 2>/dev/null | sed 's/^ //' | tr '[:upper:]' '[:lower:]' ||
date -d "@$epoch" +"%l:%M%P" 2>/dev/null | sed 's/^ //'
;;
datetime)
date -j -r "$epoch" +"%b %-d, %l:%M%p" 2>/dev/null | sed 's/ / /g; s/^ //' | tr '[:upper:]' '[:lower:]' ||
date -d "@$epoch" +"%b %-d, %l:%M%P" 2>/dev/null | sed 's/ / /g; s/^ //'
;;
esac
}
# ===== Extract JSON =====
model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"')
cwd=$(echo "$input" | jq -r '.cwd // empty')
cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // empty')
size=$(echo "$input" | jq -r '.context_window.context_window_size // 200000')
[ "$size" -eq 0 ] 2>/dev/null && size=200000
input_tokens=$(echo "$input" | jq -r '.context_window.current_usage.input_tokens // 0')
cache_create=$(echo "$input" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0')
cache_read=$(echo "$input" | jq -r '.context_window.current_usage.cache_read_input_tokens // 0')
current=$(( input_tokens + cache_create + cache_read ))
used_tokens=$(format_tokens $current)
total_tokens=$(format_tokens $size)
pct_used=$(( size > 0 ? current * 100 / size : 0 ))
thinking_on=false
settings_path="$HOME/.claude/settings.json"
if [ -f "$settings_path" ]; then
thinking_val=$(jq -r '.alwaysThinkingEnabled // false' "$settings_path" 2>/dev/null)
[ "$thinking_val" = "true" ] && thinking_on=true
fi
# Organization name — read from the active config dir's .claude.json
org_name=""
if $SHOW_ORG; then
for _cfg in \
"${CLAUDE_CONFIG_DIR:-}/.claude.json" \
"$HOME/.claude.json"
do
if [ -n "$_cfg" ] && [ -f "$_cfg" ]; then
org_name=$(jq -r '.oauthAccount.organizationName // ""' "$_cfg" 2>/dev/null)
[ -n "$org_name" ] && break
fi
done
unset _cfg
fi
# ===== Adaptive width tiers =====
#
# Tiers (tuned so each tier's max output fits within its min width):
#
# full (≥150): CWD, ahead/behind, "◆ thinking", 5h+7d+reset, cost
# wide (100–149): ahead/behind, "◆ thinking", 5h+reset, cost
# split (70–99): ahead/behind, "◆" symbol, 5h bar, cost
# narrow (<70): short model + branch + token only
#
if [ "$TERM_WIDTH" -ge 150 ] 2>/dev/null; then width_tier="full"
elif [ "$TERM_WIDTH" -ge 100 ] 2>/dev/null; then width_tier="wide"
elif [ "$TERM_WIDTH" -ge 76 ] 2>/dev/null; then width_tier="split"
else width_tier="narrow"
fi
# Shorten model name for tight spaces
short_model() {
case "$1" in
*Opus*) echo "Opus" ;;
*Sonnet*) echo "Sonnet" ;;
*Haiku*) echo "Haiku" ;;
*) echo "$1" | awk '{print $NF}' ;; # last word
esac
}
# ===== Build output =====
out=""
# Model — color by family
model_color="$orange"
case "$model_name" in
*Opus*) model_color="$cyan" ;;
*Haiku*) model_color="$amber" ;;
esac
display_model="$model_name"
[ "$width_tier" = "narrow" ] && display_model=$(short_model "$model_name")
out+="${model_color}${display_model}${reset}"
# Git branch + dirty + ahead/behind
if $SHOW_GIT && [ -n "$cwd" ]; then
git_info=$(get_git_info "$cwd")
if [ -n "$git_info" ]; then
IFS=$'\t' read -r g_branch g_dirty g_ahead g_behind <<< "$git_info"
# Progressively tighten branch truncation
local_max="$BRANCH_MAX_LEN"
[ "$width_tier" = "wide" ] && local_max=24
[ "$width_tier" = "split" ] && local_max=18
[ "$width_tier" = "narrow" ] && local_max=12
g_branch_display=$(truncate_str "$g_branch" "$local_max")
out+="${sep}${dim}⎇${reset} ${magenta}${g_branch_display}${reset}"
if [ "$g_dirty" = "dirty" ]; then
out+=" ${red}✗${reset}"
else
out+=" ${green}✔${reset}"
fi
# Ahead/behind: shown in all tiers except narrow (only if non-zero)
if [ "$width_tier" != "narrow" ]; then
[ "${g_ahead:-0}" -gt 0 ] && out+=" ${green}↑${g_ahead}${reset}"
[ "${g_behind:-0}" -gt 0 ] && out+=" ${orange}↓${g_behind}${reset}"
fi
fi
fi
# Token bar
if $SHOW_TOKENS; then
bar_w="$TOKEN_BAR_WIDTH"
[ "$width_tier" = "wide" ] && bar_w=6
[ "$width_tier" = "split" ] && bar_w=5
[ "$width_tier" = "narrow" ] && bar_w=4
token_bar=$(build_bar "$pct_used" "$bar_w")
out+="${sep}${token_bar} ${orange}${used_tokens}${dim}/${reset}${white}${total_tokens}${reset} ${dim}${pct_used}%${reset}"
fi
# Middle slot: Organization (preferred) or Thinking indicator.
# SHOW_ORG + SHOW_THINKING are independent; org wins when both are on.
# full/wide → "⬡ Vendidit" or "◆ thinking" / "◇ thinking"
# split → "Vendidit" or "◆" / "◇" (saves space)
# narrow → hidden
if $SHOW_ORG && [ -n "$org_name" ] && [ "$width_tier" != "narrow" ]; then
if [ "$width_tier" = "split" ]; then
out+="${sep}${magenta}${org_name}${reset}"
else
out+="${sep}${magenta}⬡ ${org_name}${reset}"
fi
elif $SHOW_THINKING && [ "$width_tier" != "narrow" ]; then
out+="${sep}"
if $thinking_on; then
if [ "$width_tier" = "split" ]; then out+="${amber}◆${reset}"
else out+="${amber}◆ thinking${reset}"; fi
else
if [ "$width_tier" = "split" ]; then out+="${dim}◇${reset}"
else out+="${dim}◇ thinking${reset}"; fi
fi
fi
# Session cost — wide/full only
if $SHOW_COST && [ -n "$cost_usd" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; then
cost_fmt=$(printf '%.2f' "$cost_usd" 2>/dev/null)
[ -n "$cost_fmt" ] && out+="${sep}${dim}\$${cost_fmt}${reset}"
fi
# ===== Rate limits (API, cached 60s) =====
# shown in full/wide/split; hidden only in narrow
if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then
# Fetch token first so the cache key is account-specific.
# Switching accounts automatically invalidates the old cache.
token=$(get_oauth_token)
usage_data=""
if [ -n "$token" ] && [ "$token" != "null" ]; then
token_hash=$(printf '%s' "$token" | cksum | awk '{print $1}')
api_cache="/tmp/claude/statusline-usage-${token_hash}.json"
needs_refresh=true
if [ -f "$api_cache" ]; then
cache_mtime=$(stat -c %Y "$api_cache" 2>/dev/null || stat -f %m "$api_cache" 2>/dev/null)
now=$(date +%s)
cache_age=$(( now - cache_mtime ))
if [ "$cache_age" -lt "$API_CACHE_SECS" ]; then
cached_content=$(cat "$api_cache" 2>/dev/null)
if [ -n "$cached_content" ] && ! echo "$cached_content" | jq -e '.error' >/dev/null 2>&1; then
needs_refresh=false
usage_data="$cached_content"
fi
fi
fi
if $needs_refresh; then
response=$(curl -s --max-time 10 \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "User-Agent: claude-code/2.1.34" \
"https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
if [ -n "$response" ] && echo "$response" | jq . >/dev/null 2>&1; then
if ! echo "$response" | jq -e '.error' >/dev/null 2>&1; then
usage_data="$response"
echo "$response" > "$api_cache"
fi
fi
# Fall back to stale cache if refresh failed
if [ -z "$usage_data" ] && [ -f "$api_cache" ]; then
stale=$(cat "$api_cache" 2>/dev/null)
! echo "$stale" | jq -e '.error' >/dev/null 2>&1 && usage_data="$stale"
fi
fi
fi
if [ -n "$usage_data" ] && echo "$usage_data" | jq -e . >/dev/null 2>&1; then
bar_width=$RATE_LIMIT_BAR_WIDTH
[ "$width_tier" = "split" ] && bar_width=$(( RATE_LIMIT_BAR_WIDTH - 2 ))
five_hour_pct=$(echo "$usage_data" | jq -r '.five_hour.utilization // 0' | awk '{printf "%.0f", $1}')
five_hour_reset_iso=$(echo "$usage_data" | jq -r '.five_hour.resets_at // empty')
five_hour_bar=$(build_bar "$five_hour_pct" "$bar_width")
five_hour_remaining=$(format_remaining_time "$five_hour_reset_iso")
five_hour_label="${five_hour_remaining:-5h}"
out+="${sep}${dim}${five_hour_label}${reset} ${five_hour_bar} ${cyan}${five_hour_pct}%${reset}"
# Reset time: full and wide only (not split — saves ~12 chars)
if [ "$width_tier" = "full" ] || [ "$width_tier" = "wide" ]; then
five_hour_reset=$(format_reset_time "$five_hour_reset_iso" "time")
[ -n "$five_hour_reset" ] && out+=" ${dim}↺ ${five_hour_reset}${reset}"
fi
# 7d bar + reset: full only
if [ "$width_tier" = "full" ]; then
seven_day_pct=$(echo "$usage_data" | jq -r '.seven_day.utilization // 0' | awk '{printf "%.0f", $1}')
seven_day_reset_iso=$(echo "$usage_data" | jq -r '.seven_day.resets_at // empty')
seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime")
seven_day_bar=$(build_bar "$seven_day_pct" "$bar_width")
out+="${sep}${dim}7d${reset} ${seven_day_bar} ${cyan}${seven_day_pct}%${reset}"
[ -n "$seven_day_reset" ] && out+=" ${dim}↺ ${seven_day_reset}${reset}"
extra_enabled=$(echo "$usage_data" | jq -r '.extra_usage.is_enabled // false')
if $SHOW_EXTRA_USAGE && [ "$extra_enabled" = "true" ]; then
extra_pct=$(echo "$usage_data" | jq -r '.extra_usage.utilization // 0' | awk '{printf "%.0f", $1}')
extra_used=$(echo "$usage_data" | jq -r '.extra_usage.used_credits // 0' | awk '{printf "%.2f", $1/100}')
extra_limit=$(echo "$usage_data" | jq -r '.extra_usage.monthly_limit // 0' | awk '{printf "%.2f", $1/100}')
extra_bar=$(build_bar "$extra_pct" "$bar_width")
out+="${sep}${dim}extra${reset} ${extra_bar} ${cyan}\$${extra_used}${dim}/\$${extra_limit}${reset}"
fi
fi
fi
fi
# CWD — full path with ~ abbreviation, appended at end of status line
if [ -n "$cwd" ]; then
display_cwd="${cwd/#$HOME/~}"
out+="${sep}${dim}${display_cwd}${reset}"
fi
printf "%b" "$out"
exit 0

Claude Code Status Line

A single-file bash status line for Claude Code, displaying model, git state, token usage, organization, rate limits, and working directory on one line — with adaptive width tiers so it never wraps.

Based on daniel3303/ClaudeCodeStatusLine with these additions:

  • Organization name (auto-resolved from $CLAUDE_CONFIG_DIR/.claude.json)
  • Per-directory git cache (10s) — avoids slow git status on large repos
  • Per-token rate-limit API cache (60s) — switching accounts auto-invalidates
  • Ahead/behind upstream counts
  • Feature flags to toggle each section independently

Sample output

Opus 4.7 (1M context) │ ⎇ main ✔ ↑2 │ █░░░░░░░ 171k/1.0m 17% │ ⬡ Vendidit │ 2h14m ░░░░░░ 15% │ 7d ░░░░░░ 9% │ ~/Sites/blobs

Left → right: model · git branch + dirty/clean + ahead/behind · token bar · organization · 5h rate limit · 7d rate limit · working directory.

Install

mkdir -p ~/.claude/scripts
curl -o ~/.claude/scripts/statusline.sh https://gist.githubusercontent.com/rw3iss/raw/statusline.sh
chmod +x ~/.claude/scripts/statusline.sh

Then enable it in ~/.claude/settings.json:

{
  "statusLine": {
    "type": "command",
    "command": "TERM_WIDTH=180 ~/.claude/scripts/statusline.sh"
  }
}

TERM_WIDTH is optional — it forces a width tier since Claude Code runs the script in a subprocess where terminal width detection isn't reliable. Set it to your actual terminal columns (check with tput cols).

Configuration flags

All flags live at the top of the script — toggle without touching any other code:

Flag Default What it shows
SHOW_GIT true Branch · dirty indicator · ahead/behind counts
SHOW_TOKENS true Progress bar · used/total · %
SHOW_THINKING false ◆ thinking / ◇ thinking indicator
SHOW_ORG true Organization name (beats thinking when both on)
SHOW_COST false Session cost in USD (wide/full tiers only)
SHOW_RATE_LIMITS true 5h + 7d rate-limit bars
SHOW_EXTRA_USAGE false Extra/overage credit bar ($used/$limit)

Width tiers

Claude Code subprocess doesn't always see real $COLUMNS, so the script picks a tier from TERM_WIDTH (or falls back to stty size, then 80):

Tier Min width What's included
full ≥150 CWD · ahead/behind · org · 5h + reset · 7d + reset · extra · cost
wide 100–149 ahead/behind · org · 5h + reset · cost
split 76–99 ahead/behind · org symbol-only · 5h bar · cost
narrow <76 Short model · branch · token only

Requirements

  • bash 4+
  • jq
  • curl (optional — for rate-limit API)
  • A real TTY or TERM_WIDTH set explicitly

Multi-account support

The organization name is resolved from the session's own config dir:

$CLAUDE_CONFIG_DIR/.claude.json  →  .oauthAccount.organizationName

With fallback to ~/.claude.json. So if you run multiple accounts via different config dirs (e.g., CLAUDE_CONFIG_DIR=~/.claude-work claude), each session's status line reflects its own org.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment