statuslin.es

Activity Feed

bash

A header with model, context and burn rate per hour, then live tool and agent activity parsed from the session transcript.

MIT licensed · original source

Preview

Clean repo
Opus 4.8 high ctx 22% app (main) PR #1287 $0.41 · 10m +128 -34 Session 74% left Resets in 2h 7m Weekly 93% left Resets in 2d 1h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
New session
Opus 4.8 high ctx 0% app (main) $0.00 · 10m
Dirty branch
Sonnet 4.6 medium ctx 48% app ⎇ feat-auth* PR #1290 $0.41 · 10m +128 -34 Session 60% left Resets in 1h 11m Weekly 82% left Resets in 2d 21h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
Near-full
Opus 4.8 max ctx 91% app (main) PR #1290 $4.12 · 10m +128 -34 ⚠ Context 91% — compaction imminent Session 12% left Resets in 17m Weekly 39% left Resets in 19h 59m ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
1M context
Fable 5 xhigh ctx 64% app (main) PR #1290 $0.41 · 10m +128 -34 Session 67% left Resets in 3h 29m Weekly 88% left Resets in 4d 23h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
Post-compact
Haiku 4.5 ctx 0% app (main) $0.41 · 10m +128 -34 Session 48% left Resets in 4h 1m ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
Worktree
Opus 4.8 low ctx 37% feature ⎇ feature PR #1301 $0.41 · 10m +128 -34 Session 56% left Resets in 2h 49m Weekly 80% left Resets in 1d 6h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
Non-git
Opus 4.8 high ctx 22% scratch $0.41 · 10m +128 -34 ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions

Source

#!/bin/bash
# [statuslin.es] trimmed: removed the ccusage-cache.sh sibling-script call and its daily-cost table; everything else is unmodified from the pinned source.
# Claude Code Custom Statusline
# https://github.com/JungHoonGhae/claude-statusline
#
# A rich statusline for Claude Code that shows context usage, rate limits,
# tool/agent activity, and daily token costs — all in pure bash.
#
# Dependencies: jq, curl
# Optional: ccusage (npm) for token cost tracking

# ── Platform Detection ────────────────────────────────────────────────────────
OS_TYPE="$(uname -s)"

get_mtime() {
  case "$OS_TYPE" in
    Darwin) stat -f %m "$1" 2>/dev/null || echo 0 ;;
    MINGW*|MSYS*|CYGWIN*) stat -c %Y "$1" 2>/dev/null || echo 0 ;;
    *) stat -c %Y "$1" 2>/dev/null || echo 0 ;;
  esac
}

parse_iso_to_epoch() {
  local ts=$1
  case "$OS_TYPE" in
    Darwin) TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%S" "$ts" "+%s" 2>/dev/null ;;
    *) TZ=UTC date -d "${ts}" "+%s" 2>/dev/null ;;
  esac
}

get_oauth_token() {
  local cred_file
  case "$OS_TYPE" in
    Darwin)
      security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null \
        | jq -r '.claudeAiOauth.accessToken // empty'
      return
      ;;
    MINGW*|MSYS*|CYGWIN*)
      # Windows (Git Bash / MSYS2 / Cygwin): ~/.claude/.credentials.json
      cred_file="$HOME/.claude/.credentials.json"
      if [ ! -f "$cred_file" ] && [ -n "$APPDATA" ]; then
        cred_file="$APPDATA/Claude/credentials.json"
      fi
      if [ -f "$cred_file" ]; then
        jq -r '.claudeAiOauth.accessToken // empty' "$cred_file" 2>/dev/null
      fi
      return
      ;;
  esac
  # Linux: try credentials file, then secret-tool (GNOME Keyring)
  cred_file="$HOME/.claude/.credentials.json"
  if [ -f "$cred_file" ]; then
    jq -r '.claudeAiOauth.accessToken // empty' "$cred_file" 2>/dev/null
  elif command -v secret-tool >/dev/null 2>&1; then
    secret-tool lookup service "Claude Code-credentials" 2>/dev/null \
      | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null
  fi
}

# ── Dependency Check ──────────────────────────────────────────────────────────
# Without this, a missing jq makes the statusline silently blank (common in
# Docker containers after restart — only the mounted ~/.claude survives)
if ! command -v jq >/dev/null 2>&1; then
  cat > /dev/null
  printf '\n  \033[1;31mclaude-statusline: jq not found\033[0m \033[2m— install it: apt-get install -y jq / apk add jq / brew install jq\033[0m\n\n'
  exit 0
fi

# ── Cache ─────────────────────────────────────────────────────────────────────
# Per-user dir — a shared /tmp path collides (and leaks data) between users
CACHE_DIR="${TMPDIR:-/tmp}/claude-statusline-$(id -u)"
mkdir -p "$CACHE_DIR" 2>/dev/null

# ── Configuration ─────────────────────────────────────────────────────────────
# Defaults; overridable by environment, then by the conf file (conf wins).
SHOW_RATE_LIMITS=${SHOW_RATE_LIMITS:-true}
SHOW_TOOLS=${SHOW_TOOLS:-true}
SHOW_AGENTS=${SHOW_AGENTS:-true}
SHOW_CCUSAGE=${SHOW_CCUSAGE:-true}
SHOW_CONTEXT_BAR=${SHOW_CONTEXT_BAR:-true}
SHOW_BURN_RATE=${SHOW_BURN_RATE:-true}
SHOW_GIT_AHEAD=${SHOW_GIT_AHEAD:-true}
SHOW_LINKS=${SHOW_LINKS:-true}
SHOW_SESSION_NAME=${SHOW_SESSION_NAME:-false}
CONTEXT_WARN_PCT=${CONTEXT_WARN_PCT:-30}
CONTEXT_CRIT_PCT=${CONTEXT_CRIT_PCT:-70}
DAILY_BUDGET=${DAILY_BUDGET:-0}

STATUSLINE_CONF="${STATUSLINE_CONF:-$HOME/.claude/statusline.conf}"
if [ -f "$STATUSLINE_CONF" ]; then
  while IFS='=' read -r key val; do
    key=$(echo "$key" | tr -d '[:space:]')
    val=$(echo "$val" | tr -d '[:space:]')
    case "$key" in
      SHOW_RATE_LIMITS|SHOW_TOOLS|SHOW_AGENTS|SHOW_CCUSAGE) eval "$key=$val" ;;
      SHOW_CONTEXT_BAR|SHOW_BURN_RATE|SHOW_GIT_AHEAD|SHOW_LINKS|SHOW_SESSION_NAME) eval "$key=$val" ;;
      CONTEXT_WARN_PCT|CONTEXT_CRIT_PCT|DAILY_BUDGET) eval "$key=$val" ;;
    esac
  done < <(grep -v '^\s*#' "$STATUSLINE_CONF" | grep -v '^\s*$')
fi

# Terminal width — Claude Code exports $COLUMNS. The header and the rate-limit
# lines compact at *independent* thresholds (the header is far longer, so it
# needs to shrink well before the rate-limit lines do — otherwise the gauges get
# packed together while there's still plenty of room).
#   WIDE      (>= WIDE_COLS):     full header incl. ctx bar, token detail, burn rate
#   medium:                       standard header, no extras
#   COMPACT   (<  COMPACT_COLS):  minimal header (model | ctx% | project | cost)
#   RL_COMPACT(<  RLCOMPACT_COLS):tight rate-limit lines (5 spaced dots, no labels)
# COLUMNS unset (0) → assume wide so piped/test use keeps the full layout.
COLS=${COLUMNS:-0}
COMPACT_COLS=100   # minimal header below this (standard header needs ~96 cols)
WIDE_COLS=132      # ctx bar + tokens + burn rate above this (full header ~129 cols)
RLCOMPACT_COLS=64  # tighten rate-limit lines only below this (normal line ~56 cols)
COMPACT=0
WIDE=1
RL_COMPACT=0
if [ "$COLS" -gt 0 ] 2>/dev/null; then
  [ "$COLS" -lt "$COMPACT_COLS" ] && COMPACT=1
  [ "$COLS" -lt "$WIDE_COLS" ] && WIDE=0
  [ "$COLS" -lt "$RLCOMPACT_COLS" ] && RL_COMPACT=1
fi
# Links can leak escape codes through tmux without passthrough — disable there
[ -n "$TMUX" ] && SHOW_LINKS=false

# ── Parse stdin from Claude Code ──────────────────────────────────────────────
input=$(cat)

eval "$(jq -r '
  @sh "model=\(.model.display_name // "Unknown")",
  @sh "used=\(.context_window.used_percentage // 0 | floor)",
  @sh "ctx_size=\(.context_window.context_window_size // 0)",
  @sh "ctx_tokens=\(if .context_window.current_usage then ((.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)) else "" end)",
  @sh "cwd=\(.workspace.current_dir // .cwd // "")",
  @sh "cost=\(.cost.total_cost_usd // 0)",
  @sh "duration_ms=\(.cost.total_duration_ms // 0)",
  @sh "lines_added=\(.cost.total_lines_added // 0)",
  @sh "lines_removed=\(.cost.total_lines_removed // 0)",
  @sh "git_branch=\(.git.branch // "")",
  @sh "git_dirty=\(.git.dirty // false)",
  @sh "worktree=\(.worktree.name // .workspace.git_worktree // "")",
  @sh "fast_mode=\(.fast_mode // false)",
  @sh "effort=\(.effort.level // "")",
  @sh "thinking=\(.thinking.enabled // false)",
  @sh "pr_number=\(.pr.number // "")",
  @sh "pr_state=\(.pr.review_state // "")",
  @sh "pr_url=\(.pr.url // "")",
  @sh "session_name=\(.session_name // "")",
  @sh "transcript_path=\(.transcript_path // "")",
  @sh "stdin_5h_used=\(.rate_limits.five_hour.used_percentage // "")",
  @sh "stdin_5h_reset=\(.rate_limits.five_hour.resets_at // "")",
  @sh "stdin_7d_used=\(.rate_limits.seven_day.used_percentage // "")",
  @sh "stdin_7d_reset=\(.rate_limits.seven_day.resets_at // "")"
' <<< "$input")"

# ── Project & Branch ──────────────────────────────────────────────────────────
project=$(basename "$cwd" 2>/dev/null)

# Claude Code no longer sends .git in stdin (v2.1.x) — read from the repo directly
if [ -z "$git_branch" ] && [ -n "$cwd" ] && [ -d "$cwd" ]; then
  git_branch=$(git -C "$cwd" branch --show-current 2>/dev/null)
  [ -z "$git_branch" ] && git_branch=$(git -C "$cwd" rev-parse --short HEAD 2>/dev/null)
  if [ -n "$git_branch" ] && [ "$git_dirty" != "true" ]; then
    [ -n "$(git -C "$cwd" status --porcelain --untracked-files=no 2>/dev/null | head -1)" ] && git_dirty=true
  fi
fi

# Ahead/behind vs upstream (one cheap git call; skipped if no upstream)
ahead_behind=""
if [ "$SHOW_GIT_AHEAD" = "true" ] && [ -n "$git_branch" ] && [ -n "$cwd" ] && [ -d "$cwd" ]; then
  ab=$(git -C "$cwd" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null)
  if [ -n "$ab" ]; then
    behind=${ab%%[	 ]*}
    ahead=${ab##*[	 ]}
    [ "$ahead" -gt 0 ] 2>/dev/null && ahead_behind="${ahead_behind} \033[32m↑${ahead}\033[0m"
    [ "$behind" -gt 0 ] 2>/dev/null && ahead_behind="${ahead_behind} \033[31m↓${behind}\033[0m"
  fi
fi

dirty_mark=""
if [ "$git_dirty" = "true" ]; then
  dirty_mark="\033[31m*\033[0m"
fi

location_str=""
if [ -n "$worktree" ]; then
  location_str=" \033[35m⎇ ${worktree}\033[0m${dirty_mark}${ahead_behind}"
elif [ -n "$git_branch" ]; then
  location_str=" \033[35m(${git_branch})\033[0m${dirty_mark}${ahead_behind}"
fi

# ── Format Duration ───────────────────────────────────────────────────────────
if [ "$duration_ms" -gt 0 ] 2>/dev/null; then
  total_sec=$((duration_ms / 1000))
  hours=$((total_sec / 3600))
  mins=$(( (total_sec % 3600) / 60 ))
  if [ "$hours" -gt 0 ]; then
    duration_str="${hours}h ${mins}m"
  elif [ "$mins" -gt 0 ]; then
    duration_str="${mins}m"
  else
    duration_str="${total_sec}s"
  fi
else
  duration_str="0s"
fi

# ── Format Cost ───────────────────────────────────────────────────────────────
if [ "$cost" != "0" ] && [ -n "$cost" ]; then
  cost_str=$(printf '$%.2f' "$cost")
else
  cost_str='$0.00'
fi

# ── Burn Rate ($/hr) ──────────────────────────────────────────────────────────
# Only meaningful past the first minute; integer-cents math, no bc/awk
burn_str=""
if [ "$SHOW_BURN_RATE" = "true" ] && [ "$duration_ms" -ge 60000 ] 2>/dev/null; then
  cstr=${cost_str#\$}                      # "24.32"
  cfrac=${cstr#*.}00                        # decimals, padded
  cents=$(( ${cstr%%.*} * 100 + 10#${cfrac:0:2} ))
  if [ "$cents" -gt 0 ] 2>/dev/null; then
    rate_cents=$(( cents * 3600000 / duration_ms ))
    burn_str=$(printf ' \033[2m· ~$%d.%02d/hr\033[0m' $((rate_cents / 100)) $((rate_cents % 100)))
  fi
fi

# ── OSC 8 Hyperlink ───────────────────────────────────────────────────────────
# Wraps text in a clickable link; unsupported terminals just show the text.
# Returns *literal* escape sequences so it survives the header's printf '%b'.
osc_link() {
  local url=$1 text=$2
  if [ "$SHOW_LINKS" = "true" ] && [ -n "$url" ]; then
    printf '%s' '\033]8;;'"$url"'\033\\'"$text"'\033]8;;\033\\'
  else
    printf '%s' "$text"
  fi
}

# Session name (opt-in; truncated, since long /rename labels blow up line 1)
session_str=""
if [ "$SHOW_SESSION_NAME" = "true" ] && [ -n "$session_name" ]; then
  sn=$session_name
  [ "${#sn}" -gt 24 ] && sn="${sn:0:23}…"
  session_str=" \033[2m· ${sn}\033[0m"
fi

# ── Color Helpers ─────────────────────────────────────────────────────────────

ctx_color() {
  local pct=$1
  if [ "$pct" -lt "$CONTEXT_WARN_PCT" ]; then
    printf "\033[32m"
  elif [ "$pct" -lt "$CONTEXT_CRIT_PCT" ]; then
    printf "\033[33m"
  else
    printf "\033[31m"
  fi
}

make_bar() {
  local pct=$1 count=${2:-10}
  local filled=$(( pct * count / 100 ))
  [ "$filled" -gt "$count" ] && filled=$count

  local color
  if [ "$pct" -gt 50 ]; then
    color="\033[32m"
  elif [ "$pct" -gt 20 ]; then
    color="\033[33m"
  else
    color="\033[31m"
  fi

  local bar="" i=0
  while [ "$i" -lt "$count" ]; do
    [ "$i" -gt 0 ] && bar="${bar} "   # always spaced for legibility
    if [ "$i" -lt "$filled" ]; then
      bar="${bar}${color}●\033[0m"
    else
      bar="${bar}\033[2m○\033[0m"
    fi
    i=$((i + 1))
  done
  printf '%b' "$bar"
}

# Small 5-segment context gauge for the header (spaced, like the rate-limit bars)
ctx_bar() {
  local pct=$1 c
  c=$(ctx_color "$pct")
  local filled=$(( (pct + 19) / 20 ))   # round up so any usage shows ≥1 dot
  [ "$filled" -gt 5 ] && filled=5
  [ "$filled" -lt 0 ] && filled=0
  local bar="" i=0
  while [ "$i" -lt 5 ]; do
    [ "$i" -gt 0 ] && bar="${bar} "
    if [ "$i" -lt "$filled" ]; then
      bar="${bar}${c}●\033[0m"
    else
      bar="${bar}\033[2m○\033[0m"
    fi
    i=$((i + 1))
  done
  printf '%b' "$bar"
}

status_dot() {
  local pct=$1
  if [ "$pct" -gt 50 ]; then
    printf "\033[32m●\033[0m"
  elif [ "$pct" -gt 20 ]; then
    printf "\033[33m●\033[0m"
  else
    printf "\033[31m●\033[0m"
  fi
}

# ── Rate Limit Helpers ────────────────────────────────────────────────────────

format_remaining_epoch() {
  local reset_epoch=$1
  # null/empty reset = bucket idle (0% used) — the window hasn't started yet
  if [ -z "$reset_epoch" ] || [ "$reset_epoch" = "" ] || [ "$reset_epoch" = "null" ]; then
    echo "idle"
    return
  fi
  if echo "$reset_epoch" | grep -qE '^[0-9]+\.?[0-9]*$'; then
    reset_epoch=${reset_epoch%%.*}
  else
    local utc_ts=${reset_epoch%%+*}
    utc_ts=${utc_ts%%.*}
    reset_epoch=$(parse_iso_to_epoch "$utc_ts")
  fi
  local now_epoch remaining rd rh rm
  now_epoch=$(date +%s)
  if [ -n "$reset_epoch" ] && [ "$reset_epoch" -gt "$now_epoch" ] 2>/dev/null; then
    remaining=$(( reset_epoch - now_epoch ))
    rd=$((remaining / 86400))
    rh=$(( (remaining % 86400) / 3600 ))
    rm=$(( (remaining % 3600) / 60 ))
    if [ "$rd" -gt 0 ]; then
      echo "Resets in ${rd}d ${rh}h"
    elif [ "$rh" -gt 0 ]; then
      echo "Resets in ${rh}h ${rm}m"
    else
      echo "Resets in ${rm}m"
    fi
  else
    echo "Resetting"
  fi
}

print_limit_line() {
  local label=$1 used_val=$2 reset_val=$3
  [ -n "$used_val" ] || return
  local left=$((100 - used_val))
  if [ "$RL_COMPACT" -eq 1 ]; then
    # Very narrow: 5 spaced dots, short reset, no "left"/"Resets in"
    local rem; rem=$(format_remaining_epoch "$reset_val"); rem=${rem#Resets in }; rem=${rem// /}
    printf "  \033[2m%-7.7s\033[0m %b \033[36m%s%%\033[0m \033[2m%s\033[0m\n" \
      "$label" "$(make_bar "$left" 5)" "$left" "$rem"
  else
    printf "  \033[2m%-7.7s\033[0m %b %s  \033[36m%s%% left\033[0m  \033[2m%s\033[0m\n" \
      "$label" "$(status_dot "$left")" "$(make_bar "$left")" "$left" "$(format_remaining_epoch "$reset_val")"
  fi
}

# Bucket key → display label (works on bash 3.2 — no ${var^})
pretty_bucket() {
  case "$1" in
    oauth_apps) echo "Apps" ;;
    *) echo "$1" | awk -F_ '{ print toupper(substr($1,1,1)) substr($1,2) }' ;;
  esac
}

fmt_tokens() {
  local t=$1 div unit
  if [ "$t" -ge 1000000000 ] 2>/dev/null; then
    div=1000000000; unit=B
  elif [ "$t" -ge 1000000 ] 2>/dev/null; then
    div=1000000; unit=M
  elif [ "$t" -ge 1000 ] 2>/dev/null; then
    div=1000; unit=K
  else
    printf "%d" "$t"
    return
  fi
  local whole=$((t / div)) dec=$(( (t % div) * 10 / div ))
  if [ "$dec" -eq 0 ]; then
    printf "%d%s" "$whole" "$unit"
  else
    printf "%d.%d%s" "$whole" "$dec" "$unit"
  fi
}

# ── Rate Limits: stdin first, API fallback ────────────────────────────────────

# Try stdin rate_limits first (Claude Code v2.1.6+)
if [ -n "$stdin_5h_used" ] && [ "$stdin_5h_used" != "null" ]; then
  five_hour_used=$(printf '%.0f' "$stdin_5h_used" 2>/dev/null || echo 0)
  five_hour_reset="$stdin_5h_reset"
else
  five_hour_used=""
fi

if [ -n "$stdin_7d_used" ] && [ "$stdin_7d_used" != "null" ]; then
  seven_day_used=$(printf '%.0f' "$stdin_7d_used" 2>/dev/null || echo 0)
  seven_day_reset="$stdin_7d_reset"
else
  seven_day_used=""
fi

# API fallback: model-specific buckets (auto-detected) + Session/Weekly if stdin missing
api_buckets=""
extra_enabled=""; extra_used_pct=""

if [ "$SHOW_RATE_LIMITS" = "true" ]; then
  CACHE_FILE="$CACHE_DIR/usage.json"
  CACHE_TTL=120

  fetch_usage() {
    local TOKEN
    command -v curl >/dev/null 2>&1 || return 0
    TOKEN=$(get_oauth_token)
    if [ -n "$TOKEN" ]; then
      curl -s --max-time 3 "https://api.anthropic.com/api/oauth/usage" \
        -H "Authorization: Bearer $TOKEN" \
        -H "anthropic-beta: oauth-2025-04-20" 2>/dev/null
    fi
  }

  need_refresh=1
  if [ -f "$CACHE_FILE" ]; then
    cache_age=$(( $(date +%s) - $(get_mtime "$CACHE_FILE") ))
    if [ "$cache_age" -lt "$CACHE_TTL" ]; then
      need_refresh=0
    fi
  fi

  if [ "$need_refresh" -eq 1 ]; then
    usage_data=$(fetch_usage)
    if [ -n "$usage_data" ] && echo "$usage_data" | jq -e '.five_hour' >/dev/null 2>&1; then
      echo "$usage_data" > "$CACHE_FILE"
    fi
  fi

  if [ -s "$CACHE_FILE" ]; then
    eval "$(jq -r '
      @sh "api_5h_used=\(.five_hour.utilization // "" | if . != "" then (. | floor | tostring) else "" end)",
      @sh "api_5h_reset=\(.five_hour.resets_at // "")",
      @sh "api_7d_used=\(.seven_day.utilization // "" | if . != "" then (. | floor | tostring) else "" end)",
      @sh "api_7d_reset=\(.seven_day.resets_at // "")",
      @sh "extra_enabled=\(.extra_usage.is_enabled // false)",
      @sh "extra_used_pct=\(if .extra_usage.utilization then (.extra_usage.utilization | floor | tostring) else "" end)"
    ' "$CACHE_FILE")"

    # Any seven_day_<bucket> with data (Opus/Sonnet/Fable/... — keys change over time).
    # Order by model capability (best first); unknown buckets keep API order after.
    api_buckets=$(jq -r '
      ["fable","opus","sonnet","haiku"] as $rank
      | [ to_entries[]
          | select(.key | startswith("seven_day_"))
          | select((.value | type) == "object" and .value.utilization != null)
          | (.key | sub("^seven_day_"; "")) as $n
          | {name: $n, used: (.value.utilization | floor), reset: (.value.resets_at // ""), rank: (($rank | index($n)) // 99)} ]
      | sort_by(.rank, .name)
      | .[] | "\(.name)\t\(.used)\t\(.reset)"
    ' "$CACHE_FILE" 2>/dev/null)

    [ -z "$five_hour_used" ] && five_hour_used="$api_5h_used" && five_hour_reset="$api_5h_reset"
    [ -z "$seven_day_used" ] && seven_day_used="$api_7d_used" && seven_day_reset="$api_7d_reset"
  fi
fi

ctx_c=$(ctx_color "$used")

# ══════════════════════════════════════════════════════════════════════════════
# OUTPUT
# ══════════════════════════════════════════════════════════════════════════════
echo ""

# Model badges: fast mode / effort level / extended thinking
model_badges=""
[ "$fast_mode" = "true" ] && model_badges="${model_badges} \033[36m⚡fast\033[0m"
[ -n "$effort" ] && model_badges="${model_badges} \033[2m${effort}\033[0m"
[ "$thinking" = "true" ] && model_badges="${model_badges} \033[2m✦\033[0m"

# Context mini-bar (low usage = good = green) + used/total tokens — wide only
ctx_bar_str=""
if [ "$SHOW_CONTEXT_BAR" = "true" ] && [ "$WIDE" -eq 1 ]; then
  ctx_bar_str=" $(ctx_bar "$used")"
fi
ctx_detail=""
if [ -n "$ctx_tokens" ] && [ "$ctx_size" -gt 0 ] 2>/dev/null && [ "$WIDE" -eq 1 ]; then
  ctx_detail=" \033[2m$(fmt_tokens "$ctx_tokens")/$(fmt_tokens "$ctx_size")\033[0m"
fi

# PR badge with review state (clickable when a URL is present)
pr_str=""
if [ -n "$pr_number" ]; then
  case "$pr_state" in
    approved)          pr_mark=" \033[32m✓\033[0m" ;;
    changes_requested) pr_mark=" \033[31m✗\033[0m" ;;
    draft)             pr_mark=" \033[2m◌\033[0m" ;;
    *)                 pr_mark=" \033[33m●\033[0m" ;;
  esac
  pr_label=$(osc_link "$pr_url" "PR #${pr_number}")
  pr_str=" \033[2m│\033[0m \033[36m${pr_label}\033[0m${pr_mark}"
fi

# Lines added/removed this session
lines_str=""
if [ "$lines_added" -gt 0 ] 2>/dev/null || [ "$lines_removed" -gt 0 ] 2>/dev/null; then
  lines_str=" \033[32m+${lines_added}\033[0m \033[31m-${lines_removed}\033[0m"
fi

# Burn rate is a wide-only extra; session name drops on the narrowest tier
[ "$WIDE" -ne 1 ] && burn_str=""
[ "$COMPACT" -eq 1 ] && session_str=""

if [ "$COMPACT" -eq 1 ]; then
  # Minimal header for narrow terminals: model | ctx% | project (branch) | cost
  printf "  \033[1;37m%s\033[0m \033[2m│\033[0m %bctx %s%%\033[0m \033[2m│\033[0m \033[33m%s\033[0m%b \033[2m│\033[0m \033[2m%s\033[0m\n" \
    "$model" "$ctx_c" "$used" "$project" "$location_str" "$cost_str"
else
  # Full / medium: extras (bar, tokens, burn) are pre-blanked unless WIDE
  printf "  \033[1;37m%s\033[0m%b \033[2m│\033[0m %bctx %s%%\033[0m%b%b \033[2m│\033[0m \033[33m%s\033[0m%b%b \033[2m│\033[0m \033[2m%s · %s\033[0m%b%b%b\n" \
    "$model" "$model_badges" "$ctx_c" "$used" "$ctx_bar_str" "$ctx_detail" "$project" "$location_str" "$pr_str" "$cost_str" "$duration_str" "$burn_str" "$lines_str" "$session_str"
fi

# Compaction warning
if [ "$used" -ge "$CONTEXT_CRIT_PCT" ]; then
  printf "  \033[1;31m⚠ Context %s%% — compaction imminent\033[0m\n" "$used"
fi

# Rate limit lines
if [ "$SHOW_RATE_LIMITS" = "true" ]; then
  if [ -z "$five_hour_used" ] && [ -z "$seven_day_used" ] && ! command -v curl >/dev/null 2>&1; then
    printf "  \033[2m✗ rate limits unavailable — curl not found\033[0m\n"
  fi
  print_limit_line "Session" "$five_hour_used" "$five_hour_reset"
  print_limit_line "Weekly"  "$seven_day_used" "$seven_day_reset"
  if [ -n "$api_buckets" ]; then
    while IFS=$'\t' read -r bkt_name bkt_used bkt_reset; do
      [ -z "$bkt_name" ] && continue
      print_limit_line "$(pretty_bucket "$bkt_name")" "$bkt_used" "$bkt_reset"
    done <<< "$api_buckets"
  fi
  if [ "$extra_enabled" = "true" ] && [ -n "$extra_used_pct" ]; then
    print_limit_line "Extra" "$extra_used_pct" ""
  fi
fi

# Tool / Agent Activity (from transcript)
if [ "$SHOW_TOOLS" = "true" ] || [ "$SHOW_AGENTS" = "true" ]; then
  if [ -n "$transcript_path" ] && [ -s "$transcript_path" ]; then
    transcript_data=$(tail -500 "$transcript_path" | jq -c -s '
      [.[] |
        if .type == "assistant" and (.message.content | type) == "array" then
          (.message.content)[] | select(.type == "tool_use") |
          {action: "use", id: .id, name: .name, target: (
            if .name == "Read" or .name == "Write" or .name == "Edit" then (.input.file_path // .input.path // "" | split("/") | .[-1:] | join(""))
            elif .name == "Glob" or .name == "Grep" then (.input.pattern // "")
            elif .name == "Bash" then (.input.command // "" | .[0:30])
            elif .name == "Agent" then (.input.description // .input.subagent_type // "")
            else ""
            end
          ), subagent_type: (.input.subagent_type // ""), agent_desc: (.input.description // "")}
        elif .type == "user" and (.message.content | type) == "array" then
          (.message.content)[] | select(.type == "tool_result") |
          {action: "result", id: .tool_use_id, is_error: (.is_error // false)}
        else empty
        end
      ] |
      (reduce .[] as $item (
        {tools: {}, agents: {}, completed: {}};
        if $item.action == "use" then
          if $item.name == "Agent" then
            .agents[$item.id] = {type: $item.subagent_type, desc: $item.agent_desc, status: "running"}
          elif $item.name != "TodoWrite" and $item.name != "TaskCreate" and $item.name != "TaskUpdate" then
            .tools[$item.id] = {name: $item.name, target: $item.target, status: "running"}
          else .
          end
        elif $item.action == "result" then
          if .agents[$item.id] then
            .agents[$item.id].status = "done"
          elif .tools[$item.id] then
            .tools[$item.id] as $t |
            .tools[$item.id].status = "done" |
            .completed[$t.name] = ((.completed[$t.name] // 0) + 1)
          else .
          end
        else .
        end
      )) |
      {
        running_tools: [.tools | to_entries[] | select(.value.status == "running") | {name: .value.name, target: .value.target}] | .[-3:],
        completed: .completed,
        running_agents: [.agents | to_entries[] | select(.value.status == "running") | {type: .value.type, desc: .value.desc}] | .[-3:],
        done_agents: [.agents | to_entries[] | select(.value.status != "running")] | length
      }
    ' 2>/dev/null)

    if [ -n "$transcript_data" ] && [ "$transcript_data" != "null" ]; then
      IFS=$'\x1e' read -r running_tools completed_tools running_agents done_agent_count <<< "$(
        jq -r '[
          (.running_tools | if length > 0 then [.[] | "◐ \(.name)" + (if .target != "" then ":\(.target)" else "" end)] | join("  ") else "" end),
          (.completed | to_entries | sort_by(-.value) | if length > 0 then [.[:5][] | "✓ \(.key)×\(.value)"] | join("  ") else "" end),
          (.running_agents | if length > 0 then [.[] | "◐ \(.type)" + (if .desc != "" then " \(.desc)" else "" end)] | join("  ") else "" end),
          (.done_agents | tostring)
        ] | join("\u001e")' <<< "$transcript_data"
      )"

      # Tools line
      if [ "$SHOW_TOOLS" = "true" ]; then
        if [ -n "$running_tools" ] || [ -n "$completed_tools" ]; then
          tool_line="  "
          if [ -n "$running_tools" ]; then
            tool_line="${tool_line}\033[33m${running_tools}\033[0m"
            [ -n "$completed_tools" ] && tool_line="${tool_line}  \033[2m${completed_tools}\033[0m"
          else
            tool_line="${tool_line}\033[2m${completed_tools}\033[0m"
          fi
          printf '%b\n' "$tool_line"
        fi
      fi

      # Agents line
      if [ "$SHOW_AGENTS" = "true" ]; then
        if [ -n "$running_agents" ]; then
          agent_line="  \033[35m${running_agents}\033[0m"
          [ "$done_agent_count" -gt 0 ] 2>/dev/null && agent_line="${agent_line}  \033[2m(${done_agent_count} done)\033[0m"
          printf '%b\n' "$agent_line"
        elif [ "$done_agent_count" -gt 0 ] 2>/dev/null; then
          printf '  \033[2m✓ %s agents done\033[0m\n' "$done_agent_count"
        fi
      fi
    fi
  fi
fi

echo ""

What it shows

  • Model display name
  • Effort level badge next to the model name when the session reports one
  • An extended-thinking marker (a small dim star) when thinking is enabled
  • A fast-mode badge when fast mode is on
  • Context window usage as a color-coded percentage
  • A 5-dot context gauge and used/total token counts on wide terminals
  • Project name taken from the current directory
  • Current git branch, or the worktree name with a distinct glyph when in a worktree
  • A red asterisk when the working tree is dirty
  • Commits ahead/behind upstream as green up-arrow and red down-arrow counts
  • PR number with a review-state mark (pending, approved, changes requested, or draft), rendered as a clickable link
  • Session cost in dollars and session duration
  • Approximate burn rate in dollars per hour on wide terminals
  • Lines added and removed this session
  • Optional truncated session name (off by default)
  • A red compaction warning when context usage crosses the critical threshold
  • Session (5-hour) and Weekly (7-day) rate limits as dot bars with percent remaining and a reset countdown
  • Additional per-model weekly rate-limit buckets and extra-usage percentage when the Anthropic usage API reports them
  • Currently running tools with their targets, parsed from the session transcript
  • Counts of completed tool calls by tool name
  • Running sub-agents with their descriptions, plus a count of finished agents
  • An error line telling you how to install jq if it is missing
  • A notice that rate limits are unavailable if curl is missing

Requirements

  • bash
  • jq (required; the script prints an install hint and exits if it is missing)
  • curl (optional; used only to fetch rate limits from the Anthropic usage API)
  • git (optional; used for branch, dirty state, and ahead/behind when not in stdin)
  • Standard Unix utilities: stat, date, awk, tail, grep, uname, id
  • Network access to api.anthropic.com for the rate-limit fallback
  • Read access to the Claude OAuth token, via the macOS keychain (security), ~/.claude/.credentials.json, or secret-tool on Linux
  • A terminal with ANSI color support; the glyphs used are plain Unicode (no Nerd Font needed)
  • OSC 8 hyperlink support for clickable PR links (plain text elsewhere; links are disabled inside tmux)

Behavior notes

  • In the brand-new session with no context or rate-limit data, the output shrinks to just the header, showing ctx 0% and $0.00 with no rate-limit or activity lines
  • The context percentage changes color with usage: green at 22%, yellow at 37-64%, red at 91%
  • At 91% context a bold red compaction-imminent warning line appears under the header
  • The PR mark tracks review state: yellow dot when pending or unset, green check when approved, red cross when changes are requested, dim circle for a draft
  • Worktree sessions replace the parenthesized branch with a worktree glyph and name, and a dirty tree adds a red asterisk
  • The effort badge follows the session (high, medium, max, xhigh, low) and disappears entirely when no effort level is sent, as in the Haiku scenario
  • The thinking star only appears when thinking is enabled; it is absent when thinking is false or missing
  • When only the 5-hour limit is reported, only the Session line prints and the Weekly line disappears
  • Rate-limit dot bars recolor by remaining budget: green above 50% left, yellow down to 21%, red at 12% left in the hot scenario
  • Reset countdowns reformat by span: minutes only, hours and minutes, or days and hours
  • In a non-git scratch directory the branch segment disappears from the header
  • The +lines/-lines counters only appear when either count is nonzero
  • Tool and agent activity lines appear whenever the transcript has entries, regardless of git or rate-limit state

More status lines

app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0h
Opus 4.8 | main | ██░░░░░░░░ 22.0% | 44k / 200k | 02:07 26% | Wed 7%
Opus 4.8 main app 44k/200k $0.41
app ⏱ 10m +128 -34 ░░░░░░░ 22% $0.41 ◆ Opus
󰚩 Opus 4.8󰍛 ▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 22%󰄉 $0.41 󰝰 app󰘬 main
◆ Opus 4.8 │ app/main ▰▰▰▰▱ 78% │ ↑44k ↓1.4k │ 5h: no token │ 7d: no token10m12s

New to Claude Code status lines? Read the setup guide.