Last active
March 20, 2026 22:37
-
-
Save anatoliykant/35a3e1e8a1d4f1cef7730fe9f18aa165 to your computer and use it in GitHub Desktop.
Claude code /statusline from Anatolii
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
| #!/usr/bin/env bash | |
| # Claude Code status line — colorful 3-line layout | |
| # Line 1: Model/thinking/fast | context bar | usage | |
| # Line 2: Git branch | timer | directory | |
| # Line 3: iTerm2 tab bar — Claude Code sessions with status + clickable links | |
| # Reads JSON from stdin, fetches usage from Anthropic API (cached) | |
| input=$(cat) | |
| # ── ANSI Colors ────────────────────────────────────────────────────────────── | |
| RST='\033[0m' | |
| DIM='\033[2m' | |
| GREEN='\033[32m' | |
| YELLOW='\033[33m' | |
| BLUE='\033[34m' | |
| CYAN='\033[36m' | |
| GRAY='\033[90m' | |
| BGREEN='\033[1;32m' | |
| BYELLOW='\033[1;33m' | |
| BRED='\033[1;31m' | |
| BCYAN='\033[1;36m' | |
| # ── Helpers ────────────────────────────────────────────────────────────────── | |
| get() { echo "$input" | jq -r "$1 // empty" 2>/dev/null; } | |
| color_pct() { | |
| local pct="$1" | |
| if [ -z "$pct" ] || [ "$pct" = "--" ]; then | |
| printf "${GRAY}--%${RST}"; return | |
| fi | |
| if [ "$pct" -lt 50 ] 2>/dev/null; then | |
| printf "${GREEN}${pct}%%${RST}" | |
| elif [ "$pct" -lt 80 ] 2>/dev/null; then | |
| printf "${YELLOW}${pct}%%${RST}" | |
| else | |
| printf "${BRED}${pct}%%${RST}" | |
| fi | |
| } | |
| # ── Model + Thinking + Fast ────────────────────────────────────────────────── | |
| model_name=$(get '.model.display_name') | |
| [ -z "$model_name" ] && model_name=$(get '.model.id') | |
| [ -z "$model_name" ] && model_name="--" | |
| model_short=$(echo "$model_name" | sed 's/ [0-9].*$//') | |
| STRIKE='\033[9m' | |
| thinking_str="" | |
| fast_str="" | |
| # ── Read alwaysThinkingEnabled + fastMode from settings files ───────────────── | |
| # Priority: project settings > global settings | |
| # (claude config get is NOT used — it can hang or fail in statusline context) | |
| _read_setting() { | |
| local key="$1" | |
| local val="" | |
| # Try project-level settings first (cwd from JSON input) | |
| local proj_dir | |
| proj_dir=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // .cwd // empty' 2>/dev/null) | |
| if [ -n "$proj_dir" ] && [ -f "${proj_dir}/.claude/settings.json" ]; then | |
| val=$(jq -r ".${key} // empty" "${proj_dir}/.claude/settings.json" 2>/dev/null | tr -d '[:space:]') | |
| fi | |
| # Fall back to global settings | |
| if [ -z "$val" ] && [ -f "$HOME/.claude/settings.json" ]; then | |
| val=$(jq -r ".${key} // empty" "$HOME/.claude/settings.json" 2>/dev/null | tr -d '[:space:]') | |
| fi | |
| echo "$val" | |
| } | |
| te=$(_read_setting "alwaysThinkingEnabled") | |
| [ "$te" != "true" ] && te="false" | |
| fm=$(_read_setting "fastMode") | |
| [ "$fm" != "true" ] && fm="false" | |
| # ── Thinking indicator ──────────────────────────────────────────────────────── | |
| # 🧠thinking only when alwaysThinkingEnabled=true AND fastMode=false. | |
| # All other combinations: 🙊 + strikethrough "thinking" | |
| if [ "$te" = "true" ] && [ "$fm" != "true" ]; then | |
| thinking_str=" 🧠thinking" | |
| else | |
| thinking_str=" 🙊${STRIKE}thinking${RST}" | |
| fi | |
| # ── Fast mode indicator ─────────────────────────────────────────────────────── | |
| if [ "$fm" = "true" ]; then | |
| fast_str=" ⚡️fast" | |
| else | |
| fast_str=" 🐢${STRIKE}fast${RST}" | |
| fi | |
| model_part="${BCYAN}${model_short}${RST}${DIM}${thinking_str}${RST}${fast_str}" | |
| # ── Context Progress Bar (10 chars, colored) ───────────────────────────────── | |
| context_size=$(get '.context_window.context_window_size') | |
| used_pct=$(get '.context_window.used_percentage') | |
| # Use current context input tokens (matches the progress bar) if available, | |
| # fall back to total_input_tokens for sessions without a current API call yet | |
| current_input=$(get '.context_window.current_usage.input_tokens') | |
| total_input=$(get '.context_window.total_input_tokens') | |
| [ -n "$current_input" ] && [ "$current_input" != "0" ] && total_input="$current_input" | |
| BAR_W=10 | |
| if [ -n "$used_pct" ] && [ "$used_pct" != "null" ]; then | |
| pct_int=$(printf "%.0f" "$used_pct") | |
| filled=$((pct_int * BAR_W / 100)) | |
| # At least 1 filled block when usage > 0 | |
| [ "$pct_int" -gt 0 ] && [ "$filled" -lt 1 ] && filled=1 | |
| [ "$filled" -gt "$BAR_W" ] && filled=$BAR_W | |
| [ "$filled" -lt 0 ] && filled=0 | |
| empty=$((BAR_W - filled)) | |
| if [ "$pct_int" -lt 50 ]; then | |
| BC=$GREEN | |
| elif [ "$pct_int" -lt 75 ]; then | |
| BC=$YELLOW | |
| else | |
| BC=$BRED | |
| fi | |
| bar_f=""; bar_e="" | |
| i=0; while [ $i -lt $filled ]; do bar_f="${bar_f}█"; i=$((i+1)); done | |
| i=0; while [ $i -lt $empty ]; do bar_e="${bar_e}░"; i=$((i+1)); done | |
| # Compute used tokens from used_percentage × context_window_size so both numbers are always consistent | |
| used_tokens=$(echo "$used_pct $context_size" | awk '{printf "%.0f", $1/100*$2}') | |
| used_k=$(echo "$used_tokens" | awk '{if($1>=1000000)printf "%.1fM",$1/1000000;else if($1==0)printf "0";else if($1<500)printf "<1k";else printf "%.0fk",$1/1000}') | |
| ctx_k=$(echo "$context_size" | awk '{if($1>=1000000)printf "%.0fM",$1/1000000;else printf "%.0fk",$1/1000}') | |
| bar_part="[${BC}${bar_f}${GRAY}${bar_e}${RST}] ${used_k}/${ctx_k} (${pct_int}%)" | |
| else | |
| # New session — no messages yet, show zeroed-out bar | |
| empty_bar="░░░░░░░░░░" | |
| ctx_k="--" | |
| if [ -n "$context_size" ] && [ "$context_size" != "null" ]; then | |
| ctx_k=$(echo "$context_size" | awk '{if($1>=1000000)printf "%.0fM",$1/1000000;else printf "%.0fk",$1/1000}') | |
| fi | |
| bar_part="[${GRAY}${empty_bar}${RST}] ${GREEN}0${RST}/${ctx_k} (${GREEN}0%${RST})" | |
| fi | |
| # ── Usage: Session / Weekly (Anthropic API with cache) ─────────────────────── | |
| CACHE_DIR="$HOME/.cache/claude-statusline" | |
| CACHE_FILE="$CACHE_DIR/usage.json" | |
| CACHE_MAX_AGE=180 | |
| LOCK_FILE="$CACHE_DIR/usage.lock" | |
| session_pct="--" | |
| weekly_pct="--" | |
| parse_cached() { | |
| if [ -f "$CACHE_FILE" ]; then | |
| local s w | |
| s=$(jq -r '.sessionUsage // empty' "$CACHE_FILE" 2>/dev/null) | |
| w=$(jq -r '.weeklyUsage // empty' "$CACHE_FILE" 2>/dev/null) | |
| [ -n "$s" ] && session_pct=$(echo "$s" | awk '{printf "%.0f", $1}') | |
| [ -n "$w" ] && weekly_pct=$(echo "$w" | awk '{printf "%.0f", $1}') | |
| fi | |
| } | |
| fetch_usage_bg() { | |
| ( | |
| mkdir -p "$CACHE_DIR" | |
| # Get token from macOS keychain | |
| token_json=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) | |
| [ -z "$token_json" ] && exit 0 | |
| token=$(echo "$token_json" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null) | |
| [ -z "$token" ] && exit 0 | |
| resp=$(curl -s --max-time 5 \ | |
| -H "Authorization: Bearer $token" \ | |
| -H "anthropic-beta: oauth-2025-04-20" \ | |
| "https://api.anthropic.com/api/oauth/usage" 2>/dev/null) | |
| [ -z "$resp" ] && exit 0 | |
| echo "$resp" | jq '{ | |
| sessionUsage: .five_hour.utilization, | |
| sessionResetAt: .five_hour.resets_at, | |
| weeklyUsage: .seven_day.utilization, | |
| weeklyResetAt: .seven_day.resets_at | |
| }' > "$CACHE_FILE" 2>/dev/null | |
| rm -f "$LOCK_FILE" | |
| ) &>/dev/null & | |
| } | |
| # Always try to read cache first (instant) | |
| parse_cached | |
| # Invalidate cache if a reset time has passed (prevents showing stale pre-reset values) | |
| cache_expired=false | |
| if [ -f "$CACHE_FILE" ]; then | |
| now_epoch=$(date +%s) | |
| for reset_key in sessionResetAt weeklyResetAt; do | |
| reset_ts=$(jq -r ".${reset_key} // empty" "$CACHE_FILE" 2>/dev/null) | |
| if [ -n "$reset_ts" ]; then | |
| reset_epoch=$(date -jf "%Y-%m-%dT%H:%M:%S" "$(echo "$reset_ts" | cut -c1-19)" +%s 2>/dev/null || echo 0) | |
| cache_mtime=$(stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) | |
| # Cache was written before the reset, but reset has already happened | |
| if [ "$cache_mtime" -lt "$reset_epoch" ] && [ "$now_epoch" -ge "$reset_epoch" ]; then | |
| cache_expired=true | |
| break | |
| fi | |
| fi | |
| done | |
| fi | |
| # Refresh in background if cache is stale or expired past a reset boundary | |
| if [ -f "$CACHE_FILE" ]; then | |
| cache_age=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) )) | |
| if { [ "$cache_age" -ge "$CACHE_MAX_AGE" ] || [ "$cache_expired" = true ]; } && [ ! -f "$LOCK_FILE" ]; then | |
| touch "$LOCK_FILE" 2>/dev/null | |
| fetch_usage_bg | |
| fi | |
| else | |
| # No cache yet — trigger first fetch | |
| mkdir -p "$CACHE_DIR" | |
| if [ ! -f "$LOCK_FILE" ]; then | |
| touch "$LOCK_FILE" 2>/dev/null | |
| fetch_usage_bg | |
| fi | |
| fi | |
| session_colored=$(color_pct "$session_pct") | |
| weekly_colored=$(color_pct "$weekly_pct") | |
| # ── Session Timer ──────────────────────────────────────────────────────────── | |
| duration_ms=$(get '.cost.total_duration_ms') | |
| if [ -n "$duration_ms" ] && [ "$duration_ms" != "null" ] && [ "$duration_ms" != "0" ]; then | |
| total_sec=$(echo "$duration_ms" | awk '{printf "%.0f", $1/1000}') | |
| mins=$((total_sec / 60)) | |
| secs=$((total_sec % 60)) | |
| else | |
| total_sec=0 | |
| mins=0 | |
| secs=0 | |
| fi | |
| # ── Clock emoji based on elapsed session time ──────────────────────────────── | |
| # Use transcript file creation time to determine session age in seconds | |
| transcript_path=$(get '.transcript_path') | |
| elapsed_sec=0 | |
| if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then | |
| created=$(stat -f %B "$transcript_path" 2>/dev/null) | |
| if [ -n "$created" ] && [ "$created" != "0" ]; then | |
| now=$(date +%s) | |
| elapsed_sec=$((now - created)) | |
| [ "$elapsed_sec" -lt 0 ] && elapsed_sec=0 | |
| fi | |
| fi | |
| # Map elapsed_sec to clock emoji (half-hour steps, 12-hour cycle) | |
| elapsed_min=$((elapsed_sec / 60)) | |
| elapsed_h=$((elapsed_min / 60)) | |
| elapsed_m=$((elapsed_min % 60)) | |
| if [ "$elapsed_h" -ge 12 ]; then | |
| clock_emoji="🕛" | |
| elif [ "$elapsed_h" -eq 11 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕦" || clock_emoji="🕚" | |
| elif [ "$elapsed_h" -eq 10 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕥" || clock_emoji="🕙" | |
| elif [ "$elapsed_h" -eq 9 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕤" || clock_emoji="🕘" | |
| elif [ "$elapsed_h" -eq 8 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕣" || clock_emoji="🕗" | |
| elif [ "$elapsed_h" -eq 7 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕢" || clock_emoji="🕖" | |
| elif [ "$elapsed_h" -eq 6 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕡" || clock_emoji="🕕" | |
| elif [ "$elapsed_h" -eq 5 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕠" || clock_emoji="🕔" | |
| elif [ "$elapsed_h" -eq 4 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕟" || clock_emoji="🕓" | |
| elif [ "$elapsed_h" -eq 3 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕞" || clock_emoji="🕒" | |
| elif [ "$elapsed_h" -eq 2 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕝" || clock_emoji="🕑" | |
| elif [ "$elapsed_h" -eq 1 ]; then | |
| [ "$elapsed_m" -ge 30 ] && clock_emoji="🕜" || clock_emoji="🕐" | |
| else | |
| clock_emoji="🕐" | |
| fi | |
| # Format timer: seconds only, or Xm Ys, or Xh Ym, or Xd Yh | |
| if [ "$mins" -ge 1440 ] 2>/dev/null; then | |
| days=$((mins / 1440)) | |
| hours=$(( (mins % 1440) / 60 )) | |
| timer_part="${clock_emoji} ${days}d ${hours}h" | |
| elif [ "$mins" -ge 60 ] 2>/dev/null; then | |
| hours=$((mins / 60)) | |
| rem_mins=$((mins % 60)) | |
| timer_part="${clock_emoji} ${hours}h ${rem_mins}m" | |
| elif [ "$mins" -gt 0 ] 2>/dev/null; then | |
| timer_part="${clock_emoji} ${mins}m ${secs}s" | |
| else | |
| timer_part="${clock_emoji} ${secs}s" | |
| fi | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # LINE 1: Model thinking⚡fast [bar] tokens | Session: N% | Weekly: N% | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| line1="${model_part} ${bar_part} | Session: ${session_colored} | Weekly: ${weekly_colored}" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # LINE 2: 🌿 branch +N~N | ⏱ Nm Ns | 📂 dir | 🔗 project | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| cwd=$(get '.workspace.current_dir') | |
| [ -z "$cwd" ] && cwd=$(get '.cwd') | |
| # ── Git Branch + Changes ──────────────────────────────────────────────────── | |
| git_branch="" | |
| if [ -n "$cwd" ] && { [ -d "$cwd/.git" ] || git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; }; then | |
| git_branch=$(git -C "$cwd" symbolic-ref --short HEAD 2>/dev/null || git -C "$cwd" rev-parse --short HEAD 2>/dev/null) | |
| fi | |
| git_part="" | |
| if [ -n "$git_branch" ]; then | |
| added=0; removed=0 | |
| stats=$(git -C "$cwd" diff --no-lock-index --shortstat HEAD 2>/dev/null) | |
| if [ -n "$stats" ]; then | |
| a=$(echo "$stats" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+') | |
| r=$(echo "$stats" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+') | |
| [ -n "$a" ] && added=$a | |
| [ -n "$r" ] && removed=$r | |
| fi | |
| changes="" | |
| [ "$added" -gt 0 ] 2>/dev/null && changes="${BGREEN}+${added}${RST}" | |
| [ "$removed" -gt 0 ] 2>/dev/null && changes="${changes}${BYELLOW}~${removed}${RST}" | |
| git_part="🌿 ${GREEN}${git_branch}${RST}" | |
| [ -n "$changes" ] && git_part="${git_part} ${changes}" | |
| else | |
| git_part="🌿 ${GRAY}(no git)${RST}" | |
| fi | |
| # ── Directory (short path, bright project name) ───────────────────────────── | |
| dir_short="${cwd/#$HOME/~}" | |
| dir_base=$(dirname "$dir_short") | |
| dir_name=$(basename "$dir_short") | |
| if [ "$dir_base" = "." ]; then | |
| dir_part="📂 ${BCYAN}${dir_name}${RST}" | |
| else | |
| dir_part="📂 ${CYAN}${dir_base}/${BCYAN}${dir_name}${RST}" | |
| fi | |
| # ── GitHub Branch Link (make branch name a clickable link to /pulls) ──────── | |
| if [ -n "$cwd" ] && [ -n "$git_branch" ]; then | |
| remote_url=$(git -C "$cwd" --no-optional-locks remote get-url origin 2>/dev/null) | |
| repo_slug="" | |
| if [ -n "$remote_url" ]; then | |
| repo_slug=$(echo "$remote_url" | sed -E \ | |
| -e 's#^git@github\.com:##' \ | |
| -e 's#^https?://github\.com/##' \ | |
| -e 's#\.git$##') | |
| owner=$(echo "$repo_slug" | cut -d/ -f1) | |
| repo=$(echo "$repo_slug" | cut -d/ -f2) | |
| # Validate: owner and repo must be different (i.e. slug contains a slash) | |
| [ -z "$owner" ] || [ -z "$repo" ] || [ "$owner" = "$repo_slug" ] && repo_slug="" | |
| fi | |
| if [ -n "$repo_slug" ]; then | |
| gh_url="https://github.com/${repo_slug}" | |
| git_part="🌿 \033]8;;${gh_url}/pulls\a${GREEN}${git_branch}${RST}\033]8;;\a" | |
| [ -n "$changes" ] && git_part="${git_part} ${changes}" | |
| fi | |
| fi | |
| line2="${git_part} ${timer_part} | ${dir_part}" | |
| # ── Output ─────────────────────────────────────────────────────────────────── | |
| printf '%b\n%b\n' "$line1" "$line2" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment