|
#!/bin/bash |
|
# Line 1: Model | tokens used/total | % used <fullused> | % remain <fullremain> | thinking: on/off |
|
# Line 2: project: <folder> | current: <progressbar> % | weekly: <progressbar> % | extra: <progressbar> $used/$limit |
|
# Line 3: branch: <branch> | resets <time> | resets <datetime> | resets <date> |
|
|
|
set -f # disable globbing |
|
|
|
input=$(cat) |
|
|
|
if [ -z "$input" ]; then |
|
printf "Claude" |
|
exit 0 |
|
fi |
|
|
|
# ANSI colors matching oh-my-posh theme |
|
blue='\033[38;2;0;153;255m' |
|
orange='\033[38;2;255;176;85m' |
|
green='\033[38;2;0;160;0m' |
|
cyan='\033[38;2;46;149;153m' |
|
red='\033[38;2;255;85;85m' |
|
yellow='\033[38;2;230;200;0m' |
|
white='\033[38;2;220;220;220m' |
|
dim='\033[2m' |
|
reset='\033[0m' |
|
|
|
# Format token counts (e.g., 50k / 200k) |
|
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 |
|
} |
|
|
|
# Format number with commas (e.g., 134,938) |
|
format_commas() { |
|
printf "%'d" "$1" |
|
} |
|
|
|
# Build a colored progress bar |
|
# Usage: build_bar <pct> <width> |
|
build_bar() { |
|
local pct=$1 |
|
local 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 )) |
|
|
|
# Color based on usage level |
|
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 filled_str="" empty_str="" |
|
for ((i=0; i<filled; i++)); do filled_str+="●"; done |
|
for ((i=0; i<empty; i++)); do empty_str+="○"; done |
|
|
|
printf "${bar_color}${filled_str}${dim}${empty_str}${reset}" |
|
} |
|
|
|
# ===== Extract data from JSON ===== |
|
model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"') |
|
|
|
# Current working directory |
|
cwd=$(echo "$input" | jq -r '.cwd // empty') |
|
|
|
# Git branch, worktree, and dirty state detection |
|
git_branch="" |
|
git_worktree="" |
|
git_dirty=false |
|
if [ -n "$cwd" ] && command -v git >/dev/null 2>&1; then |
|
git_branch=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" symbolic-ref --short HEAD 2>/dev/null) |
|
if [ -n "$git_branch" ]; then |
|
# Check for uncommitted changes |
|
if [ -n "$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" status --porcelain 2>/dev/null)" ]; then |
|
git_dirty=true |
|
fi |
|
# Check if we're in a worktree (not the main working tree) |
|
git_toplevel=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) |
|
git_common=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) |
|
git_dir=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" rev-parse --git-dir 2>/dev/null) |
|
if [ -n "$git_dir" ] && [ -n "$git_common" ] && [ "$git_dir" != "$git_common" ]; then |
|
git_worktree=$(basename "$git_toplevel") |
|
fi |
|
fi |
|
fi |
|
|
|
# Folder name (last component of cwd) |
|
folder_name="" |
|
if [ -n "$cwd" ]; then |
|
folder_name=$(basename "$cwd") |
|
fi |
|
|
|
# Context window |
|
size=$(echo "$input" | jq -r '.context_window.context_window_size // 200000') |
|
[ "$size" -eq 0 ] 2>/dev/null && size=200000 |
|
|
|
# Token usage |
|
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) |
|
|
|
if [ "$size" -gt 0 ]; then |
|
pct_used=$(( current * 100 / size )) |
|
else |
|
pct_used=0 |
|
fi |
|
|
|
# Check thinking status |
|
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 |
|
|
|
# ===== LINE 1: Model (% used) | tokens | thinking ===== |
|
line1="" |
|
line1+="${blue}${model_name}${reset}" |
|
line1+=" ${dim}|${reset} " |
|
line1+="${green}${pct_used}% used${reset}" |
|
line1+=" ${dim}|${reset} " |
|
line1+="${orange}${used_tokens} / ${total_tokens}${reset}" |
|
line1+=" ${dim}|${reset} " |
|
line1+="thinking: " |
|
if $thinking_on; then |
|
line1+="${orange}On${reset}" |
|
else |
|
line1+="${dim}Off${reset}" |
|
fi |
|
|
|
# ===== Cross-platform OAuth token resolution (from statusline.sh) ===== |
|
# Tries credential sources in order: env var → macOS Keychain → Linux creds file → GNOME Keyring |
|
get_oauth_token() { |
|
local token="" |
|
# 1. Explicit env var override |
|
if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then |
|
echo "$CLAUDE_CODE_OAUTH_TOKEN" |
|
return 0 |
|
fi |
|
|
|
# 2. macOS Keychain |
|
if command -v security >/dev/null 2>&1; then |
|
local blob |
|
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) |
|
if [ -n "$token" ] && [ "$token" != "null" ]; then |
|
echo "$token" |
|
return 0 |
|
fi |
|
fi |
|
fi |
|
|
|
# 3. Linux credentials file |
|
local creds_file="${HOME}/.claude/.credentials.json" |
|
if [ -f "$creds_file" ]; then |
|
token=$(jq -r '.claudeAiOauth.accessToken // empty' "$creds_file" 2>/dev/null) |
|
if [ -n "$token" ] && [ "$token" != "null" ]; then |
|
echo "$token" |
|
return 0 |
|
fi |
|
fi |
|
|
|
# 4. GNOME Keyring via secret-tool |
|
if command -v secret-tool >/dev/null 2>&1; then |
|
local blob |
|
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) |
|
if [ -n "$token" ] && [ "$token" != "null" ]; then |
|
echo "$token" |
|
return 0 |
|
fi |
|
fi |
|
fi |
|
|
|
echo "" |
|
} |
|
|
|
# ===== LINE 2 & 3: Usage limits with progress bars (cached) ===== |
|
cache_file="/tmp/claude/statusline-usage-cache.json" |
|
lock_file="/tmp/claude/statusline-refresh.lock" |
|
cache_max_age=60 # seconds between API calls |
|
lock_max_age=10 # seconds before considering a lock stale (> curl timeout of 5s) |
|
mkdir -p /tmp/claude |
|
|
|
# Always read from cache first (even if stale) — never block on network I/O |
|
usage_data="" |
|
if [ -f "$cache_file" ]; then |
|
usage_data=$(cat "$cache_file" 2>/dev/null) |
|
fi |
|
|
|
# Check if cache needs refreshing |
|
needs_refresh=true |
|
if [ -f "$cache_file" ]; then |
|
cache_mtime=$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null) |
|
now=$(date +%s) |
|
cache_age=$(( now - cache_mtime )) |
|
[ "$cache_age" -lt "$cache_max_age" ] && needs_refresh=false |
|
fi |
|
|
|
# If stale, kick off a background refresh (non-blocking) |
|
if $needs_refresh; then |
|
# Check lock: skip if another refresh is already in progress |
|
launch_refresh=true |
|
if [ -f "$lock_file" ]; then |
|
lock_mtime=$(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null) |
|
now=${now:-$(date +%s)} |
|
lock_age=$(( now - lock_mtime )) |
|
if [ "$lock_age" -lt "$lock_max_age" ]; then |
|
launch_refresh=false # lock is fresh, another fetch is running |
|
else |
|
rm -f "$lock_file" # lock is stale, clear it |
|
fi |
|
fi |
|
|
|
if $launch_refresh; then |
|
( |
|
touch "$lock_file" |
|
token=$(get_oauth_token) |
|
if [ -n "$token" ] && [ "$token" != "null" ]; then |
|
response=$(curl -s --max-time 5 \ |
|
-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 |
|
echo "$response" > "$cache_file" |
|
fi |
|
fi |
|
rm -f "$lock_file" |
|
) & |
|
disown 2>/dev/null |
|
fi |
|
fi |
|
|
|
# Cross-platform ISO to epoch conversion |
|
# Converts ISO 8601 timestamp (e.g. "2025-06-15T12:30:00Z" or "2025-06-15T12:30:00.123+00:00") to epoch seconds. |
|
# Properly handles UTC timestamps and converts to local time. |
|
iso_to_epoch() { |
|
local iso_str="$1" |
|
# Try GNU date first (Linux) — handles ISO 8601 format automatically |
|
local epoch |
|
epoch=$(date -d "${iso_str}" +%s 2>/dev/null) |
|
if [ -n "$epoch" ]; then |
|
echo "$epoch" |
|
return 0 |
|
fi |
|
|
|
# BSD date (macOS) - handle various ISO 8601 formats |
|
local stripped="${iso_str%%.*}" # Remove fractional seconds (.123456) |
|
stripped="${stripped%%Z}" # Remove trailing Z |
|
stripped="${stripped%%+*}" # Remove timezone offset (+00:00) |
|
stripped="${stripped%%-[0-9][0-9]:[0-9][0-9]}" # Remove negative timezone offset |
|
|
|
# Check if timestamp is UTC (has Z or +00:00 or -00:00) |
|
if [[ "$iso_str" == *"Z"* ]] || [[ "$iso_str" == *"+00:00"* ]] || [[ "$iso_str" == *"-00:00"* ]]; then |
|
# For UTC timestamps, parse with timezone set to UTC |
|
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 |
|
|
|
if [ -n "$epoch" ]; then |
|
echo "$epoch" |
|
return 0 |
|
fi |
|
|
|
return 1 |
|
} |
|
|
|
# Format ISO reset time to compact local time |
|
# Usage: format_reset_time <iso_string> <style: time|datetime|date> |
|
format_reset_time() { |
|
local iso_str="$1" |
|
local style="$2" |
|
[ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return |
|
|
|
# Parse ISO datetime and convert to local time (cross-platform) |
|
local epoch |
|
epoch=$(iso_to_epoch "$iso_str") |
|
[ -z "$epoch" ] && return |
|
|
|
# Format based on style (try BSD date first, then GNU date) |
|
# BSD date uses %p (uppercase AM/PM), so convert to lowercase |
|
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/^ //' |
|
;; |
|
*) |
|
date -j -r "$epoch" +"%b %-d" 2>/dev/null | tr '[:upper:]' '[:lower:]' || \ |
|
date -d "@$epoch" +"%b %-d" 2>/dev/null |
|
;; |
|
esac |
|
} |
|
|
|
# Pad column to fixed width (ignoring ANSI codes) |
|
# Usage: pad_column <text_with_ansi> <visible_length> <column_width> |
|
pad_column() { |
|
local text="$1" |
|
local visible_len=$2 |
|
local col_width=$3 |
|
local padding=$(( col_width - visible_len )) |
|
if [ "$padding" -gt 0 ]; then |
|
printf "%s%*s" "$text" "$padding" "" |
|
else |
|
printf "%s" "$text" |
|
fi |
|
} |
|
|
|
line2="" |
|
line3="" |
|
sep=" ${dim}|${reset} " |
|
|
|
# ---- Project/branch column (first column on lines 2 & 3) ---- |
|
col0_project="" |
|
col0_branch="" |
|
col0_project_plain="" |
|
col0_branch_plain="" |
|
if [ -n "$folder_name" ]; then |
|
col0_project_plain="project: ${folder_name}" |
|
fi |
|
if [ -n "$git_branch" ]; then |
|
branch_display="${git_branch}" |
|
if [ -n "$git_worktree" ]; then |
|
branch_display="${git_branch} (${git_worktree})" |
|
fi |
|
col0_branch_plain="branch: ${branch_display}" |
|
fi |
|
|
|
# Dynamically size col0 to fit the longer of project/branch |
|
col0w=${#col0_project_plain} |
|
[ ${#col0_branch_plain} -gt "$col0w" ] && col0w=${#col0_branch_plain} |
|
|
|
if [ -n "$col0_project_plain" ]; then |
|
col0_project="${white}project:${reset} ${cyan}${folder_name}${reset}" |
|
col0_project=$(pad_column "$col0_project" "${#col0_project_plain}" "$col0w") |
|
fi |
|
if [ -n "$col0_branch_plain" ]; then |
|
if $git_dirty; then |
|
branch_color="$yellow" |
|
else |
|
branch_color="$green" |
|
fi |
|
col0_branch="${white}branch:${reset} ${branch_color}${branch_display}${reset}" |
|
col0_branch=$(pad_column "$col0_branch" "${#col0_branch_plain}" "$col0w") |
|
elif [ -n "$folder_name" ]; then |
|
col0_branch=$(printf "%*s" "$col0w" "") |
|
fi |
|
|
|
if [ -n "$usage_data" ] && echo "$usage_data" | jq -e . >/dev/null 2>&1; then |
|
bar_width=10 |
|
col1w=23 |
|
col2w=22 |
|
|
|
# ---- 5-hour (current) ---- |
|
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_reset=$(format_reset_time "$five_hour_reset_iso" "time") |
|
five_hour_bar=$(build_bar "$five_hour_pct" "$bar_width") |
|
|
|
# Calculate visible length: "current: " + bar + " " + "XX%" |
|
col1_bar_vis_len=$(( 9 + bar_width + 1 + ${#five_hour_pct} + 1 )) |
|
col1_bar="${white}current:${reset} ${five_hour_bar} ${cyan}${five_hour_pct}%${reset}" |
|
col1_bar=$(pad_column "$col1_bar" "$col1_bar_vis_len" "$col1w") |
|
|
|
col1_reset_plain="resets ${five_hour_reset}" |
|
col1_reset="${white}resets ${five_hour_reset}${reset}" |
|
col1_reset=$(pad_column "$col1_reset" "${#col1_reset_plain}" "$col1w") |
|
|
|
# ---- 7-day (weekly) ---- |
|
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") |
|
|
|
col2_bar_vis_len=$(( 8 + bar_width + 1 + ${#seven_day_pct} + 1 )) |
|
col2_bar="${white}weekly:${reset} ${seven_day_bar} ${cyan}${seven_day_pct}%${reset}" |
|
col2_bar=$(pad_column "$col2_bar" "$col2_bar_vis_len" "$col2w") |
|
|
|
col2_reset_plain="resets ${seven_day_reset}" |
|
col2_reset="${white}resets ${seven_day_reset}${reset}" |
|
col2_reset=$(pad_column "$col2_reset" "${#col2_reset_plain}" "$col2w") |
|
|
|
# ---- Extra usage ---- |
|
col3_bar="" |
|
col3_reset="" |
|
extra_enabled=$(echo "$usage_data" | jq -r '.extra_usage.is_enabled // false') |
|
if [ "$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") |
|
|
|
# Next month 1st for reset date (macOS compatible) |
|
extra_reset=$(date -v+1m -v1d +"%b %-d" | tr '[:upper:]' '[:lower:]') |
|
|
|
col3_bar="${white}extra:${reset} ${extra_bar} ${cyan}\$${extra_used}/\$${extra_limit}${reset}" |
|
col3_reset="${white}resets ${extra_reset}${reset}" |
|
fi |
|
|
|
# Assemble line 2: project + bars row |
|
line2="" |
|
[ -n "$col0_project" ] && line2+="${col0_project}${sep}" |
|
line2+="${col1_bar}${sep}${col2_bar}" |
|
[ -n "$col3_bar" ] && line2+="${sep}${col3_bar}" |
|
|
|
# Assemble line 3: branch + resets row |
|
line3="" |
|
[ -n "$col0_branch" ] && line3+="${col0_branch}${sep}" |
|
line3+="${col1_reset}${sep}${col2_reset}" |
|
[ -n "$col3_reset" ] && line3+="${sep}${col3_reset}" |
|
else |
|
# No usage data - still show project/branch if available |
|
[ -n "$col0_project" ] && line2="${col0_project}" |
|
[ -n "$col0_branch" ] && line3="${col0_branch}" |
|
fi |
|
|
|
# Output all lines |
|
printf "%b" "$line1" |
|
[ -n "$line2" ] && printf "\n%b" "$line2" |
|
[ -n "$line3" ] && printf "\n%b" "$line3" |
|
|
|
exit 0 |