The measured ladder tried every single-line variant before considering a second line, so whenever line one plus the full group did not fit — from around 92 usable columns upwards, depending on how long the branch, cwd and model names are — it emitted rl_bare (5h and 7d only) and silently dropped the per-model (Fable) bar that is the main reason the group is worth rendering. On a 110-column laptop the limit was invisible. Reorder so a second line beats losing that bar: one line rich/mid/lean, then wrap, and rl_bare only when WRAP_NARROW is false. Reset times and extra-usage credits are still given up rather than wrapped for, which makes the rendered content non-monotone in width; the comment on the ladder spells that out. Also stop the tests writing fixtures to the caches the live status line reads. ~/.claude/statusline.sh is a symlink to the script, so a test run was visibly rendering fabricated usage — a Fable bar at 10%, extra-usage credits of $1234.56/$2000.00 — in whatever session happened to be open, and a run killed before its trap fired would have left that in place for an hour. The cache directory is now $STATUSLINE_CACHE_DIR (default /tmp/claude) and each suite points it at a temporary directory, which also removes the backup/restore dance and the deletion of the live git-status cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
643 lines
26 KiB
Shell
Executable file
643 lines
26 KiB
Shell
Executable file
#!/bin/bash
|
||
# Claude Code Status Line
|
||
# Based on https://github.com/daniel3303/ClaudeCodeStatusLine
|
||
# Enhanced: git branch/ahead-behind, token bar, caching, improved visuals
|
||
|
||
set -f # disable globbing
|
||
|
||
# ===== Config =====
|
||
SHOW_GIT=true # git branch, dirty status, ahead/behind
|
||
SHOW_TOKENS=true # token usage bar
|
||
SHOW_THINKING=true # extended thinking indicator
|
||
SHOW_RATE_LIMITS=true # 5h / 7d rate limit bars
|
||
SHOW_MODEL_LIMIT=true # per-model weekly limit bar (e.g. Fable), when the API reports one
|
||
WRAP_NARROW=true # wrap onto a second line rather than dropping segments or being clipped
|
||
WRAP_MIN_WIDTH=100 # below this, keep wide-tier line-one content (it can wrap)
|
||
BRANCH_MAX_LEN=28 # truncate branch names longer than this
|
||
CWD_MAX_LEN=20 # truncate the cwd basename longer than this
|
||
GIT_CACHE_SECS=10 # seconds to cache git status (git diff is slow on large repos)
|
||
TOKEN_BAR_WIDTH=8 # width of token progress bar
|
||
|
||
# Where the git and usage-API caches live. Overridable via the environment so a
|
||
# test run can render into its own directory: ~/.claude/statusline.sh is a
|
||
# symlink to this script, so a fixture written to the live cache is picked up by
|
||
# the next redraw and shown as real usage for up to an hour.
|
||
CACHE_DIR="${STATUSLINE_CACHE_DIR:-/tmp/claude}"
|
||
|
||
# Terminal width detection.
|
||
# Claude Code exports COLUMNS for this subprocess. There is no controlling
|
||
# terminal, so the stty fallback fails; when both fail we default to 80
|
||
# (safe/compact) rather than wide.
|
||
#
|
||
# The `padding` setting indents the status line on both sides, and Claude Code
|
||
# applies it on top of COLUMNS rather than deducting it first — measured: with
|
||
# COLUMNS=121 and padding 2, a 121-column line is clipped with an ellipsis. So
|
||
# deduct it here, plus a column of margin.
|
||
#
|
||
# To override detection entirely, set TERM_WIDTH in settings.json:
|
||
# "command": "TERM_WIDTH=160 ~/.claude/statusline.sh"
|
||
# Read both settings this script needs in one jq pass.
|
||
# Only the user-level file: Claude Code also merges project .claude/settings.json,
|
||
# .claude/settings.local.json and managed policy, so a statusLine.padding set at
|
||
# project level would be applied by the renderer but missed here.
|
||
sl_padding=0
|
||
sl_thinking=false
|
||
if [ -f "$HOME/.claude/settings.json" ]; then
|
||
{ IFS= read -r sl_padding; IFS= read -r sl_thinking; } <<EOF
|
||
$(jq -r '[(.statusLine.padding // 0), (.alwaysThinkingEnabled // false)] | .[] | tostring' "$HOME/.claude/settings.json" 2>/dev/null)
|
||
EOF
|
||
[ "${sl_padding:-0}" -ge 0 ] 2>/dev/null || sl_padding=0
|
||
fi
|
||
|
||
if [ "${TERM_WIDTH:-0}" -le 0 ] 2>/dev/null; then
|
||
if [ "${COLUMNS:-0}" -gt 0 ] 2>/dev/null; then
|
||
TERM_WIDTH=$COLUMNS
|
||
else
|
||
_w=$(stty size </dev/tty 2>/dev/null | awk '{print $2}')
|
||
[ "${_w:-0}" -gt 0 ] 2>/dev/null && TERM_WIDTH=$_w || TERM_WIDTH=80
|
||
unset _w
|
||
fi
|
||
fi
|
||
|
||
# Usable width: minus padding on both sides, minus a column of margin.
|
||
USABLE_WIDTH=$(( TERM_WIDTH - 2 * sl_padding - 1 ))
|
||
[ "$USABLE_WIDTH" -lt 20 ] && USABLE_WIDTH=20
|
||
|
||
# ${#str} counts characters only under a UTF-8 locale; under LC_ALL=C it counts
|
||
# bytes, which would mis-measure the box/block glyphs and break line wrapping.
|
||
case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in
|
||
*UTF-8*|*utf8*|*UTF8*) ;;
|
||
*)
|
||
# C.UTF-8 on glibc, en_US.UTF-8 on macOS (which has no C.UTF-8).
|
||
# Picking one that does not exist makes bash warn on every render and
|
||
# silently fall back to byte counting, so verify before committing.
|
||
for _loc in C.UTF-8 en_US.UTF-8; do
|
||
if LC_ALL="$_loc" locale charmap 2>/dev/null | grep -qi utf; then
|
||
export LC_ALL="$_loc"; break
|
||
fi
|
||
done
|
||
unset _loc
|
||
;;
|
||
esac
|
||
|
||
input=$(cat)
|
||
[ -z "$input" ] && printf "Claude" && exit 0
|
||
|
||
mkdir -p "$CACHE_DIR"
|
||
|
||
# ===== Colors =====
|
||
# Palette indices rather than RGB, so these resolve through the terminal's own
|
||
# palette and follow the ghostty tangere-light/tangere-dark swap for free. No
|
||
# appearance detection, so no cost per statusline render.
|
||
#
|
||
# The previous values were One Dark RGB: fine on dark (4.3-10.8:1) but 1.35-3.4:1
|
||
# on a light background, with 'white' effectively invisible. These clear WCAG AA
|
||
# in both modes (5.8-14.3:1 on dark, 6.3-20.6:1 on light).
|
||
blue='\033[38;5;4m'
|
||
orange='\033[38;5;3m'
|
||
amber='\033[38;5;11m'
|
||
green='\033[38;5;2m'
|
||
cyan='\033[38;5;14m'
|
||
red='\033[38;5;1m'
|
||
yellow='\033[38;5;3m'
|
||
white='\033[39m'
|
||
magenta='\033[38;5;5m'
|
||
dim='\033[2m'
|
||
reset='\033[0m'
|
||
|
||
sep=" ${dim}│${reset} "
|
||
|
||
# ===== Helpers =====
|
||
|
||
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
|
||
}
|
||
|
||
# Display width of a string, ignoring ANSI colour escapes. Every glyph the
|
||
# statusline uses is single-width, so a character count is the display width.
|
||
#
|
||
# The colour vars hold the escapes as literal backslash-033 text (they are
|
||
# single-quoted, and printf %b only interprets them when the line is finally
|
||
# emitted), so strip that literal form as well as a real ESC byte.
|
||
vis_len() {
|
||
local plain
|
||
plain=$(printf '%s' "$1" | sed -e 's/\\033\[[0-9;]*m//g' -e $'s/\033\[[0-9;]*m//g' -e 's/\\\\/\\/g')
|
||
printf '%d' "${#plain}"
|
||
}
|
||
|
||
# printf %b interprets backslash escapes -- that is how the colour variables
|
||
# above are applied. Data from the API or the filesystem must NOT be
|
||
# interpreted: a directory or model name containing a literal \n would split
|
||
# the line, breaking both the width measurement and the two-line guarantee.
|
||
# Double the backslashes so %b renders them literally.
|
||
esc_data() {
|
||
local v="$1"
|
||
# Real control characters (jq decodes \n in the JSON payload into an actual
|
||
# newline) would split the line regardless of escaping, so drop them first.
|
||
v="${v//$'\n'/ }"; v="${v//$'\r'/ }"; v="${v//$'\t'/ }"; v="${v//$'\033'/}"
|
||
printf '%s' "${v//\\/\\\\}"
|
||
}
|
||
|
||
truncate_str() {
|
||
local str="$1" max="$2"
|
||
[ "${#str}" -gt "$max" ] \
|
||
&& printf "%s…" "${str:0:$((max-1))}" \
|
||
|| printf "%s" "$str"
|
||
}
|
||
|
||
# Colored progress bar using block chars
|
||
build_bar() {
|
||
local pct=$1 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 ))
|
||
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 f="" e=""
|
||
for ((i=0; i<filled; i++)); do f+="█"; done
|
||
for ((i=0; i<empty; i++)); do e+="░"; done
|
||
printf "${bar_color}${f}${dim}${e}${reset}"
|
||
}
|
||
|
||
# ===== Git info with per-directory caching =====
|
||
get_git_info() {
|
||
local dir="$1"
|
||
[ -z "$dir" ] && return
|
||
|
||
# Stable cache key per directory path
|
||
local dir_hash
|
||
dir_hash=$(printf '%s' "$dir" | cksum | awk '{print $1}')
|
||
local cache_file="$CACHE_DIR/git-${dir_hash}"
|
||
|
||
local needs_refresh=true
|
||
if [ -f "$cache_file" ]; then
|
||
local mtime now age
|
||
mtime=$(stat -f %m "$cache_file" 2>/dev/null || stat -c %Y "$cache_file" 2>/dev/null)
|
||
now=$(date +%s)
|
||
age=$(( now - mtime ))
|
||
[ "$age" -lt "$GIT_CACHE_SECS" ] && needs_refresh=false
|
||
fi
|
||
|
||
if $needs_refresh; then
|
||
# Branch name (or short hash for detached HEAD)
|
||
local branch
|
||
branch=$(git -C "$dir" symbolic-ref --short HEAD 2>/dev/null)
|
||
if [ -z "$branch" ]; then
|
||
branch=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null)
|
||
if [ -z "$branch" ]; then
|
||
: > "$cache_file" # not a git repo — write empty cache
|
||
return
|
||
fi
|
||
branch="(${branch})"
|
||
fi
|
||
|
||
# Dirty: any staged or unstaged changes
|
||
local dirty=""
|
||
[ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ] && dirty="dirty"
|
||
|
||
# Ahead / behind upstream (skip if no upstream set)
|
||
local ahead=0 behind=0
|
||
local upstream
|
||
upstream=$(git -C "$dir" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null)
|
||
if [ -n "$upstream" ]; then
|
||
local ab
|
||
ab=$(git -C "$dir" rev-list --left-right --count "HEAD...${upstream}" 2>/dev/null)
|
||
ahead=$(echo "$ab" | awk '{print $1}')
|
||
behind=$(echo "$ab" | awk '{print $2}')
|
||
fi
|
||
|
||
# Tab-delimited cache (branch names can't contain tabs per git spec)
|
||
printf '%s\t%s\t%s\t%s' "$branch" "$dirty" "${ahead:-0}" "${behind:-0}" > "$cache_file"
|
||
fi
|
||
|
||
cat "$cache_file" 2>/dev/null
|
||
}
|
||
|
||
# ===== OAuth token resolution =====
|
||
get_oauth_token() {
|
||
[ -n "$CLAUDE_CODE_OAUTH_TOKEN" ] && echo "$CLAUDE_CODE_OAUTH_TOKEN" && return 0
|
||
|
||
if command -v security >/dev/null 2>&1; then
|
||
local blob token
|
||
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)
|
||
[ -n "$token" ] && [ "$token" != "null" ] && echo "$token" && return 0
|
||
fi
|
||
fi
|
||
|
||
local creds_file="${HOME}/.claude/.credentials.json"
|
||
if [ -f "$creds_file" ]; then
|
||
local token
|
||
token=$(jq -r '.claudeAiOauth.accessToken // empty' "$creds_file" 2>/dev/null)
|
||
[ -n "$token" ] && [ "$token" != "null" ] && echo "$token" && return 0
|
||
fi
|
||
|
||
if command -v secret-tool >/dev/null 2>&1; then
|
||
local blob token
|
||
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)
|
||
[ -n "$token" ] && [ "$token" != "null" ] && echo "$token" && return 0
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
}
|
||
|
||
# ===== ISO 8601 → epoch (cross-platform) =====
|
||
iso_to_epoch() {
|
||
local iso_str="$1"
|
||
local epoch
|
||
|
||
epoch=$(date -d "${iso_str}" +%s 2>/dev/null)
|
||
[ -n "$epoch" ] && echo "$epoch" && return 0
|
||
|
||
local stripped="${iso_str%%.*}"
|
||
stripped="${stripped%%Z}"
|
||
stripped="${stripped%%+*}"
|
||
stripped="${stripped%%-[0-9][0-9]:[0-9][0-9]}"
|
||
|
||
if [[ "$iso_str" == *"Z"* ]] || [[ "$iso_str" == *"+00:00"* ]] || [[ "$iso_str" == *"-00:00"* ]]; then
|
||
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
|
||
|
||
[ -n "$epoch" ] && echo "$epoch" && return 0
|
||
return 1
|
||
}
|
||
|
||
format_reset_time() {
|
||
local iso_str="$1" style="$2"
|
||
[ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return
|
||
local epoch
|
||
epoch=$(iso_to_epoch "$iso_str")
|
||
[ -z "$epoch" ] && return
|
||
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/^ //'
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ===== Extract JSON =====
|
||
model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"')
|
||
cwd=$(echo "$input" | jq -r '.cwd // empty')
|
||
cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // empty')
|
||
|
||
size=$(echo "$input" | jq -r '.context_window.context_window_size // 200000')
|
||
[ "$size" -eq 0 ] 2>/dev/null && size=200000
|
||
|
||
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)
|
||
pct_used=$(( size > 0 ? current * 100 / size : 0 ))
|
||
|
||
thinking_on=false
|
||
[ "$sl_thinking" = "true" ] && thinking_on=true
|
||
|
||
# ===== Adaptive width tiers =====
|
||
#
|
||
# Tiers now choose only how much of LINE ONE to show. What the rate-limit
|
||
# group contains, and whether it wraps, is decided by measurement at the end of
|
||
# the script, not here -- tiers cannot see how long the branch, cwd or model
|
||
# name is, which is how content used to end up clipped.
|
||
#
|
||
# full (≥150): CWD, ahead/behind, "◆ thinking", cost
|
||
# wide (100–149): ahead/behind, "◆ thinking", cost
|
||
# split (76–99): short model, ahead/behind, "◆" symbol
|
||
# (reachable only with WRAP_NARROW=false; otherwise the wrap
|
||
# band below overrides this range to wide)
|
||
# narrow (<76): short model + branch + token only; no rate limits at all
|
||
#
|
||
if [ "$USABLE_WIDTH" -ge 150 ] 2>/dev/null; then width_tier="full"
|
||
elif [ "$USABLE_WIDTH" -ge 100 ] 2>/dev/null; then width_tier="wide"
|
||
elif [ "$USABLE_WIDTH" -ge 76 ] 2>/dev/null; then width_tier="split"
|
||
else width_tier="narrow"
|
||
fi
|
||
|
||
# Two-line mode. Between WRAP_FLOOR and WRAP_MIN_WIDTH there isn't room for
|
||
# everything on one line, but there IS room across two — so instead of dropping
|
||
# the rate-limit group we break before it and render the wide-tier content.
|
||
# Below WRAP_FLOOR the narrow tier applies instead, which drops the rate-limit
|
||
# group entirely -- so there is nothing to wrap and no second line to put it on.
|
||
WRAP_FLOOR=68
|
||
wrap_mode=false
|
||
if $WRAP_NARROW \
|
||
&& [ "$USABLE_WIDTH" -lt "$WRAP_MIN_WIDTH" ] 2>/dev/null \
|
||
&& [ "$USABLE_WIDTH" -ge "$WRAP_FLOOR" ] 2>/dev/null; then
|
||
wrap_mode=true
|
||
width_tier="wide"
|
||
fi
|
||
|
||
# Shorten model name for tight spaces
|
||
short_model() {
|
||
case "$1" in
|
||
*Opus*) echo "Opus" ;;
|
||
*Sonnet*) echo "Sonnet" ;;
|
||
*Haiku*) echo "Haiku" ;;
|
||
*) echo "$1" | awk '{print $NF}' ;; # last word
|
||
esac
|
||
}
|
||
|
||
# ===== Build output =====
|
||
# Explicitly empty: bash imports same-named environment variables, and a
|
||
# stray $rl_lean would otherwise leak into the rendered line.
|
||
out=""
|
||
rl_bare=""
|
||
rl_lean=""
|
||
rl_mid=""
|
||
rl_rich=""
|
||
|
||
# Model — color by family
|
||
model_color="$blue"
|
||
case "$model_name" in
|
||
*Opus*) model_color="$amber" ;;
|
||
*Haiku*) model_color="$cyan" ;;
|
||
esac
|
||
|
||
display_model="$model_name"
|
||
# split/narrow: shorten so the 5h + 7d bars still fit on one line.
|
||
# wrap mode too: it renders wide-tier content at a split-tier width, and a long
|
||
# display name ("Opus 5 (1M context)" is 19 cols) would overflow line one.
|
||
if [ "$width_tier" = "split" -o "$width_tier" = "narrow" ] || $wrap_mode; then
|
||
display_model=$(short_model "$model_name")
|
||
fi
|
||
out+="${model_color}$(esc_data "$display_model")${reset}"
|
||
|
||
# CWD — full tier only (branch name gives enough context below that)
|
||
if [ "$width_tier" = "full" ] && [ -n "$cwd" ]; then
|
||
# Capped like the branch name: nothing else shortens line one, so an
|
||
# unusually long project directory would push it past the terminal edge.
|
||
display_dir=$(truncate_str "${cwd##*/}" "$CWD_MAX_LEN")
|
||
out+="${sep}${dim}$(esc_data "$display_dir")${reset}"
|
||
fi
|
||
|
||
# Git branch + dirty + ahead/behind
|
||
if $SHOW_GIT && [ -n "$cwd" ]; then
|
||
git_info=$(get_git_info "$cwd")
|
||
if [ -n "$git_info" ]; then
|
||
IFS=$'\t' read -r g_branch g_dirty g_ahead g_behind <<< "$git_info"
|
||
|
||
# Progressively tighten branch truncation
|
||
local_max="$BRANCH_MAX_LEN"
|
||
[ "$width_tier" = "wide" ] && local_max=24
|
||
[ "$width_tier" = "split" ] && local_max=18
|
||
[ "$width_tier" = "narrow" ] && local_max=12
|
||
# In wrap mode line one is model+branch+tokens+thinking+cost; with the
|
||
# shortened model name everything but the branch is ~56 cols, so give
|
||
# the branch whatever is left.
|
||
if $wrap_mode; then
|
||
local_max=$(( USABLE_WIDTH - 56 ))
|
||
[ "$local_max" -lt 8 ] && local_max=8
|
||
[ "$local_max" -gt 24 ] && local_max=24
|
||
fi
|
||
g_branch_display=$(truncate_str "$g_branch" "$local_max")
|
||
|
||
out+="${sep}${dim}⎇${reset} ${magenta}$(esc_data "$g_branch_display")${reset}"
|
||
|
||
if [ "$g_dirty" = "dirty" ]; then
|
||
out+=" ${red}✗${reset}"
|
||
else
|
||
out+=" ${green}✔${reset}"
|
||
fi
|
||
|
||
# Ahead/behind: shown in all tiers except narrow (only if non-zero)
|
||
if [ "$width_tier" != "narrow" ]; then
|
||
[ "${g_ahead:-0}" -gt 0 ] && out+=" ${green}↑${g_ahead}${reset}"
|
||
[ "${g_behind:-0}" -gt 0 ] && out+=" ${orange}↓${g_behind}${reset}"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Token bar
|
||
if $SHOW_TOKENS; then
|
||
bar_w="$TOKEN_BAR_WIDTH"
|
||
[ "$width_tier" = "wide" ] && bar_w=6
|
||
[ "$width_tier" = "split" ] && bar_w=5
|
||
[ "$width_tier" = "narrow" ] && bar_w=4
|
||
token_bar=$(build_bar "$pct_used" "$bar_w")
|
||
out+="${sep}${token_bar} ${orange}${used_tokens}${dim}/${reset}${white}${total_tokens}${reset} ${dim}${pct_used}%${reset}"
|
||
fi
|
||
|
||
# Thinking:
|
||
# full/wide → "◆ thinking" / "◇ thinking" (label)
|
||
# split → "◆" / "◇" (symbol only, saves ~9 chars)
|
||
# narrow → hidden
|
||
if $SHOW_THINKING && [ "$width_tier" != "narrow" ]; then
|
||
out+="${sep}"
|
||
if $thinking_on; then
|
||
if [ "$width_tier" = "split" ]; then out+="${amber}◆${reset}"
|
||
else out+="${amber}◆ thinking${reset}"; fi
|
||
else
|
||
if [ "$width_tier" = "split" ]; then out+="${dim}◇${reset}"
|
||
else out+="${dim}◇ thinking${reset}"; fi
|
||
fi
|
||
fi
|
||
|
||
# Session cost — wide/full only
|
||
if [ -n "$cost_usd" ] && [ "$width_tier" = "wide" -o "$width_tier" = "full" ]; then
|
||
cost_fmt=$(printf '%.2f' "$cost_usd" 2>/dev/null)
|
||
[ -n "$cost_fmt" ] && out+="${sep}${dim}\$${cost_fmt}${reset}"
|
||
fi
|
||
|
||
# ===== Rate limits (API, cached 60s) =====
|
||
# Built at every tier except narrow. Which variant is actually emitted,
|
||
# and on how many lines, is decided by the measured ladder at the end.
|
||
if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then
|
||
api_cache="$CACHE_DIR/statusline-usage-cache.json"
|
||
api_cache_max=3600 # 1 hour — rate limit data changes slowly
|
||
needs_refresh=true
|
||
usage_data=""
|
||
|
||
if [ -f "$api_cache" ]; then
|
||
cache_mtime=$(stat -c %Y "$api_cache" 2>/dev/null || stat -f %m "$api_cache" 2>/dev/null)
|
||
now=$(date +%s)
|
||
cache_age=$(( now - cache_mtime ))
|
||
if [ "$cache_age" -lt "$api_cache_max" ]; then
|
||
cached_content=$(cat "$api_cache" 2>/dev/null)
|
||
# Only use cache if it's valid data (not an error response)
|
||
if [ -n "$cached_content" ] && ! echo "$cached_content" | jq -e '.error' >/dev/null 2>&1; then
|
||
needs_refresh=false
|
||
usage_data="$cached_content"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if $needs_refresh; then
|
||
token=$(get_oauth_token)
|
||
if [ -n "$token" ] && [ "$token" != "null" ]; then
|
||
response=$(curl -s --max-time 10 \
|
||
-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
|
||
# Only cache successful (non-error) responses
|
||
if ! echo "$response" | jq -e '.error' >/dev/null 2>&1; then
|
||
usage_data="$response"
|
||
echo "$response" > "$api_cache"
|
||
fi
|
||
fi
|
||
fi
|
||
# Fall back to stale cache if refresh failed — skip if it's an error response
|
||
if [ -z "$usage_data" ] && [ -f "$api_cache" ]; then
|
||
stale=$(cat "$api_cache" 2>/dev/null)
|
||
! echo "$stale" | jq -e '.error' >/dev/null 2>&1 && usage_data="$stale"
|
||
fi
|
||
fi
|
||
|
||
if [ -n "$usage_data" ] && echo "$usage_data" | jq -e . >/dev/null 2>&1; then
|
||
bar_width=6
|
||
[ "$width_tier" = "split" ] && bar_width=4
|
||
|
||
# One jq pass for the whole payload. Parsing it field by field meant
|
||
# ~10 jq processes per render on the same JSON; the status line runs on
|
||
# every redraw, so that was the dominant cost. Rounding happens in jq
|
||
# too, which removes the per-field awk calls.
|
||
#
|
||
# The per-model weekly limit (e.g. Fable) lives in .limits[] under kind
|
||
# "weekly_scoped" -- the legacy seven_day_opus / seven_day_sonnet fields
|
||
# are always null now. Prefer an is_active entry, else take the first.
|
||
# `.limits[]?` tolerates limits being absent or not an array, and
|
||
# `tonumber? // 0` tolerates a percentage arriving as a string.
|
||
# One field per line, not @tsv: tab is IFS whitespace, so `read` collapses
|
||
# runs of it and an empty field (no scoped limit -> empty display name)
|
||
# would silently shift every later field along by one.
|
||
{
|
||
IFS= read -r five_hour_pct
|
||
IFS= read -r seven_day_pct
|
||
IFS= read -r five_hour_reset_iso
|
||
IFS= read -r seven_day_reset_iso
|
||
IFS= read -r scoped_name
|
||
IFS= read -r scoped_pct
|
||
IFS= read -r extra_enabled
|
||
IFS= read -r extra_pct
|
||
IFS= read -r extra_used
|
||
IFS= read -r extra_limit
|
||
} <<EOF
|
||
$(echo "$usage_data" | jq -r '
|
||
def num: (tonumber? // 0);
|
||
([.limits[]? | select(.kind == "weekly_scoped")]
|
||
| (map(select(.is_active)) + .) | first) as $scoped
|
||
| [ (.five_hour.utilization | num | round),
|
||
(.seven_day.utilization | num | round),
|
||
(.five_hour.resets_at // ""),
|
||
(.seven_day.resets_at // ""),
|
||
(($scoped.scope.model.display_name // "") | gsub("[\\n\\r\\t]"; " ")),
|
||
($scoped.percent | num | round),
|
||
(.extra_usage.is_enabled // false),
|
||
(.extra_usage.utilization | num | round),
|
||
((.extra_usage.used_credits | num) / 100 * 100 | round / 100),
|
||
((.extra_usage.monthly_limit | num) / 100 * 100 | round / 100)
|
||
] | .[] | tostring' 2>/dev/null)
|
||
EOF
|
||
|
||
# Three variants of the rate-limit group, richest first:
|
||
# rl_rich bars + reset times + extra usage
|
||
# rl_mid bars + extra usage
|
||
# rl_lean bars only
|
||
# The ladder at the end emits the richest one that fits the space it
|
||
# has. Keeping a lean variant matters: extra-usage credits add ~30
|
||
# columns, which can overflow line two on its own.
|
||
seg_5h="${dim}5h${reset} $(build_bar "${five_hour_pct:-0}" "$bar_width") ${cyan}${five_hour_pct:-0}%${reset}"
|
||
seg_7d="${sep}${dim}7d${reset} $(build_bar "${seven_day_pct:-0}" "$bar_width") ${cyan}${seven_day_pct:-0}%${reset}"
|
||
|
||
# Rendered inside the 7d segment rather than as its own: both are weekly
|
||
# limits resetting at the same time, so one shared reset label covers
|
||
# the pair and saves a separator plus a second timestamp.
|
||
seg_scoped=""
|
||
if $SHOW_MODEL_LIMIT && [ -n "$scoped_name" ]; then
|
||
seg_scoped=" ${dim}$(esc_data "$(truncate_str "$scoped_name" 8)")${reset} $(build_bar "${scoped_pct:-0}" "$bar_width") ${cyan}${scoped_pct:-0}%${reset}"
|
||
fi
|
||
|
||
# Extra usage, when the account has it enabled.
|
||
seg_extra=""
|
||
if [ "$extra_enabled" = "true" ]; then
|
||
seg_extra="${sep}${dim}extra${reset} $(build_bar "${extra_pct:-0}" "$bar_width") ${cyan}\$$(printf '%.2f' "${extra_used:-0}")${dim}/\$$(printf '%.2f' "${extra_limit:-0}")${reset}"
|
||
fi
|
||
|
||
# rl_bare drops the per-model bar too: the last thing worth giving up,
|
||
# and the only way to fit at all when wrapping is disabled and the
|
||
# terminal is narrow.
|
||
rl_bare="${seg_5h}${seg_7d}"
|
||
rl_lean="${rl_bare}${seg_scoped}"
|
||
rl_mid="${rl_lean}${seg_extra}"
|
||
rl_rich="$rl_mid"
|
||
|
||
# Formatting the two reset timestamps costs ~10 subprocesses (date has
|
||
# no portable one-shot form here), so only pay for it when there is a
|
||
# chance they will be shown: appended to line one, or alone on line two.
|
||
# RESET_COST is the combined width of " reset 4:40p.m." and
|
||
# " reset aug 28, 3:00a.m.".
|
||
line1_len=$(vis_len "$out")
|
||
mid_len=$(vis_len "$rl_mid")
|
||
RESET_COST=30
|
||
if [ $(( line1_len + 3 + mid_len + RESET_COST )) -le "$USABLE_WIDTH" ] \
|
||
|| { $WRAP_NARROW && [ $(( mid_len + RESET_COST )) -le "$USABLE_WIDTH" ]; }; then
|
||
five_hour_reset=$(format_reset_time "$five_hour_reset_iso" "time")
|
||
seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime")
|
||
r5=""; [ -n "$five_hour_reset" ] && r5=" ${dim}↺ ${five_hour_reset}${reset}"
|
||
r7=""; [ -n "$seven_day_reset" ] && r7=" ${dim}↺ ${seven_day_reset}${reset}"
|
||
rl_rich="${seg_5h}${r5}${seg_7d}${seg_scoped}${r7}${seg_extra}"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Attach the rate-limit group, emitting the richest layout that fits.
|
||
#
|
||
# A second line is worth it for the per-model bar and nothing else: rl_bare is
|
||
# the only variant that drops that bar, and it is the whole reason the group is
|
||
# worth showing on an account that has one. Reset times and extra-usage credits
|
||
# are given up instead of wrapped for, so content is NOT monotone in width -- at
|
||
# one column narrower, rung 3 stops fitting and the wrap that follows has room
|
||
# for the timestamps as well. Only wrapping being switched off makes rl_bare the
|
||
# answer.
|
||
# 1-3. one line: with resets / with extra usage / bars + per-model
|
||
# 4-6. two lines: same order, line two having room the single line lacked
|
||
# 7. one line, bars only -- WRAP_NARROW=false, the least-bad single line
|
||
# Every branch is fit-checked, so no layout is chosen that would be clipped by
|
||
# the renderer, whatever the branch name, cwd, model name or extra-usage width.
|
||
if [ -n "$rl_bare" ]; then
|
||
rich_len=$(vis_len "$rl_rich")
|
||
lean_len=$(vis_len "$rl_lean")
|
||
if [ $(( line1_len + 3 + rich_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_rich}"
|
||
elif [ $(( line1_len + 3 + mid_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_mid}"
|
||
elif [ $(( line1_len + 3 + lean_len )) -le "$USABLE_WIDTH" ]; then out+="${sep}${rl_lean}"
|
||
elif $WRAP_NARROW; then
|
||
if [ "$rich_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_rich"
|
||
elif [ "$mid_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_mid"
|
||
elif [ "$lean_len" -le "$USABLE_WIDTH" ]; then out+=$'\n'"$rl_lean"
|
||
else out+=$'\n'"$rl_bare"
|
||
fi
|
||
else
|
||
out+="${sep}${rl_bare}"
|
||
fi
|
||
fi
|
||
|
||
printf "%b" "$out"
|
||
exit 0
|