Skip to content

Instantly share code, notes, and snippets.

@ondrasek
Last active February 23, 2026 12:41
Show Gist options
  • Select an option

  • Save ondrasek/1f801107be6b7e391b488c723b125260 to your computer and use it in GitHub Desktop.

Select an option

Save ondrasek/1f801107be6b7e391b488c723b125260 to your computer and use it in GitHub Desktop.
Claude Code statusline script.
#!/usr/bin/env bash
# Claude Code status line script (bash port of PowerShell original)
# Reads JSON session data from stdin, outputs multiple lines.
# Line order is controlled by the render_* call sequence in section 17.
# --- 1. Constants ---
# oh-my-posh inspired true-color palette
C_BLUE=$'\033[38;2;0;153;255m'
C_ORANGE=$'\033[38;2;255;176;85m'
C_GREEN=$'\033[38;2;0;160;0m'
C_CYAN=$'\033[38;2;46;149;153m'
C_RED=$'\033[38;2;255;85;85m'
C_YELLOW=$'\033[38;2;230;200;0m'
C_DIM=$'\033[38;2;80;80;80m'
C_WHITE=$'\033[38;2;220;220;220m'
C_RESET=$'\033[0m'
# Semantic aliases (change one line here to restyle globally)
C_TITLE="$C_WHITE" # label prefixes: "session:", "git:", "hooks:", etc.
C_EMPTY="$C_DIM" # empty-state placeholders: "none", "(waiting for data)"
C_VALUE="$C_CYAN" # primary data values: percentages, costs, language versions
C_ACCENT="$C_ORANGE" # secondary emphasis: token counts, agent name, vim mode, ETA
C_GOOD="$C_GREEN" # positive states: clean, passing, lines added
C_BAD="$C_RED" # negative states: failing, compaction, lines removed
C_WARN="$C_YELLOW" # warnings/attention: pending, stash count, concurrent sessions
C_MUTED="$C_DIM" # structural dim: separators, brackets, bar empties, reset times
C_MODEL="$C_BLUE" # model name display
USAGE_CACHE="/tmp/claude-statusline-usage.json"
USAGE_CACHE_TTL=20
PR_CACHE_TTL=20
# --- 2. Fallback trap ---
set -eE # errexit + inherit ERR trap into subshells and command substitutions
trap 'printf "Claude"; exit 0' ERR
# --- 3. Guard: require jq ---
command -v jq >/dev/null 2>&1 || { printf 'Claude'; exit 0; }
# --- 4. Functions ---
# Portable date parsing: converts ISO 8601 timestamp to epoch seconds.
# GNU date uses -d, macOS/BSD date uses -jf.
date_to_epoch() {
local ts=$1
date -d "$ts" +%s 2>/dev/null && return
# macOS/BSD: strip fractional seconds for parsing
# Check for Z on the original string BEFORE stripping fractional part,
# because .NNNZ gets removed together by %%.*
local clean="$ts"
local is_utc=false
[[ "$clean" == *Z ]] && is_utc=true
clean=${clean%Z} # drop trailing Z if present
clean=${clean%%.*} # drop .NNN fractional part
if [ "$is_utc" = true ]; then
TZ=UTC date -jf "%Y-%m-%dT%H:%M:%S" "$clean" +%s 2>/dev/null && return
else
date -jf "%Y-%m-%dT%H:%M:%S" "$clean" +%s 2>/dev/null && return
fi
return 1
}
# Portable date formatting: formats ISO 8601 timestamp with a strftime format.
date_fmt() {
local ts=$1 fmt=$2
date -d "$ts" "+$fmt" 2>/dev/null && return
local clean="$ts"
local is_utc=false
[[ "$clean" == *Z ]] && is_utc=true
clean=${clean%Z}
clean=${clean%%.*}
if [ "$is_utc" = true ]; then
TZ=UTC date -jf "%Y-%m-%dT%H:%M:%S" "$clean" "+$fmt" 2>/dev/null && return
else
date -jf "%Y-%m-%dT%H:%M:%S" "$clean" "+$fmt" 2>/dev/null && return
fi
return 1
}
# Portable date arithmetic: outputs formatted date for an expression.
# GNU: date -d "$expr" "+$fmt", macOS: date -v modifier.
date_calc() {
local expr=$1 fmt=$2
date -d "$expr" "+$fmt" 2>/dev/null && return
# Fallback for macOS: only supports "+1 month from 1st" used below
if [[ "$expr" == *"+1 month"* ]]; then
date -v1d -v+1m "+$fmt" 2>/dev/null && return
fi
return 1
}
# Portable file mtime: returns epoch seconds of file's last modification.
# GNU stat uses -c %Y, macOS/BSD stat uses -f %m.
file_mtime() {
stat -c %Y "$1" 2>/dev/null && return
stat -f %m "$1" 2>/dev/null && return
echo 0
}
format_tokens() {
local n=$1
# Guard: only process numeric values to prevent awk injection
[[ "$n" =~ ^[0-9]+$ ]] || { printf '0'; return; }
if [ "$n" -ge 1000000 ] 2>/dev/null; then
awk -v n="$n" 'BEGIN {v=n/1000000; if (v==int(v)) printf "%dm",v; else printf "%.1fm",v}'
elif [ "$n" -ge 1000 ] 2>/dev/null; then
awk -v n="$n" 'BEGIN {printf "%dk", n/1000}'
else
printf '%d' "$n"
fi
}
format_number() {
# Add comma separators locale-independently: 155000 -> 155,000
awk -v n="${1:-0}" 'BEGIN {
s = sprintf("%d", n)
len = length(s)
r = ""
for (i = 1; i <= len; i++) {
if (i > 1 && (len - i + 1) % 3 == 0) r = r ","
r = r substr(s, i, 1)
}
print r
}'
}
color_for_pct() {
local pct=$1
if [ "$pct" -ge 90 ] 2>/dev/null; then printf '%s' "$C_BAD"
elif [ "$pct" -ge 70 ] 2>/dev/null; then printf '%s' "$C_WARN"
elif [ "$pct" -ge 50 ] 2>/dev/null; then printf '%s' "$C_ACCENT"
else printf '%s' "$C_GOOD"
fi
}
build_bar() {
local pct=${1:-0} width=${2:-10}
[ "$pct" -lt 0 ] 2>/dev/null && pct=0
[ "$pct" -gt 100 ] 2>/dev/null && pct=100
local filled=$((pct * width / 100))
[ "$filled" -gt "$width" ] && filled=$width
local empty=$((width - filled))
local bar_color
bar_color=$(color_for_pct "$pct")
local bar=""
if [ "$filled" -gt 0 ]; then
bar="${bar_color}$(printf '%0.s█' $(seq 1 "$filled"))"
fi
if [ "$empty" -gt 0 ]; then
bar="${bar}${C_MUTED}$(printf '%0.s░' $(seq 1 "$empty"))"
fi
printf '%s%s' "$bar" "$C_RESET"
}
format_reset_time_relative() {
local timestamp=$1
[ -z "$timestamp" ] && return
local reset_epoch now_epoch diff
reset_epoch=$(date_to_epoch "$timestamp") || return
now_epoch=$(date +%s)
diff=$((reset_epoch - now_epoch))
if [ "$diff" -gt 0 ]; then
local hours=$((diff / 3600))
local mins=$(((diff % 3600) / 60))
if [ "$hours" -gt 0 ]; then
printf '%dh%02dm' "$hours" "$mins"
else
printf '%dm' "$mins"
fi
else
printf 'resetting...'
fi
}
format_reset_time_absolute() {
local timestamp=$1
[ -z "$timestamp" ] && return
date_fmt "$timestamp" "%b %-d, %-I:%M%P" 2>/dev/null | tr '[:upper:]' '[:lower:]'
}
# --- 5. Parse stdin JSON (single jq call) ---
input=$(cat)
[ -z "$input" ] && { printf 'Claude'; exit 0; }
IFS='|' read -r DISPLAY_NAME PROJECT_DIR CTX_SIZE CTX_PCT INPUT_TOK CACHE_CREATE CACHE_READ \
TOTAL_COST TOTAL_DURATION TOTAL_API_DURATION LINES_ADDED LINES_REMOVED \
TOTAL_INPUT_TOK TOTAL_OUTPUT_TOK VIM_MODE AGENT_NAME SESSION_ID <<< \
"$(printf '%s' "$input" | jq -r '[
.model.display_name // "?",
.workspace.project_dir // ".",
(.context_window.context_window_size // 200000),
(.context_window.used_percentage // 0 | floor),
(.context_window.current_usage.input_tokens // 0),
(.context_window.current_usage.cache_creation_input_tokens // 0),
(.context_window.current_usage.cache_read_input_tokens // 0),
(.cost.total_cost_usd // 0),
(.cost.total_duration_ms // 0),
(.cost.total_api_duration_ms // 0),
(.cost.total_lines_added // 0),
(.cost.total_lines_removed // 0),
(.context_window.total_input_tokens // 0),
(.context_window.total_output_tokens // 0),
(.vim.mode // ""),
(.agent.name // ""),
(.session_id // "")
] | join("|")' 2>/dev/null)"
DISPLAY_NAME="${DISPLAY_NAME:-?}"
CTX_SIZE="${CTX_SIZE:-200000}"
CTX_PCT="${CTX_PCT%%.*}"
CTX_PCT="${CTX_PCT:-0}"
[ "$CTX_PCT" -eq "$CTX_PCT" ] 2>/dev/null || CTX_PCT=0
INPUT_TOK="${INPUT_TOK:-0}"
CACHE_CREATE="${CACHE_CREATE:-0}"
CACHE_READ="${CACHE_READ:-0}"
TOTAL_COST="${TOTAL_COST:-0}"
TOTAL_DURATION="${TOTAL_DURATION:-0}"
TOTAL_API_DURATION="${TOTAL_API_DURATION:-0}"
LINES_ADDED="${LINES_ADDED:-0}"
LINES_REMOVED="${LINES_REMOVED:-0}"
TOTAL_INPUT_TOK="${TOTAL_INPUT_TOK:-0}"
TOTAL_OUTPUT_TOK="${TOTAL_OUTPUT_TOK:-0}"
VIM_MODE="${VIM_MODE:-}"
AGENT_NAME="${AGENT_NAME:-}"
SESSION_ID="${SESSION_ID:-}"
# --- 6. Git branch ---
GIT_BRANCH=""
GIT_WORKTREE=""
GIT_TOPLEVEL=""
GIT_CWD_REL=""
GIT_ORIGIN_REPO=""
GIT_UPSTREAM_REPO=""
IS_GIT_REPO=false
if git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
IS_GIT_REPO=true
GIT_BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null)
# Working copy folder name and cwd relative to repo root
GIT_TOPLEVEL=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null)
GIT_TOPLEVEL_NAME="${GIT_TOPLEVEL##*/}"
if [ -n "$GIT_TOPLEVEL" ] && [ -n "$PROJECT_DIR" ]; then
rel="${PROJECT_DIR#"$GIT_TOPLEVEL"}"
rel="${rel#/}"
GIT_CWD_REL="${rel:-.}"
fi
# Detect worktree: git-dir differs from git-common-dir in worktrees
git_dir=$(git -C "$PROJECT_DIR" rev-parse --git-dir 2>/dev/null)
git_common=$(git -C "$PROJECT_DIR" rev-parse --git-common-dir 2>/dev/null)
if [ -n "$git_dir" ] && [ -n "$git_common" ] && [ "$git_dir" != "$git_common" ]; then
GIT_WORKTREE="${GIT_TOPLEVEL_NAME}"
fi
# Origin remote: try gh first, fall back to git remote -v
if command -v gh >/dev/null 2>&1; then
GIT_ORIGIN_REPO=$(cd "$PROJECT_DIR" && gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null) || GIT_ORIGIN_REPO=""
fi
if [ -z "$GIT_ORIGIN_REPO" ]; then
origin_url=$(git -C "$PROJECT_DIR" remote get-url origin 2>/dev/null) || origin_url=""
if [ -n "$origin_url" ]; then
# Parse owner/repo from SSH or HTTPS URLs
GIT_ORIGIN_REPO=$(printf '%s' "$origin_url" | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')
fi
fi
# Upstream remote (common for forks)
upstream_url=$(git -C "$PROJECT_DIR" remote get-url upstream 2>/dev/null) || upstream_url=""
if [ -n "$upstream_url" ]; then
GIT_UPSTREAM_REPO=$(printf '%s' "$upstream_url" | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')
fi
fi
# --- 7. Context window calculations ---
CURRENT_TOKENS=$((INPUT_TOK + CACHE_CREATE + CACHE_READ))
REMAIN_PCT=$((100 - CTX_PCT))
REMAIN_TOKENS=$((CTX_SIZE - CURRENT_TOKENS))
[ "$REMAIN_TOKENS" -lt 0 ] 2>/dev/null && REMAIN_TOKENS=0
USED_FMT=$(format_tokens "$CURRENT_TOKENS")
TOTAL_FMT=$(format_tokens "$CTX_SIZE")
USED_COMMA=$(format_number "$CURRENT_TOKENS")
REMAIN_COMMA=$(format_number "$REMAIN_TOKENS")
# --- 8. Thinking status ---
THINKING="off"
THINKING_COLOR="$C_MUTED"
if [ -f "$HOME/.claude/settings.json" ]; then
thinking_val=$(jq -r '.alwaysThinkingEnabled // false' "$HOME/.claude/settings.json" 2>/dev/null)
[ "$thinking_val" = "true" ] && THINKING="on" && THINKING_COLOR="$C_ACCENT"
fi
if [ -n "$MAX_THINKING_TOKENS" ] && [ "$MAX_THINKING_TOKENS" != "0" ]; then
THINKING="on"
THINKING_COLOR="$C_ACCENT"
fi
# --- 9. Usage API fetch (cached) ---
HAVE_USAGE=false
fetch_usage() {
local token=""
# Try credentials file first (Linux/devcontainer)
local creds="$HOME/.claude/.credentials.json"
if [ -f "$creds" ]; then
token=$(jq -r '.claudeAiOauth.accessToken // empty' "$creds" 2>/dev/null)
fi
# Fallback: macOS Keychain
if [ -z "$token" ] && command -v security >/dev/null 2>&1; then
local keychain_json
keychain_json=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || keychain_json=""
if [ -n "$keychain_json" ]; then
token=$(printf '%s' "$keychain_json" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
fi
fi
[ -z "$token" ] && return 1
local response http_code body curl_status
# Pass credentials via curl --config stdin to keep them in memory only
# (avoids leaking token to temp files or process argument list)
response=$(printf 'header = "Authorization: Bearer %s"\n' "$token" | \
curl -s -m 5 -w "\n%{http_code}" \
--config - \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "User-Agent: claude-code/2.1.34" \
-H "anthropic-beta: oauth-2025-04-20" \
"https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
curl_status=$?
[ $curl_status -eq 0 ] || return 1
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
[ "$http_code" = "200" ] || return 1
local tmp
tmp=$(mktemp "${USAGE_CACHE}.XXXXXX") || return 1
chmod 600 "$tmp"
printf '%s' "$body" > "$tmp" && mv "$tmp" "$USAGE_CACHE" 2>/dev/null || rm -f "$tmp"
}
now=${now:-$(date +%s)}
cache_age=999
if [ -f "$USAGE_CACHE" ]; then
cache_mtime=$(file_mtime "$USAGE_CACHE")
cache_age=$((now - cache_mtime))
fi
if [ "$cache_age" -ge "$USAGE_CACHE_TTL" ]; then
fetch_usage 2>/dev/null || true
fi
# --- 10. Parse usage cache ---
FIVE_HOUR_PCT=0; FIVE_HOUR_RESET=""
SEVEN_DAY_PCT=0; SEVEN_DAY_RESET=""
EXTRA_ENABLED=false; EXTRA_PCT=0; EXTRA_USED="0"; EXTRA_LIMIT="0"
if [ -f "$USAGE_CACHE" ]; then
parsed=$(jq -r '[
((.five_hour.utilization // 0) | round),
(.five_hour.resets_at // ""),
((.seven_day.utilization // 0) | round),
(.seven_day.resets_at // ""),
(.extra_usage.is_enabled // false),
((.extra_usage.utilization // 0) | round),
((.extra_usage.used_credits // 0) / 100 * 100 | round / 100),
((.extra_usage.monthly_limit // 0) / 100 * 100 | round / 100)
] | @tsv' "$USAGE_CACHE" 2>/dev/null) || parsed=""
if [ -n "$parsed" ]; then
read -r FIVE_HOUR_PCT FIVE_HOUR_RESET SEVEN_DAY_PCT SEVEN_DAY_RESET \
EXTRA_ENABLED EXTRA_PCT EXTRA_USED EXTRA_LIMIT <<< "$parsed"
FIVE_HOUR_PCT="${FIVE_HOUR_PCT:-0}"
SEVEN_DAY_PCT="${SEVEN_DAY_PCT:-0}"
EXTRA_PCT="${EXTRA_PCT:-0}"
HAVE_USAGE=true
fi
fi
# --- 11. Hooks aggregation ---
HOOKS_LINE=""
if [ -d "$PROJECT_DIR/.claude" ]; then
all_hooks=""
for settings_file in "$PROJECT_DIR/.claude/settings.json" "$PROJECT_DIR/.claude/settings.local.json"; do
[ -f "$settings_file" ] || continue
file_hooks=$(jq -r '
.hooks // {} | to_entries[] | .key as $ev |
.value[].hooks[]?.command //empty |
"\($ev)|\(split("/")[-1] | sub("\\.(sh|py|js|ts)$";""))"
' "$settings_file" 2>/dev/null) || file_hooks=""
if [ -n "$file_hooks" ]; then
all_hooks="${all_hooks}${all_hooks:+$'\n'}${file_hooks}"
fi
done
if [ -n "$all_hooks" ]; then
# Deduplicate and group by event
HOOKS_LINE=$(printf '%s' "$all_hooks" | sort -u | awk -F'|' '
{ events[$1] = events[$1] ? events[$1] ", " $2 : $2 }
END { for (ev in events) printf "%s[%s] ", ev, events[ev] }
')
HOOKS_LINE="${HOOKS_LINE% }" # trim trailing space
fi
fi
# Hook last-run times and results from session debug log
# Log format: "2026-02-22T10:09:49.260Z [DEBUG] Hook SessionStart:startup (SessionStart) success:"
# Stores per event: "timestamp|success", "timestamp|error", or "timestamp|cancelled"
declare -A HOOK_LAST_RUN 2>/dev/null || true # associative array; silently skip on bash <4
if [ -n "$SESSION_ID" ]; then
debug_log="$HOME/.claude/debug/${SESSION_ID}.txt"
if [ -f "$debug_log" ]; then
while IFS='|' read -r evt ts_raw result; do
[ -n "$evt" ] && HOOK_LAST_RUN["$evt"]="${ts_raw}|${result}"
done < <(
grep -E '\[DEBUG\] Hook .* (success|error|cancelled):' "$debug_log" 2>/dev/null \
| grep -v 'output does not' \
| sed -E 's/^([^ ]+) \[DEBUG\] Hook [^ ]+ \(([^)]+)\) (success|error|cancelled):.*/\2|\1|\3/' \
| sort -t'|' -k1,1 -k2,2 # keeps last occurrence per event after read loop
) || true
fi
fi
format_hook_age() {
local ts=$1
[ -z "$ts" ] && return
local hook_epoch
hook_epoch=$(date_to_epoch "$ts") || return
local diff=$((now - hook_epoch))
[ "$diff" -lt 0 ] && diff=0
if [ "$diff" -lt 60 ]; then
printf '%ds ago' "$diff"
elif [ "$diff" -lt 3600 ]; then
printf '%dm ago' "$((diff / 60))"
elif [ "$diff" -lt 86400 ]; then
local h=$((diff / 3600)) m=$(((diff % 3600) / 60))
if [ "$m" -gt 0 ]; then
printf '%dh%dm ago' "$h" "$m"
else
printf '%dh ago' "$h"
fi
else
printf '%dd ago' "$((diff / 86400))"
fi
}
# --- 12. Git detail ---
GIT_DIRTY=""
GIT_AHEAD_BEHIND=""
GIT_STASH_COUNT=0
if [ "$IS_GIT_REPO" = true ]; then
# Dirty state: count staged, modified, untracked
git_status=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null) || git_status=""
if [ -n "$git_status" ]; then
staged=0; modified=0; untracked=0
while IFS= read -r status_line; do
x="${status_line:0:1}"
y="${status_line:1:1}"
case "$x" in
A|M|R) staged=$((staged + 1)) ;;
esac
case "$y" in
M|D) modified=$((modified + 1)) ;;
esac
if [ "$x$y" = "??" ]; then
untracked=$((untracked + 1))
fi
done <<< "$git_status"
parts=""
[ "$staged" -gt 0 ] && parts="+${staged} staged"
[ "$modified" -gt 0 ] && parts="${parts:+${parts} }~${modified} modified"
[ "$untracked" -gt 0 ] && parts="${parts:+${parts} }?${untracked} untracked"
GIT_DIRTY="$parts"
fi
# Ahead/behind upstream
ab=$(git -C "$PROJECT_DIR" rev-list --left-right --count HEAD...@{u} 2>/dev/null) || ab=""
if [ -n "$ab" ]; then
read -r ahead behind <<< "$ab"
parts=""
[ "$ahead" -gt 0 ] 2>/dev/null && parts="↑${ahead} ahead"
[ "$behind" -gt 0 ] 2>/dev/null && parts="${parts:+${parts} }↓${behind} behind"
GIT_AHEAD_BEHIND="$parts"
fi
# Worktree count (includes the main worktree)
GIT_WORKTREE_COUNT=$(git -C "$PROJECT_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
GIT_WORKTREE_COUNT="${GIT_WORKTREE_COUNT:-1}"
# Stash count
GIT_STASH_COUNT=$(git -C "$PROJECT_DIR" stash list 2>/dev/null | wc -l | tr -d ' ')
GIT_STASH_COUNT="${GIT_STASH_COUNT:-0}"
fi
# --- 13. Environment: PR/CI and Language ---
PR_STATUS=""
LANG_VERSION=""
# PR/CI status (requires gh CLI)
if [ "$IS_GIT_REPO" = true ] && command -v gh >/dev/null 2>&1; then
dir_key=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
pr_cache="/tmp/claude-statusline-pr-${dir_key}.json"
pr_cache_age=999
if [ -f "$pr_cache" ]; then
pr_mtime=$(file_mtime "$pr_cache")
pr_cache_age=$((now - pr_mtime))
fi
if [ "$pr_cache_age" -ge "$PR_CACHE_TTL" ]; then
pr_json=$(cd "$PROJECT_DIR" && gh pr view --json number,state,statusCheckRollup 2>/dev/null) || pr_json=""
if [ -n "$pr_json" ]; then
printf '%s' "$pr_json" > "$pr_cache" 2>/dev/null || true
else
# No PR — write empty marker so we don't re-fetch constantly
printf '{}' > "$pr_cache" 2>/dev/null || true
fi
fi
if [ -f "$pr_cache" ]; then
pr_num=$(jq -r '.number // empty' "$pr_cache" 2>/dev/null) || pr_num=""
if [ -n "$pr_num" ]; then
ci_status=$(jq -r '
(.statusCheckRollup // []) |
if length == 0 then "none"
elif any(.[]; .conclusion == "FAILURE") then "failing"
elif any(.[]; .conclusion == null or .conclusion == "") then "pending"
else "passing"
end
' "$pr_cache" 2>/dev/null) || ci_status="none"
case "$ci_status" in
passing) PR_STATUS="${C_TITLE}pr ${C_MUTED}(cached)${C_TITLE}:${C_RESET} #${pr_num} ${C_GOOD}✓ passing${C_RESET}" ;;
failing) PR_STATUS="${C_TITLE}pr ${C_MUTED}(cached)${C_TITLE}:${C_RESET} #${pr_num} ${C_BAD}✗ failing${C_RESET}" ;;
pending) PR_STATUS="${C_TITLE}pr ${C_MUTED}(cached)${C_TITLE}:${C_RESET} #${pr_num} ${C_WARN}● pending${C_RESET}" ;;
*) PR_STATUS="${C_TITLE}pr ${C_MUTED}(cached)${C_TITLE}:${C_RESET} #${pr_num}" ;;
esac
fi
fi
fi
# Language/runtime detection (first match wins)
if [ -n "$PROJECT_DIR" ]; then
if [ -f "${PROJECT_DIR}/go.mod" ] && command -v go >/dev/null 2>&1; then
ver=$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//') || ver=""
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}go ${ver}${C_RESET}"
elif [ -f "${PROJECT_DIR}/package.json" ] && command -v node >/dev/null 2>&1; then
ver=$(node --version 2>/dev/null | sed 's/^v//') || ver=""
ver=$(printf '%s' "$ver" | awk -F. '{print $1"."$2}')
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}node ${ver}${C_RESET}"
elif [ -f "${PROJECT_DIR}/Cargo.toml" ] && command -v rustc >/dev/null 2>&1; then
ver=$(rustc --version 2>/dev/null | awk '{print $2}') || ver=""
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}rust ${ver}${C_RESET}"
elif [ -f "${PROJECT_DIR}/pyproject.toml" ] || [ -f "${PROJECT_DIR}/requirements.txt" ] || [ -f "${PROJECT_DIR}/.python-version" ]; then
if command -v python3 >/dev/null 2>&1; then
ver=$(python3 --version 2>/dev/null | awk '{print $2}') || ver=""
ver=$(printf '%s' "$ver" | awk -F. '{print $1"."$2}')
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}python ${ver}${C_RESET}"
fi
elif [ -f "${PROJECT_DIR}/Gemfile" ] && command -v ruby >/dev/null 2>&1; then
ver=$(ruby --version 2>/dev/null | awk '{print $2}') || ver=""
ver=$(printf '%s' "$ver" | awk -F. '{print $1"."$2}')
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}ruby ${ver}${C_RESET}"
elif [ -f "${PROJECT_DIR}/mix.exs" ] && command -v elixir >/dev/null 2>&1; then
ver=$(elixir --version 2>/dev/null | grep 'Elixir' | awk '{print $2}') || ver=""
ver=$(printf '%s' "$ver" | awk -F. '{print $1"."$2}')
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}elixir ${ver}${C_RESET}"
elif [ -f "${PROJECT_DIR}/pom.xml" ] || [ -f "${PROJECT_DIR}/build.gradle" ]; then
if command -v java >/dev/null 2>&1; then
ver=$(java --version 2>&1 | head -1 | awk '{print $2}') || ver=""
ver=$(printf '%s' "$ver" | awk -F. '{print $1}')
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}java ${ver}${C_RESET}"
fi
elif [ -f "${PROJECT_DIR}/composer.json" ] && command -v php >/dev/null 2>&1; then
ver=$(php --version 2>/dev/null | head -1 | awk '{print $2}') || ver=""
ver=$(printf '%s' "$ver" | awk -F. '{print $1"."$2}')
[ -n "$ver" ] && LANG_VERSION="${C_VALUE}php ${ver}${C_RESET}"
fi
fi
# Package/build tool detection
PKG_TOOL=""
if [ -n "$PROJECT_DIR" ]; then
if [ -f "${PROJECT_DIR}/uv.lock" ] || grep -q '\[tool\.uv\]' "${PROJECT_DIR}/pyproject.toml" 2>/dev/null; then
PKG_TOOL="uv"
elif [ -f "${PROJECT_DIR}/Pipfile" ] || [ -f "${PROJECT_DIR}/Pipfile.lock" ]; then
PKG_TOOL="pipenv"
elif [ -f "${PROJECT_DIR}/poetry.lock" ] || grep -q '\[tool\.poetry\]' "${PROJECT_DIR}/pyproject.toml" 2>/dev/null; then
PKG_TOOL="poetry"
elif [ -f "${PROJECT_DIR}/conda.yaml" ] || [ -f "${PROJECT_DIR}/environment.yml" ]; then
PKG_TOOL="conda"
elif [ -f "${PROJECT_DIR}/requirements.txt" ] || [ -f "${PROJECT_DIR}/setup.py" ] || [ -f "${PROJECT_DIR}/setup.cfg" ]; then
PKG_TOOL="pip"
elif [ -f "${PROJECT_DIR}/pnpm-lock.yaml" ]; then
PKG_TOOL="pnpm"
elif [ -f "${PROJECT_DIR}/yarn.lock" ]; then
PKG_TOOL="yarn"
elif [ -f "${PROJECT_DIR}/bun.lockb" ] || [ -f "${PROJECT_DIR}/bun.lock" ]; then
PKG_TOOL="bun"
elif [ -f "${PROJECT_DIR}/package-lock.json" ]; then
PKG_TOOL="npm"
elif [ -f "${PROJECT_DIR}/Cargo.lock" ]; then
PKG_TOOL="cargo"
elif [ -f "${PROJECT_DIR}/go.sum" ]; then
PKG_TOOL="go mod"
elif [ -f "${PROJECT_DIR}/Gemfile.lock" ]; then
PKG_TOOL="bundler"
elif [ -f "${PROJECT_DIR}/mix.lock" ]; then
PKG_TOOL="mix"
elif [ -f "${PROJECT_DIR}/composer.lock" ]; then
PKG_TOOL="composer"
fi
fi
# --- 14. Session metrics ---
SESSION_COST_FMT=""
SESSION_LINES_FMT=""
API_LATENCY_FMT=""
CACHE_HIT_FMT=""
BURN_RATE_FMT=""
CONCURRENT_FMT=""
HAVE_SESSION_DATA=false
# Check if we have meaningful session data
if [ "$TOTAL_COST" != "0" ] || [ "$LINES_ADDED" != "0" ] || [ "$LINES_REMOVED" != "0" ] || [ "$TOTAL_DURATION" != "0" ]; then
HAVE_SESSION_DATA=true
# Cost (round to 2 decimal places)
cost_fmt=$(awk -v c="$TOTAL_COST" 'BEGIN { printf "%.2f", c }')
SESSION_COST_FMT="${C_TITLE}session:${C_RESET} ${C_VALUE}\$${cost_fmt}${C_RESET}"
# Lines changed
if [ "$LINES_ADDED" != "0" ] || [ "$LINES_REMOVED" != "0" ]; then
SESSION_LINES_FMT="${C_GOOD}+${LINES_ADDED}${C_RESET} ${C_BAD}-${LINES_REMOVED}${C_RESET} lines"
fi
# API latency ratio
if [ "$TOTAL_DURATION" -gt 0 ] 2>/dev/null; then
api_pct=$(awk -v api="$TOTAL_API_DURATION" -v total="$TOTAL_DURATION" \
'BEGIN { printf "%.0f", (api / total) * 100 }')
API_LATENCY_FMT="${C_TITLE}API:${C_RESET} ${C_VALUE}${api_pct}%${C_RESET}"
fi
# Cache hit rate
total_cache_tokens=$((INPUT_TOK + CACHE_CREATE + CACHE_READ))
if [ "$total_cache_tokens" -gt 0 ] 2>/dev/null; then
cache_pct=$(awk -v cr="$CACHE_READ" -v total="$total_cache_tokens" \
'BEGIN { printf "%.0f", (cr / total) * 100 }')
CACHE_HIT_FMT="${C_TITLE}cache:${C_RESET} ${C_VALUE}${cache_pct}%${C_RESET}"
fi
# Burn rate + depletion ETA
if [ "$TOTAL_DURATION" -gt 0 ] 2>/dev/null; then
total_tok=$((TOTAL_INPUT_TOK + TOTAL_OUTPUT_TOK))
if [ "$total_tok" -gt 0 ] 2>/dev/null; then
burn_rate=$(awk -v tok="$total_tok" -v dur="$TOTAL_DURATION" \
'BEGIN { printf "%.0f", tok / (dur / 60000) }')
# Format rate display
burn_display=$(awk -v r="$burn_rate" \
'BEGIN { if (r >= 1000) printf "~%.1fk tok/min", r/1000; else printf "~%.0f tok/min", r }')
# ETA
eta_display=""
if [ "$burn_rate" -gt 0 ] 2>/dev/null; then
eta_min=$(awk -v rem="$REMAIN_TOKENS" -v rate="$burn_rate" \
'BEGIN { printf "%.0f", rem / rate }')
if [ "$eta_min" -gt 60 ] 2>/dev/null; then
eta_hrs=$((eta_min / 60))
eta_mins=$((eta_min % 60))
eta_display="~${eta_hrs}h${eta_mins}m left"
elif [ "$eta_min" -gt 0 ] 2>/dev/null; then
eta_display="~${eta_min}m left"
fi
fi
BURN_RATE_FMT="${C_VALUE}${burn_display}${C_RESET}"
[ -n "$eta_display" ] && BURN_RATE_FMT="${BURN_RATE_FMT} ${C_MUTED}→${C_RESET} ${C_ACCENT}${eta_display}${C_RESET}"
fi
fi
fi
# Concurrent sessions
if [ -n "$SESSION_ID" ]; then
touch "/tmp/claude-session-${SESSION_ID}.marker" 2>/dev/null || true
concurrent_count=$(find /tmp -maxdepth 1 -name "claude-session-*.marker" -mmin -2 2>/dev/null | wc -l | tr -d ' ')
[ "$concurrent_count" -gt 1 ] 2>/dev/null && CONCURRENT_FMT="${C_WARN}x${concurrent_count}${C_RESET}"
fi
# --- 15. Rules files discovery ---
RULES_LINE=""
rules_files=()
for candidate in \
"${PROJECT_DIR}/CLAUDE.md" \
"${PROJECT_DIR}/CLAUDE.local.md" \
"${PROJECT_DIR}/.claude/CLAUDE.md" \
"$HOME/.claude/CLAUDE.md"; do
[ -f "$candidate" ] && rules_files+=("$candidate")
done
if [ -d "${PROJECT_DIR}/.claude/rules" ]; then
for f in "${PROJECT_DIR}/.claude/rules"/*.md; do
[ -f "$f" ] && rules_files+=("$f")
done
fi
if [ ${#rules_files[@]} -gt 0 ]; then
display_names=""
for rf in "${rules_files[@]}"; do
case "$rf" in
"$HOME/.claude/CLAUDE.md")
name="~/.claude/CLAUDE.md" ;;
"${PROJECT_DIR}/"*)
name="${rf#"${PROJECT_DIR}/"}" ;;
*)
name="$rf" ;;
esac
display_names="${display_names:+${display_names} }${name}"
done
RULES_LINE="$display_names"
fi
# --- 16. Render functions ---
# Each function prints its line(s) preceded by \n (except render_model which is first).
# Reorder output by changing the call order in section 17.
sep=" ${C_MUTED}|${C_RESET} "
render_model() {
local used_color
used_color=$(color_for_pct "$CTX_PCT")
local line="${C_MODEL}${DISPLAY_NAME}${C_RESET}"
if [ -n "$AGENT_NAME" ]; then
line="${line} ${C_MUTED}(${C_ACCENT}${AGENT_NAME}${C_MUTED})${C_RESET}"
fi
line="${line}${sep}${C_ACCENT}${USED_FMT}/${TOTAL_FMT}${C_RESET}"
line="${line}${sep}"
if [ "$CTX_PCT" -ge 80 ] 2>/dev/null; then
line="${line}${C_BAD}${CTX_PCT}% used ⚠ COMPACTION${C_RESET} ${C_ACCENT}${USED_COMMA}${C_RESET}"
else
line="${line}${used_color}${CTX_PCT}% used${C_RESET} ${C_ACCENT}${USED_COMMA}${C_RESET}"
fi
line="${line}${sep}${C_VALUE}${REMAIN_PCT}% remain${C_RESET} ${C_MODEL}${REMAIN_COMMA}${C_RESET}"
line="${line}${sep}${C_TITLE}thinking:${C_RESET} ${THINKING_COLOR}${THINKING}${C_RESET}"
if [ -n "$VIM_MODE" ]; then
line="${line}${sep}${C_TITLE}vim:${C_RESET} ${C_ACCENT}${VIM_MODE}${C_RESET}"
fi
printf '%s' "$line"
}
render_usage() {
[ "$HAVE_USAGE" = true ] || return 0
local bar_width=10
local five_bar seven_bar
five_bar=$(build_bar "$FIVE_HOUR_PCT" "$bar_width")
seven_bar=$(build_bar "$SEVEN_DAY_PCT" "$bar_width")
local line="${C_TITLE}current ${C_MUTED}(cached)${C_TITLE}:${C_RESET} ${five_bar} ${C_VALUE}${FIVE_HOUR_PCT}%${C_RESET}"
line="${line}${sep}${C_TITLE}weekly:${C_RESET} ${seven_bar} ${C_VALUE}${SEVEN_DAY_PCT}%${C_RESET}"
if [ "$EXTRA_ENABLED" = "true" ]; then
local extra_bar
extra_bar=$(build_bar "$EXTRA_PCT" "$bar_width")
line="${line}${sep}${C_TITLE}extra:${C_RESET} ${extra_bar} ${C_VALUE}\$${EXTRA_USED}/\$${EXTRA_LIMIT}${C_RESET}"
fi
printf '\n%s' "$line"
# Reset times
local five_reset seven_reset
five_reset=$(format_reset_time_relative "$FIVE_HOUR_RESET")
seven_reset=$(format_reset_time_absolute "$SEVEN_DAY_RESET")
if [ -n "$five_reset" ] || [ -n "$seven_reset" ]; then
local rline=""
if [ -n "$five_reset" ]; then
rline="${C_TITLE}resets:${C_RESET} ${C_MUTED}${five_reset}${C_RESET}"
fi
if [ -n "$seven_reset" ]; then
[ -n "$rline" ] && rline="${rline}${sep}"
rline="${rline}${C_TITLE}resets:${C_RESET} ${C_MUTED}${seven_reset}${C_RESET}"
fi
if [ "$EXTRA_ENABLED" = "true" ]; then
local next_month_reset
next_month_reset=$(date_calc "$(date +%Y-%m-01) +1 month" "%b %-d" 2>/dev/null | tr '[:upper:]' '[:lower:]')
if [ -n "$next_month_reset" ]; then
rline="${rline}${sep}${C_TITLE}resets:${C_RESET} ${C_MUTED}${next_month_reset}${C_RESET}"
fi
fi
printf '\n%s' "$rline"
fi
}
render_session() {
local line
if [ "$HAVE_SESSION_DATA" = true ]; then
line="$SESSION_COST_FMT"
[ -n "$API_LATENCY_FMT" ] && line="${line}${sep}${API_LATENCY_FMT}"
[ -n "$CACHE_HIT_FMT" ] && line="${line}${sep}${CACHE_HIT_FMT}"
[ -n "$SESSION_LINES_FMT" ] && line="${line}${sep}${SESSION_LINES_FMT}"
[ -n "$BURN_RATE_FMT" ] && line="${line}${sep}${BURN_RATE_FMT}"
[ -n "$CONCURRENT_FMT" ] && line="${line}${sep}${CONCURRENT_FMT}"
else
line="${C_TITLE}session:${C_RESET} ${C_EMPTY}(waiting for data)${C_RESET}"
[ -n "$CONCURRENT_FMT" ] && line="${line}${sep}${CONCURRENT_FMT}"
fi
printf '\n%s' "$line"
}
render_git() {
if [ "$IS_GIT_REPO" != true ]; then
printf '\n%s' "${C_TITLE}git:${C_RESET} ${C_EMPTY}not a repo${C_RESET}"
return 0
fi
local line="${C_TITLE}git:${C_RESET} "
# Branch and worktree
if [ -n "$GIT_BRANCH" ]; then
line="${line}${C_VALUE}${GIT_BRANCH}${C_RESET}"
fi
local wt_count=" ${C_MUTED}${GIT_WORKTREE_COUNT:-1}wt${C_RESET}"
if [ -n "$GIT_WORKTREE" ]; then
line="${line} ${C_MUTED}[${C_ACCENT}${GIT_WORKTREE}${C_MUTED}]${C_RESET}${wt_count}"
else
line="${line} ${C_MUTED}[main]${wt_count}${C_RESET}"
fi
# Working copy folder and cwd
line="${line}${sep}${C_TITLE}dir:${C_RESET} ${C_VALUE}${GIT_TOPLEVEL_NAME:-?}${C_RESET}"
if [ -n "$GIT_CWD_REL" ] && [ "$GIT_CWD_REL" != "." ]; then
line="${line}${C_MUTED}/${C_ACCENT}${GIT_CWD_REL}${C_RESET}"
fi
# Origin and upstream on the same line
if [ -n "$GIT_ORIGIN_REPO" ]; then
line="${line}${sep}${C_TITLE}origin:${C_RESET} ${C_VALUE}${GIT_ORIGIN_REPO}${C_RESET}"
fi
if [ -n "$GIT_UPSTREAM_REPO" ]; then
line="${line}${sep}${C_TITLE}upstream:${C_RESET} ${C_ACCENT}${GIT_UPSTREAM_REPO}${C_RESET}"
fi
printf '\n%s' "$line"
# Second line: file status, ahead/behind, stash
local line3=""
# Dirty state or clean
if [ -n "$GIT_DIRTY" ]; then
line3="${C_GOOD}${GIT_DIRTY}${C_RESET}"
else
line3="${C_GOOD}clean${C_RESET}"
fi
# Ahead/behind
if [ -n "$GIT_AHEAD_BEHIND" ]; then
line3="${line3}${sep}${C_VALUE}${GIT_AHEAD_BEHIND}${C_RESET}"
fi
# Stash
if [ "$GIT_STASH_COUNT" -gt 0 ] 2>/dev/null; then
line3="${line3}${sep}${C_TITLE}stash:${C_RESET} ${C_WARN}${GIT_STASH_COUNT}${C_RESET}"
fi
printf '\n%s%s' "${C_TITLE}git status:${C_RESET} " "$line3"
}
render_hooks() {
if [ -n "$HOOKS_LINE" ]; then
# Inject inline last-run info into each "Event[commands]" token
local decorated="$HOOKS_LINE"
if [ "${#HOOK_LAST_RUN[@]}" -gt 0 ] 2>/dev/null; then
for evt in "${!HOOK_LAST_RUN[@]}"; do
local val="${HOOK_LAST_RUN[$evt]}"
local ts="${val%%|*}"
local result="${val##*|}"
local age
age=$(format_hook_age "$ts") || continue
[ -n "$age" ] || continue
local icon color name_color
if [ "$result" = "success" ]; then
icon="✓"; color="$C_GOOD"; name_color="$C_GOOD"
elif [ "$result" = "cancelled" ]; then
icon="⊘"; color="$C_WARN"; name_color="$C_WARN"
else
icon="✗"; color="$C_BAD"; name_color="$C_BAD"
fi
local suffix=" ${color}${icon}${C_RESET} ${C_MUTED}${age}${C_RESET}"
decorated="${decorated//${evt}\[/${name_color}${evt}${C_RESET}${suffix} \[}"
done
fi
printf '\n%s%s' "${C_TITLE}hooks:${C_RESET} " "$decorated"
else
printf '\n%s' "${C_TITLE}hooks:${C_RESET} ${C_EMPTY}none${C_RESET}"
fi
}
render_rules() {
if [ -n "$RULES_LINE" ]; then
printf '\n%s%s%s' "${C_TITLE}rules:${C_RESET} " "$RULES_LINE" ""
else
printf '\n%s' "${C_TITLE}rules:${C_RESET} ${C_EMPTY}none${C_RESET}"
fi
}
render_environment() {
if [ -n "$PR_STATUS" ] || [ -n "$LANG_VERSION" ] || [ -n "$PKG_TOOL" ]; then
local line=""
[ -n "$PR_STATUS" ] && line="${PR_STATUS}"
if [ -n "$LANG_VERSION" ]; then
[ -n "$line" ] && line="${line}${sep}"
line="${line}${LANG_VERSION}"
fi
if [ -n "$PKG_TOOL" ]; then
[ -n "$line" ] && line="${line}${sep}"
line="${line}${C_MUTED}via${C_RESET} ${C_VALUE}${PKG_TOOL}${C_RESET}"
fi
printf '\n%s%s' "${C_TITLE}env:${C_RESET} " "$line"
else
printf '\n%s' "${C_TITLE}env:${C_RESET} ${C_EMPTY}none${C_RESET}"
fi
}
# --- 17. Output (reorder lines by changing call order below) ---
render_model
render_usage
render_session
render_git
render_hooks
render_rules
render_environment
# Clean up stale session markers (older than 5 minutes)
find /tmp -maxdepth 1 -name "claude-session-*.marker" -mmin +5 -delete 2>/dev/null || true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment