Created
July 28, 2026 23:43
-
-
Save joematthews/9bfe88cb6b6bfd3d9a3b84bac0aad4f4 to your computer and use it in GitHub Desktop.
Claude Code status line
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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