Skip to content

Instantly share code, notes, and snippets.

@joematthews
Created July 28, 2026 23:43
Show Gist options
  • Select an option

  • Save joematthews/9bfe88cb6b6bfd3d9a3b84bac0aad4f4 to your computer and use it in GitHub Desktop.

Select an option

Save joematthews/9bfe88cb6b6bfd3d9a3b84bac0aad4f4 to your computer and use it in GitHub Desktop.
Claude Code status line
#!/bin/bash
# =============================================================================
# Claude Code Status Line Script
# =============================================================================
#
# Adapted from https://gist.github.com/joematthews/a6a10a3a56a7c2387cbac546fe491719
#
# Displays a status bar inside Claude Code with:
# Line 1: Model info, context usage, rate limits
# Line 2: Current directory with git branch and file change indicators
#
# Line 1 example:
# Opus 5 (1M context) 45K (12%) 5h: 21% ↻2h13m 7d: 11% ↻4d3h
#
# Line 2 examples (single repo vs parent-of-repos):
# my-project → main !3 ?2 ↑1
# Code
# ├── frontend → main !2
# └── backend → develop ↑3 ↓1
#
# Wired up via ~/.claude/settings.json:
# { "statusLine": { "type": "command", "command": "~/.claude/statusline-command.sh" } }
#
# REQUIRES: jq — present at /usr/bin/jq on this machine (macOS 26).
#
# LOCAL ADAPTATIONS vs the source gist:
# - Directory/git status is resolved from the JSON `workspace.current_dir`
# field rather than `pwd`, which is what the official statusline docs do.
# `pwd` is not a documented contract for statusline scripts and goes stale
# if the session changes directory.
# - Removed the hourly `claude update` block. `claude update` *installs* the
# new version, so the gist silently swaps the running binary from a status
# line render. Claude Code auto-updates and reports new versions on its own.
# - Fixed a redirect-order bug in that block (`2>&1 > file` sends stderr to
# the status line, not to the file) — moot now that the block is gone.
# - context_window.used_percentage is floored; it is not guaranteed to be an
# integer, and a float breaks bash's `-gt` comparison.
# =============================================================================
# --- Verify jq is installed (needed to read Claude Code's JSON input) ---
if ! command -v jq &> /dev/null; then
echo "Error: jq is required (brew install jq)"
exit 1
fi
# Claude Code pipes a JSON object to this script via stdin with session data.
# We capture it once and extract fields from it throughout the script.
input=$(cat)
# --- ANSI color codes for terminal styling ---
BOLD='\033[1m' # Bold text (model name, labels, arrow separator)
CYAN='\033[1;36m' # Directory names
GREEN='\033[32m' # Staged files (ready to commit)
YELLOW='\033[1;33m' # Unstaged changes / caution-level rate limits
RED='\033[1;31m' # Untracked files / danger-level rate limits
MAGENTA='\033[1;35m' # Commits behind remote (needs pull)
RESET='\033[0m' # Resets all styling back to normal
# =============================================================================
# LINE 1: Model info, context, rate limits
# =============================================================================
# --- Extract all needed values from JSON in a single jq call for speed ---
# Instead of calling jq many times (slow), we pull everything at once.
# The @sh filter safely quotes values for shell eval (no injection risk).
# Percentages are floored/ceiled so bash's integer comparisons never see a float.
eval "$(echo "$input" | jq -r '
"model=" + (.model.display_name // "Claude" | @sh),
"cwd=" + (.workspace.current_dir // .cwd // "." | @sh),
"used_pct=" + (.context_window.used_percentage // 0 | floor | tostring),
"input_tokens=" + (.context_window.current_usage.input_tokens // 0 | tostring),
"cache_create=" + (.context_window.current_usage.cache_creation_input_tokens // 0 | tostring),
"cache_read=" + (.context_window.current_usage.cache_read_input_tokens // 0 | tostring),
"rate_5h=" + (.rate_limits.five_hour.used_percentage // 0 | ceil | tostring),
"rate_7d=" + (.rate_limits.seven_day.used_percentage // 0 | ceil | tostring),
"reset_5h=" + (.rate_limits.five_hour.resets_at // 0 | tostring),
"reset_7d=" + (.rate_limits.seven_day.resets_at // 0 | tostring)
')"
# --- Context window usage (how much of the conversation memory is used) ---
# Tokens are the units of text the model processes. More tokens = more context.
context_info=""
if [ "$used_pct" -gt 0 ]; then
current_tokens=$((input_tokens + cache_create + cache_read))
# Show as "45K" instead of "45000" for readability
if [ "$current_tokens" -gt 1000 ]; then
token_display="$((current_tokens / 1000))K"
else
token_display="$current_tokens"
fi
context_info=" ${token_display} (${used_pct}%)"
fi
# --- Rate limit colors (green = fine, yellow = caution, red = near limit) ---
rate_color() {
local pct=$1
if [ "$pct" -ge 80 ]; then echo -ne "$RED"
elif [ "$pct" -ge 50 ]; then echo -ne "$YELLOW"
else echo -ne "$GREEN"
fi
}
# --- Countdown timer (converts a future unix timestamp to "2h13m" format) ---
format_countdown() {
local resets_at=$1
local now
now=$(date +%s)
local diff=$((resets_at - now))
# Already reset
if [ "$diff" -le 0 ]; then
echo "now"
return
fi
local hours=$((diff / 3600))
local mins=$(( (diff % 3600) / 60 ))
if [ "$hours" -gt 24 ]; then
# Show days + hours for longer countdowns
local days=$((hours / 24))
hours=$((hours % 24))
echo "${days}d${hours}h"
elif [ "$hours" -gt 0 ]; then
echo "${hours}h${mins}m"
else
echo "${mins}m"
fi
}
countdown_5h=$(format_countdown "$reset_5h")
countdown_7d=$(format_countdown "$reset_7d")
# --- Print line 1 ---
# Example: Opus 5 (1M context) 45K (12%) 5h: 21% ↻2h13m 7d: 11% ↻4d3h
printf "${BOLD}%s${RESET}%s " "$model" "$context_info"
printf "${BOLD}5h:${RESET} $(rate_color "$rate_5h")${rate_5h}%% ↻${countdown_5h}${RESET} "
printf "${BOLD}7d:${RESET} $(rate_color "$rate_7d")${rate_7d}%% ↻${countdown_7d}${RESET}\n"
# =============================================================================
# LINE 2+: Directory and git status
# =============================================================================
# --- Git info for a single repository ---
# Shows: branch name, staged files, unstaged changes, untracked files, ahead/behind
# Example output: main 1 !3 ?2 ↑1
get_repo_git_info() {
local repo_path=$1
# Check if this directory is actually a git repo
if ! git -C "$repo_path" rev-parse --git-dir > /dev/null 2>&1; then
return 1
fi
# Get current branch name (or short commit hash if in detached HEAD state)
# --no-optional-locks prevents git from waiting on lock files
local branch
branch=$(git -C "$repo_path" --no-optional-locks branch --show-current 2>/dev/null)
if [ -z "$branch" ]; then
branch=$(git -C "$repo_path" --no-optional-locks rev-parse --short HEAD 2>/dev/null)
fi
local status_parts=()
# Count staged files (files added to the next commit with "git add") - green
local staged
staged=$(git -C "$repo_path" --no-optional-locks diff --cached --numstat 2>/dev/null | wc -l | tr -d ' ')
[ "$staged" != "0" ] && status_parts+=("${GREEN}${staged}${RESET}")
# Count unstaged changes (modified files not yet "git add"ed) - yellow with !
local unstaged
unstaged=$(git -C "$repo_path" --no-optional-locks diff --numstat 2>/dev/null | wc -l | tr -d ' ')
[ "$unstaged" != "0" ] && status_parts+=("${YELLOW}!${unstaged}${RESET}")
# Count untracked files (brand new files git doesn't know about) - red with ?
local untracked
untracked=$(git -C "$repo_path" --no-optional-locks ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
[ "$untracked" != "0" ] && status_parts+=("${RED}?${untracked}${RESET}")
# Check if local branch is ahead of or behind the remote branch
# ↑ = commits to push (cyan), ↓ = commits to pull (magenta)
local upstream
upstream=$(git -C "$repo_path" --no-optional-locks rev-parse --abbrev-ref @{upstream} 2>/dev/null)
if [ -n "$upstream" ]; then
local ahead behind
ahead=$(git -C "$repo_path" --no-optional-locks rev-list --count @{upstream}..HEAD 2>/dev/null || echo "0")
behind=$(git -C "$repo_path" --no-optional-locks rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0")
[ "$ahead" != "0" ] && status_parts+=("${CYAN}↑${ahead}${RESET}")
[ "$behind" != "0" ] && status_parts+=("${MAGENTA}↓${behind}${RESET}")
fi
# Assemble the output (echo -e renders the embedded ANSI color codes)
if [ ${#status_parts[@]} -gt 0 ]; then
echo -e "$branch $(IFS=' '; echo "${status_parts[*]}")"
else
echo -e "$branch"
fi
}
# --- Determine if we're in a git repo or a parent of multiple repos ---
dir_name=$(basename "$cwd")
if git -C "$cwd" rev-parse --git-dir > /dev/null 2>&1; then
# Current directory is a git repo - show its status directly
printf "${CYAN}%s${RESET} ${BOLD}→${RESET} %b\n" "$dir_name" "$(get_repo_git_info "$cwd")"
exit 0
fi
# Not a git repo - check if subdirectories are repos (e.g. ~/Code)
git_repos=()
for dir in "$cwd"/*/; do
[ -d "$dir" ] || continue
if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1; then
git_repos+=("$dir")
fi
done
if [ ${#git_repos[@]} -eq 0 ]; then
# No git repos found anywhere - just show directory name
printf "${CYAN}%s${RESET} ${BOLD}→${RESET} no git\n" "$dir_name"
else
# Show parent directory with tree branches for each sub-repo
printf "${CYAN}%s${RESET}" "$dir_name"
total=${#git_repos[@]}
for i in "${!git_repos[@]}"; do
dir="${git_repos[$i]}"
sub_name=$(basename "$dir")
sub_status=$(get_repo_git_info "$dir")
# └── for last item, ├── for all others (tree-style formatting)
if [ $((i + 1)) -eq $total ]; then
printf "\n└── ${CYAN}%s${RESET} ${BOLD}→${RESET} %b" "$sub_name" "$sub_status"
else
printf "\n├── ${CYAN}%s${RESET} ${BOLD}→${RESET} %b" "$sub_name" "$sub_status"
fi
done
printf "\n"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment