Created
July 16, 2026 18:13
-
-
Save ungoldman/da9b4dc679f78344b4087de52b2a06f2 to your computer and use it in GitHub Desktop.
statusline.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Claude Code status line. Two lines under your prompt. | |
| # | |
| # Line 1: 📁 dir 🌿 branch+state 🤖 model (effort) 📟 version 🔑 session | |
| # Line 2: ⏳ 5h: X% (↻reset) 📅 7d: X% (↻reset) 🧠 Ctx: X% (Nk) 🎟️ session tokens | |
| # | |
| # 📁 dir working directory, home shortened to ~ | |
| # 🌿 branch+state git branch; * = dirty (incl. untracked), ↑N/↓N = ahead/behind upstream | |
| # 🤖 model (effort) active model, with reasoning-effort level when set | |
| # 📟 version Claude Code version | |
| # 🔑 session first 8 chars of the session id | |
| # ⏳ 5h / 📅 7d rolling 5-hour and 7-day rate-limit usage %, with ↻ local reset time | |
| # 🧠 Ctx context-window fill %, plus (Nk) tokens used so far | |
| # 🎟️ session tokens tokens processed this session (input + output + cache-creation) | |
| # └ new work only: Σ(input + output + cache_creation) per turn; omits cache_read (re-read context) | |
| # | |
| # Usage % reads your own local OAuth token via an undocumented endpoint (goes | |
| # blank if unavailable). Needs jq + curl. Works on macOS (BSD) and Linux (GNU). | |
| # | |
| # shellcheck disable=SC2154 # vars are set by the eval'd jq @sh blocks below | |
| # shellcheck disable=SC2312 # every external call is best-effort; a statusline | |
| # # must never fail, so masked exit codes are intended | |
| # Toggles: set to 0 to hide, or override per run (e.g. SHOW_USAGE=0). | |
| SHOW_USAGE="${SHOW_USAGE:-1}" # ⏳ 📅 rate-limit % (network) | |
| SHOW_SESSION_ID="${SHOW_SESSION_ID:-1}" # 🔑 session id | |
| SHOW_SESSION_TOKENS="${SHOW_SESSION_TOKENS:-1}" # 🎟️ session token throughput | |
| input=$(cat) | |
| # --- Cross-platform helpers (macOS BSD vs Linux GNU coreutils) --- | |
| if stat --version >/dev/null 2>&1; then STAT_FLAVOR=gnu; else STAT_FLAVOR=bsd; fi | |
| if date --version >/dev/null 2>&1; then DATE_FLAVOR=gnu; else DATE_FLAVOR=bsd; fi | |
| file_mtime() { # path -> mtime epoch (0 if missing) | |
| if [[ "${STAT_FLAVOR}" = gnu ]]; then stat -c '%Y' "${1}" 2>/dev/null || echo 0 | |
| else stat -f '%m' "${1}" 2>/dev/null || echo 0; fi | |
| } | |
| iso_to_epoch() { # ISO8601 (no fraction/zone) -> epoch | |
| if [[ "${DATE_FLAVOR}" = gnu ]]; then date -u -d "${1}" +%s 2>/dev/null | |
| else date -u -j -f "%Y-%m-%dT%H:%M:%S" "${1}" +%s 2>/dev/null; fi | |
| } | |
| epoch_fmt() { # epoch, strftime fmt -> local time | |
| if [[ "${DATE_FLAVOR}" = gnu ]]; then date -d "@${1}" "+${2}" 2>/dev/null | |
| else date -r "${1}" "+${2}" 2>/dev/null; fi | |
| } | |
| # --- Extract fields from Claude Code JSON --- | |
| eval "$(jq -r ' | |
| @sh "model=\(.model.display_name // "Unknown" | sub(" \\(.*\\)$"; ""))", | |
| @sh "effort=\(.effort.level // "")", | |
| @sh "version=\(.version // "")", | |
| @sh "used_pct=\(.context_window.used_percentage // 0)", | |
| @sh "used_tokens=\(((.context_window.total_input_tokens // 0) + (.context_window.total_output_tokens // 0)))", | |
| @sh "cwd=\(.workspace.current_dir // .cwd // "")", | |
| @sh "session_id=\(.sessionId // .session_id // "")", | |
| @sh "transcript_path=\(.transcript_path // "")" | |
| ' <<< "${input}" 2>/dev/null)" | |
| # --- Derived values --- | |
| dir="${cwd:-$(pwd)}" | |
| short_dir="${dir/#${HOME}/~}" | |
| # shellcheck disable=SC2088 # literal ~/ is for display, not expansion | |
| [[ "${short_dir}" = "~" ]] && short_dir="~/" | |
| branch="" | |
| if gb=$(git -C "${dir}" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null); then | |
| branch="${gb}" | |
| elif gb=$(git -C "${dir}" --no-optional-locks rev-parse --short HEAD 2>/dev/null); then | |
| branch="detached:${gb}" | |
| fi | |
| # Working-tree state: "*" if dirty (incl. untracked), "↑N"/"↓N" vs upstream. | |
| git_state="" | |
| if [[ -n "${branch}" ]]; then | |
| [[ -n "$(git -C "${dir}" --no-optional-locks status --porcelain 2>/dev/null)" ]] && git_state+="*" | |
| if ab=$(git -C "${dir}" --no-optional-locks rev-list --count --left-right '@{upstream}...HEAD' 2>/dev/null); then | |
| behind=${ab%% *}; ahead=${ab##* } | |
| [[ "${ahead:-0}" -gt 0 ]] && git_state+=" ↑${ahead}" | |
| [[ "${behind:-0}" -gt 0 ]] && git_state+=" ↓${behind}" | |
| fi | |
| fi | |
| # --- Usage from Anthropic API (your own token; cached 2 min) --- | |
| usage_5h="" usage_7d="" resets_5h="" resets_7d="" | |
| if [[ "${SHOW_USAGE}" = 1 ]]; then | |
| CACHE="${HOME}/.cache/cc-usage.json" | |
| TTL=120 | |
| now=$(date +%s) | |
| mkdir -p "${HOME}/.cache" | |
| fetch_usage=1 | |
| if [[ -f "${CACHE}" ]]; then | |
| age=$(( now - $(file_mtime "${CACHE}") )) | |
| [[ "${age}" -lt "${TTL}" ]] && fetch_usage=0 | |
| fi | |
| if [[ "${fetch_usage}" -eq 1 ]]; then | |
| token=$(jq -r '.claudeAiOauth.accessToken // empty' "${HOME}/.claude/.credentials.json" 2>/dev/null) | |
| token=${token:-$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)} | |
| if [[ -n "${token}" ]]; then | |
| resp=$(curl -s --max-time 5 \ | |
| "https://api.anthropic.com/api/oauth/usage" \ | |
| -H "Authorization: Bearer ${token}" \ | |
| -H "anthropic-beta: oauth-2025-04-20" \ | |
| -H "User-Agent: claude-code/${version}" 2>/dev/null) | |
| if [[ -n "${resp}" ]] && jq -e '.five_hour' <<< "${resp}" >/dev/null 2>&1; then | |
| echo "${resp}" > "${CACHE}" | |
| else | |
| touch "${CACHE}" # cache failures too, to avoid hammering the API | |
| fi | |
| fi | |
| fi | |
| if [[ -f "${CACHE}" ]]; then | |
| eval "$(jq -r ' | |
| @sh "usage_5h=\(.five_hour.utilization // "")", | |
| @sh "usage_7d=\(.seven_day.utilization // "")", | |
| @sh "resets_5h=\(.five_hour.resets_at // "")", | |
| @sh "resets_7d=\(.seven_day.resets_at // "")" | |
| ' "${CACHE}" 2>/dev/null)" | |
| fi | |
| fi | |
| # Local clock time an ISO (UTC) reset lands at: "3pm" / "3:44pm", or "wed 3pm". | |
| format_reset() { | |
| local ts="${1%%.*}" reset_sec timefmt | |
| reset_sec=$(iso_to_epoch "${ts}") || return | |
| [[ -n "${reset_sec}" ]] || return | |
| if [[ "$(epoch_fmt "${reset_sec}" %M)" = "00" ]]; then timefmt="%-I%p"; else timefmt="%-I:%M%p"; fi | |
| [[ "${today_j}" != "$(epoch_fmt "${reset_sec}" %j)" ]] && timefmt="%a ${timefmt}" | |
| epoch_fmt "${reset_sec}" "${timefmt}" | tr '[:upper:]' '[:lower:]' | |
| } | |
| today_j=$(date +%j) # "now" day-of-year, computed once for format_reset | |
| # --- Session token throughput (in + out + cache_creation; excludes cache_read) --- | |
| # The scan is O(transcript size) and the line re-renders many times between | |
| # turns, so cache the sum and only rescan when the transcript's mtime moves. | |
| session_tokens=0 | |
| if [[ "${SHOW_SESSION_TOKENS}" = 1 ]]; then | |
| transcript="${transcript_path}" | |
| if [[ -z "${transcript}" ]] && [[ -n "${session_id}" ]] && [[ -n "${dir}" ]]; then | |
| enc=$(printf '%s' "${dir}" | sed 's/[^a-zA-Z0-9]/-/g') | |
| transcript="${HOME}/.claude/projects/${enc}/${session_id}.jsonl" | |
| fi | |
| if [[ -n "${transcript}" ]] && [[ -f "${transcript}" ]]; then | |
| TOK_CACHE="${HOME}/.cache/cc-tokens.json" | |
| t_mtime=$(file_mtime "${transcript}") | |
| [[ -f "${TOK_CACHE}" ]] && eval "$(jq -r ' | |
| @sh "c_file=\(.transcript // "")", @sh "c_mtime=\(.mtime // 0)", @sh "c_tokens=\(.tokens // 0)" | |
| ' "${TOK_CACHE}" 2>/dev/null)" | |
| if [[ "${c_file:-}" = "${transcript}" ]] && [[ "${c_mtime:-}" = "${t_mtime}" ]]; then | |
| session_tokens=${c_tokens} | |
| else | |
| session_tokens=$(jq -n 'reduce inputs as $r (0; | |
| if $r.type == "assistant" and ($r.message.usage) | |
| then . + (($r.message.usage.input_tokens // 0) | |
| + ($r.message.usage.output_tokens // 0) | |
| + ($r.message.usage.cache_creation_input_tokens // 0)) | |
| else . end)' "${transcript}" 2>/dev/null) | |
| session_tokens=${session_tokens:-0} | |
| mkdir -p "${HOME}/.cache" | |
| printf '{"transcript":"%s","mtime":%s,"tokens":%s}\n' \ | |
| "${transcript}" "${t_mtime:-0}" "${session_tokens}" > "${TOK_CACHE}" 2>/dev/null | |
| fi | |
| fi | |
| fi | |
| # Compact token count: 1234 -> 1.2k, 1234567 -> 1.2M | |
| fmt_tokens() { | |
| local n=${1} | |
| if (( n >= 1000000 )); then printf '%d.%dM' $(( n / 1000000 )) $(( (n % 1000000) / 100000 )) | |
| elif (( n >= 1000 )); then printf '%d.%dk' $(( n / 1000 )) $(( (n % 1000) / 100 )) | |
| else printf '%d' "${n}" | |
| fi | |
| } | |
| # pct + low/mid/high colors -> the color for that tier (mid at 50%, high at 80%) | |
| pct_color() { | |
| local pct=${1%.*}; pct=${pct:-0} | |
| if (( pct >= 80 )); then printf '%s' "${4}" | |
| elif (( pct >= 50 )); then printf '%s' "${3}" | |
| else printf '%s' "${2}" | |
| fi | |
| } | |
| # icon label pct resets -> a formatted usage segment, or "" when pct is empty | |
| usage_seg() { | |
| [[ -n "${3}" ]] || return | |
| local clr reset | |
| clr=$(pct_color "${3}" '\e[38;5;194m' '\e[38;5;228m' '\e[38;5;210m') | |
| [[ -n "${4}" ]] && reset=$(format_reset "${4}") | |
| printf '%s%s %s: %.0f%%%s%s' "${clr}" "${1}" "${2}" "${3}" "${reset:+ (↻${reset})}" "${RST}" | |
| } | |
| # Join non-empty segments with two spaces (escapes stay literal for final %b) | |
| join_segs() { | |
| local out="" s | |
| for s in "$@"; do | |
| [[ -n "${s}" ]] || continue | |
| out+="${out:+ }${s}" | |
| done | |
| printf '%s' "${out}" | |
| } | |
| # --- Colors (ANSI-256) --- | |
| RST='\e[0m' | |
| DIR_CLR='\e[38;5;117m' | |
| GIT_CLR='\e[38;5;150m' | |
| MODEL_CLR='\e[38;5;147m' | |
| VER_CLR='\e[38;5;249m' | |
| CTX_CLR=$(pct_color "${used_pct}" '\e[38;5;158m' '\e[38;5;215m' '\e[38;5;203m') | |
| # --- Assemble (empty segments are dropped by join_segs; two-space separator) --- | |
| seg_branch=""; [[ -n "${branch}" ]] && seg_branch="${GIT_CLR}🌿 ${branch}${git_state}${RST}" | |
| seg_ver=""; [[ -n "${version}" ]] && seg_ver="${VER_CLR}📟 v${version}${RST}" | |
| seg_sess=""; { [[ "${SHOW_SESSION_ID}" = 1 ]] && [[ -n "${session_id}" ]]; } && seg_sess="${VER_CLR}🔑 ${session_id:0:8}${RST}" | |
| line1=$(join_segs \ | |
| "${DIR_CLR}📁 ${short_dir}${RST}" \ | |
| "${seg_branch}" \ | |
| "${MODEL_CLR}🤖 ${model}${effort:+ (${effort})}${RST}" \ | |
| "${seg_ver}" \ | |
| "${seg_sess}") | |
| seg_ctx="${CTX_CLR}🧠 Ctx: ${used_pct:-0}%" | |
| (( ${used_tokens:-0} > 0 )) && seg_ctx+=" ($(( (used_tokens + 500) / 1000 ))k)" | |
| seg_ctx+="${RST}" | |
| seg_tok=""; (( ${session_tokens:-0} > 0 )) && seg_tok="${CTX_CLR}🎟️ $(fmt_tokens "${session_tokens}")${RST}" | |
| line2=$(join_segs \ | |
| "$(usage_seg "⏳" "5h" "${usage_5h}" "${resets_5h}")" \ | |
| "$(usage_seg "📅" "7d" "${usage_7d}" "${resets_7d}")" \ | |
| "${seg_ctx}" \ | |
| "${seg_tok}") | |
| printf '%b\n%b\n' "${line1}" "${line2}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment