|
#!/bin/bash |
|
# ~/.claude/statusline.sh |
|
# Two-line Claude Code statusline. |
|
# |
|
# Role in system: |
|
# Invoked by Claude Code after each assistant message / on permission-mode or |
|
# vim-mode changes (debounced 300ms). Receives JSON session data on stdin |
|
# and prints two lines of formatted output. Coexists with the built-in |
|
# right-side notification area (MCP errors, context-low warnings, etc.) — |
|
# those are NOT rendered by this script, they share the row by design. |
|
# |
|
# Wired up from: ~/.claude/settings.json -> "statusLine.command" |
|
# Related docs: https://code.claude.com/docs/en/statusline |
|
# |
|
# Layout: |
|
# line 1: [Model] dir | branch |
|
# line 2: <context-bar> N% | $cost | mm:ss | HH:MM:SS | 5h:N% 7d:N% |
|
# |
|
# Design notes: |
|
# - No git status / dirty-check on purpose (user pref) — only `branch --show-current`. |
|
# - jq is required. If absent the script silently degrades to "[unknown]". |
|
# - Rate limits are absent for non-Pro/Max users; handled with `// empty`. |
|
# - claude-code-router (ccr code) sessions are special-cased: the [Model] field |
|
# shows CCR's real routed model, and the cost field is replaced with real |
|
# OpenRouter spend (session delta + remaining credits) because Claude Code's |
|
# own cost figure is wrong on the proxy. Both are gated on ANTHROPIC_BASE_URL |
|
# pointing at the local CCR proxy, so plain `claude` sessions are unchanged. |
|
# Needs $OPENROUTER_API_KEY in env; OpenRouter calls are cached in ~/.cache/orc. |
|
|
|
input=$(cat) |
|
|
|
# Bail to a useful default if jq is missing rather than printing nothing. |
|
if ! command -v jq >/dev/null 2>&1; then |
|
echo "[statusline: install jq]" |
|
exit 0 |
|
fi |
|
|
|
MODEL=$(echo "$input" | jq -r '.model.display_name // "unknown"') |
|
|
|
# claude-code-router sessions: `ccr code` points ANTHROPIC_BASE_URL at the local |
|
# proxy (:3456). In that case Claude Code only knows the Anthropic *tier* it |
|
# requested (e.g. "Opus"), not the model CCR actually routes to underneath, so |
|
# the statusline would lie. Detect the proxy and surface CCR's real default route |
|
# instead. Gated on the base URL so plain `claude` sessions are unaffected. |
|
# Note: shows Router.default — a mid-session `/model` switch or background/think |
|
# routes may differ; this reflects the configured default, not per-request routing. |
|
CCR_ACTIVE="" |
|
case "$ANTHROPIC_BASE_URL" in |
|
*127.0.0.1:3456*|*localhost:3456*) |
|
CCR_ACTIVE=1 |
|
CCR_CONFIG="$HOME/.claude-code-router/config.json" |
|
if [ -f "$CCR_CONFIG" ]; then |
|
ROUTED=$(jq -r '.Router.default // empty' "$CCR_CONFIG" 2>/dev/null | sed 's/^[^,]*,//') |
|
[ -n "$ROUTED" ] && MODEL="ccr:$ROUTED" |
|
fi |
|
;; |
|
esac |
|
|
|
DIR=$(echo "$input" | jq -r '.workspace.current_dir // .cwd // ""') |
|
PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1) |
|
COST=$(echo "$input" | jq -r '.cost.total_cost_usd // 0') |
|
DURATION_MS=$(echo "$input" | jq -r '.cost.total_duration_ms // 0') |
|
FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty') |
|
WEEK=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty') |
|
|
|
CYAN='\033[36m' |
|
GREEN='\033[32m' |
|
YELLOW='\033[33m' |
|
RED='\033[31m' |
|
DIM='\033[2m' |
|
RESET='\033[0m' |
|
|
|
# Color the context bar by pressure so high usage is visually loud. |
|
if [ "$PCT" -ge 90 ]; then |
|
BAR_COLOR="$RED" |
|
elif [ "$PCT" -ge 70 ]; then |
|
BAR_COLOR="$YELLOW" |
|
else |
|
BAR_COLOR="$GREEN" |
|
fi |
|
|
|
BAR_WIDTH=10 |
|
FILLED=$((PCT * BAR_WIDTH / 100)) |
|
[ "$FILLED" -gt "$BAR_WIDTH" ] && FILLED=$BAR_WIDTH |
|
EMPTY=$((BAR_WIDTH - FILLED)) |
|
BAR="" |
|
[ "$FILLED" -gt 0 ] && printf -v FILL "%${FILLED}s" && BAR="${FILL// /█}" |
|
[ "$EMPTY" -gt 0 ] && printf -v PAD "%${EMPTY}s" && BAR="${BAR}${PAD// /░}" |
|
|
|
MINS=$((DURATION_MS / 60000)) |
|
SECS=$(((DURATION_MS % 60000) / 1000)) |
|
DUR_FMT=$(printf '%dm %02ds' "$MINS" "$SECS") |
|
COST_FMT=$(printf '$%.2f' "$COST") |
|
|
|
# "Last responded" clock. The statusline fires immediately after each assistant |
|
# message (and on mode changes), so the current wall-clock time at render time is |
|
# a good proxy for when the agent last produced output. The displayed value stays |
|
# frozen until the next render (the script never polls), so a session left idle |
|
# for days keeps showing its last timestamp. The date is included UNCONDITIONALLY: |
|
# at render time it is always "today", so any logic that drops the date when it |
|
# equals today would drop it every time and never help. Carrying MM/DD means a |
|
# frozen value left on screen for days reads e.g. "18/06 21:49" — self-evidently |
|
# stale — instead of a bare "21:49:38" that looks deceptively recent. |
|
NOW_FMT=$(date '+%d/%m %H:%M:%S') |
|
|
|
# Cost segment. Default = Claude Code's own estimate. But on claude-code-router |
|
# that figure is meaningless: Claude Code prices tokens against its Anthropic |
|
# table for a model it thinks is Opus, while the request actually ran on a |
|
# cheaper OpenRouter model. The real spend lives in the OpenRouter account, so |
|
# for CCR sessions we replace it with "spent-this-session / remaining" pulled |
|
# from OpenRouter's credits endpoint. |
|
# - credits call is cached ~45s (statusline fires after every turn). |
|
# - per-session usage is snapshotted on first render so we can show the delta. |
|
# - curl is hard-capped at 2s and every failure degrades silently; the |
|
# statusline must never hang or error on a flaky network. |
|
COST_SEG="${YELLOW}${COST_FMT}${RESET}" |
|
if [ -n "$CCR_ACTIVE" ] && [ -n "$OPENROUTER_API_KEY" ]; then |
|
CACHE_DIR="$HOME/.cache/orc" |
|
mkdir -p "$CACHE_DIR" 2>/dev/null |
|
CRED="$CACHE_DIR/or-credits.json" |
|
CRED_AGE=$(( $(date +%s) - $(stat -f %m "$CRED" 2>/dev/null || echo 0) )) |
|
if [ ! -s "$CRED" ] || [ "$CRED_AGE" -ge 45 ]; then |
|
curl -s --max-time 2 https://openrouter.ai/api/v1/credits \ |
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" -o "$CRED.tmp" 2>/dev/null \ |
|
&& [ -s "$CRED.tmp" ] && mv "$CRED.tmp" "$CRED" || rm -f "$CRED.tmp" 2>/dev/null |
|
fi |
|
if [ -s "$CRED" ]; then |
|
OR_USAGE=$(jq -r '.data.total_usage // empty' "$CRED" 2>/dev/null) |
|
OR_CREDITS=$(jq -r '.data.total_credits // empty' "$CRED" 2>/dev/null) |
|
if [ -n "$OR_USAGE" ] && [ -n "$OR_CREDITS" ]; then |
|
SID=$(echo "$input" | jq -r '.session_id // "nosession"') |
|
SNAP="$CACHE_DIR/session-$SID.usage" |
|
[ -f "$SNAP" ] || printf '%s' "$OR_USAGE" > "$SNAP" |
|
SNAP_USAGE=$(cat "$SNAP" 2>/dev/null || echo "$OR_USAGE") |
|
OR_SPENT=$(awk -v a="$OR_USAGE" -v b="$SNAP_USAGE" 'BEGIN{d=a-b; if(d<0)d=0; printf "%.2f", d}') |
|
OR_LEFT=$(awk -v c="$OR_CREDITS" -v u="$OR_USAGE" 'BEGIN{printf "%.2f", c-u}') |
|
COST_SEG="${YELLOW}\$${OR_SPENT}${RESET} ${DIM}(\$${OR_LEFT} left)${RESET}" |
|
fi |
|
fi |
|
fi |
|
|
|
# Branch is cheap — no caching needed. Silent fail outside a repo. |
|
BRANCH="" |
|
if git rev-parse --git-dir >/dev/null 2>&1; then |
|
B=$(git branch --show-current 2>/dev/null) |
|
[ -n "$B" ] && BRANCH=" | 🌿 $B" |
|
fi |
|
|
|
# Rate-limits segment is omitted entirely for non-subscribers. |
|
LIMITS="" |
|
if [ -n "$FIVE_H" ]; then |
|
LIMITS=$(printf '5h:%.0f%%' "$FIVE_H") |
|
fi |
|
if [ -n "$WEEK" ]; then |
|
W=$(printf '7d:%.0f%%' "$WEEK") |
|
LIMITS="${LIMITS:+$LIMITS }$W" |
|
fi |
|
|
|
# Collapse $HOME to ~ for readability without losing path context. |
|
DIR_DISPLAY="$DIR" |
|
case "$DIR" in |
|
"$HOME") DIR_DISPLAY="~" ;; |
|
"$HOME"/*) DIR_DISPLAY="~${DIR#$HOME}" ;; |
|
esac |
|
|
|
# Line 1: identity + location |
|
printf '%b\n' "${CYAN}[$MODEL]${RESET} 📁 ${DIR_DISPLAY}${BRANCH}" |
|
|
|
# Line 2: pressure + economics |
|
LINE2="${BAR_COLOR}${BAR}${RESET} ${PCT}% | ${COST_SEG} | ${DIM}⏱ ${DUR_FMT}${RESET} | ${DIM}⏱ ${NOW_FMT}${RESET}" |
|
[ -n "$LIMITS" ] && LINE2="${LINE2} | ${DIM}${LIMITS}${RESET}" |
|
printf '%b\n' "$LINE2" |