# Claude Code status line: context + 5h/7d rate limit bars with reset times, clickable repo/branch/ticket/MR links (jq only; optional glab)

> Source: <https://gist.github.com/rootindex/d09e8215bef62ec9d40f394cd3f34ce1>
> Published: 2026-07-13 09:55:00+00:00

| #!/bin/bash | |
| # Claude Code status line — two lines, subscription-focused (rate limits, no cost). | |
| # | |
| # ● my-project · ✦ Fable 5 · ⚡high · 🧠 · 🏷 ACME-123 (feature/ACME-123-fix) · 🔀 !42 | |
| # ██████░░░░ 62% (124k/200k) · 5h ████░░░░░░ 41% →17:00 · 7d ██░░░░░░░░ 23% →Wed · ⏱ 34m | |
| # | |
| # Features | |
| # - Context bar: tokens used vs window, green/yellow/red at 70/90% | |
| # - Rate limits: 5-hour + weekly windows as matching bars, with reset time | |
| # (clock time for 5h, weekday for 7d). Claude.ai Pro/Max only; hidden otherwise. | |
| # - Clickable (OSC 8) project name -> repo, branch -> tree, Jira/GitLab ticket | |
| # parsed from the branch name, open MR badge via glab (cached, non-blocking) | |
| # - Effort + thinking badges, session duration, subdir indicator | |
| # | |
| # Requirements: jq. Optional: glab (MR badge), git. macOS + Linux. | |
| # Terminals with OSC 8 support (iTerm2, Ghostty, WezTerm, kitty) get clickable | |
| # links; others just show plain text. | |
| # | |
| # Install: | |
| # 1. Save as ~/.claude/statusline.sh && chmod +x ~/.claude/statusline.sh | |
| # 2. In ~/.claude/settings.json: | |
| # "statusLine": { "type": "command", "command": "~/.claude/statusline.sh" } | |
| # | |
| # ── config ───────────────────────────────────────────────────────────────── | |
| # Jira links are per-project: map each Jira project-key PREFIX to its base URL. | |
| # Only prefixes listed here get a clickable link; an unknown prefix shows the | |
| # ticket ID as plain text (never a wrong link). Add one line per project. | |
| # GitLab/GitHub issue + repo links need NO config (built from the git origin). | |
| jira_base_for() { | |
| case "$1" in | |
| # ACME) printf 'https://acme.atlassian.net' ;; | |
| *) printf '' ;; | |
| esac | |
| } | |
| GIT_CACHE_TTL=5 # seconds; branch lookup is cached per session to keep renders fast | |
| # ─────────────────────────────────────────────────────────────────────────── | |
| input=$(cat) | |
| # One jq call: floors numerics, defaults nulls, -1 sentinel for absent rate limit. | |
| # 0x1F (unit separator) delimiter: non-whitespace, so `read` keeps empty fields | |
| # instead of collapsing consecutive ones (the tab/whitespace-IFS gotcha). | |
| IFS=$'\037' read -r MODEL PROJECT CURDIR PCT WIN TOKENS DURATION_MS EFFORT THINK SID RHOST ROWNER RNAME RATE5 RATE7 RESET5 RESET7 < <( | |
| printf '%s' "$input" | jq -r '[ | |
| (.model.display_name // "?"), | |
| (.workspace.project_dir // .workspace.current_dir // "."), | |
| (.workspace.current_dir // "."), | |
| ((.context_window.used_percentage // 0) | floor), | |
| (.context_window.context_window_size // 200000), | |
| (.context_window.total_input_tokens // 0), | |
| ((.cost.total_duration_ms // 0) | floor), | |
| (.effort.level? // ""), | |
| (.thinking.enabled // false), | |
| (.session_id // "default"), | |
| (.workspace.repo.host? // ""), | |
| (.workspace.repo.owner? // ""), | |
| (.workspace.repo.name? // ""), | |
| ((.rate_limits.five_hour.used_percentage // -1) | floor), | |
| ((.rate_limits.seven_day.used_percentage // -1) | floor), | |
| ((.rate_limits.five_hour.resets_at // -1) | floor), | |
| ((.rate_limits.seven_day.resets_at // -1) | floor) | |
| ] | map(tostring) | join("�")' | |
| ) | |
| # Guard PCT so the integer comparisons below never crash on empty/non-numeric. | |
| case "$PCT" in ''|*[!0-9]*) PCT=0 ;; esac | |
| [ "$PCT" -gt 100 ] && PCT=100 | |
| # ── colors (literal ESC so we can printf '%s') ───────────────────────────── | |
| ESC=$'\033' | |
| CYAN="${ESC}[36m"; GREEN="${ESC}[32m"; YELLOW="${ESC}[33m"; RED="${ESC}[31m" | |
| MAGENTA="${ESC}[35m"; BLUE="${ESC}[34m"; DIM="${ESC}[2m"; BOLD="${ESC}[1m"; RESET="${ESC}[0m" | |
| BEL=$'\a'; OSC="${ESC}]8;;" | |
| # OSC 8 hyperlink: hlink <url> <text> | |
| hlink() { printf '%s%s%s%s%s%s' "$OSC" "$1" "$BEL" "$2" "$OSC" "$BEL"; } | |
| # ── context bar (pure bash; tr corrupts in C locale) ─────────────────────── | |
| if [ "$PCT" -ge 90 ]; then BAR_COLOR="$RED" | |
| elif [ "$PCT" -ge 70 ]; then BAR_COLOR="$YELLOW" | |
| else BAR_COLOR="$GREEN"; fi | |
| FILLED=$((PCT / 10)); EMPTY=$((10 - FILLED)) | |
| bar() { local n=$1 ch=$2 o=; while [ "$n" -gt 0 ]; do o="$o$ch"; n=$((n - 1)); done; printf '%s' "$o"; } | |
| BAR="$(bar "$FILLED" '█')$(bar "$EMPTY" '░')" | |
| fmt_k() { awk -v n="$1" 'BEGIN{if(n>=1000000)printf "%.1fM",n/1000000;else if(n>=1000)printf "%dk",n/1000;else printf "%d",n}'; } | |
| TOK_H=$(fmt_k "$TOKENS"); WIN_H=$(fmt_k "$WIN") | |
| MINS=$((DURATION_MS / 60000)) | |
| # ── git branch + origin remote (cached per session) ──────────────────────── | |
| mtime() { stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null; } | |
| CACHE="${TMPDIR:-/tmp}/cc_sl_branch_${SID}" | |
| NOW=$(date +%s) | |
| if [ -f "$CACHE" ] && [ $((NOW - $(mtime "$CACHE"))) -lt "$GIT_CACHE_TTL" ]; then | |
| IFS=$'\037' read -r BRANCH GREMOTE < "$CACHE" | |
| else | |
| GITDATA=$( (cd "$CURDIR" 2>/dev/null && printf '%s\037%s' \ | |
| "$(git symbolic-ref --short -q HEAD 2>/dev/null)" \ | |
| "$(git remote get-url origin 2>/dev/null)") 2>/dev/null ) | |
| printf '%s' "$GITDATA" > "$CACHE" 2>/dev/null | |
| IFS=$'\037' read -r BRANCH GREMOTE <<< "$GITDATA" | |
| fi | |
| # ── repo web URL from the git origin (handles nested GitLab groups, where | |
| # the JSON's workspace.repo.owner/name would drop the subgroup); JSON is fallback. | |
| REPO_URL="" | |
| if [ -n "$GREMOTE" ]; then | |
| REPO_URL=$(printf '%s' "$GREMOTE" | sed -E \ | |
| -e 's#^git@([^:]+):#https://\1/#' \ | |
| -e 's#^ssh://git@([^/]+)/#https://\1/#' \ | |
| -e 's#^https://[^@/]*@#https://#' \ | |
| -e 's#\.git$##') | |
| elif [ -n "$RHOST" ]; then | |
| REPO_URL="https://${RHOST}/${ROWNER}/${RNAME}" | |
| fi | |
| RWEB_HOST="${REPO_URL#https://}"; RWEB_HOST="${RWEB_HOST%%/*}" | |
| # ── ticket from branch (Jira key, else GitLab issue number) ───────────────── | |
| TICKET=""; TICKET_URL="" | |
| jira_re='([A-Z]{2,}-[0-9]+)' | |
| gl_re='^([0-9]+)[-_/]' | |
| if [[ "$BRANCH" =~ $jira_re ]]; then | |
| TICKET="${BASH_REMATCH[1]}" | |
| base="$(jira_base_for "${TICKET%%-*}")" # prefix before the dash -> Jira host | |
| [ -n "$base" ] && TICKET_URL="${base}/browse/${TICKET}" | |
| elif [[ "$BRANCH" =~ $gl_re ]]; then | |
| num="${BASH_REMATCH[1]}" | |
| TICKET="#${num}" | |
| [ -n "$REPO_URL" ] && TICKET_URL="${REPO_URL}/-/issues/${num}" | |
| fi | |
| # Branch name links to its page on the remote (GitLab tree, or GitHub tree). | |
| BRANCH_DISP="$BRANCH" | |
| if [ -n "$REPO_URL" ] && [ -n "$BRANCH" ]; then | |
| case "$RWEB_HOST" in | |
| *github*) TREE_URL="${REPO_URL}/tree/${BRANCH}" ;; | |
| *) TREE_URL="${REPO_URL}/-/tree/${BRANCH}" ;; | |
| esac | |
| BRANCH_DISP="$(hlink "$TREE_URL" "$BRANCH")" | |
| fi | |
| # ── open merge request for this branch (GitLab via glab) ──────────────────── | |
| # A network call, so the result is cached (MR_TTL) and refreshed by a detached | |
| # background job: renders never block, showing the last-known value meanwhile. | |
| # An anti-stampede attempt file caps spawns to one per ~20s while a fetch runs. | |
| # Needs glab authed to the origin host; if glab is missing/unauthed the MR | |
| # segment just stays empty (no error). | |
| MR_IID=""; MR_URL=""; MR_DRAFT=""; MR_TTL=90 | |
| if [ -n "$BRANCH" ] && [ -n "$REPO_URL" ]; then | |
| MRCACHE="${TMPDIR:-/tmp}/cc_sl_mr_${SID}"; ATT="${MRCACHE}.attempt" | |
| if [ -f "$MRCACHE" ]; then | |
| IFS=$'\037' read -r cb MR_IID MR_URL MR_DRAFT < "$MRCACHE" | |
| [ "$cb" = "$BRANCH" ] || { MR_IID=""; MR_URL=""; MR_DRAFT=""; } | |
| fi | |
| fresh=0 | |
| [ -f "$MRCACHE" ] && [ "$cb" = "$BRANCH" ] && [ $((NOW - $(mtime "$MRCACHE"))) -lt "$MR_TTL" ] && fresh=1 | |
| if [ "$fresh" = 0 ] && command -v glab >/dev/null 2>&1 \ | |
| && { [ ! -f "$ATT" ] || [ $((NOW - $(mtime "$ATT"))) -ge 20 ]; }; then | |
| : > "$ATT" 2>/dev/null | |
| ( | |
| cd "$CURDIR" 2>/dev/null || exit | |
| out=$(glab mr list -s "$BRANCH" -F json 2>/dev/null \ | |
| | jq -r '(.[0] // {}) | [((.iid // "")|tostring),(.web_url // ""),((.draft // false)|tostring)] | join("�")' 2>/dev/null) | |
| printf '%s\037%s\n' "$BRANCH" "$out" > "${MRCACHE}.tmp" 2>/dev/null | |
| mv -f "${MRCACHE}.tmp" "$MRCACHE" 2>/dev/null | |
| ) >/dev/null 2>&1 & | |
| fi | |
| fi | |
| MR_SEG="" | |
| if [ -n "$MR_IID" ]; then | |
| if [ "$MR_DRAFT" = "true" ]; then MC="$DIM"; else MC="$MAGENTA"; fi | |
| if [ -n "$MR_URL" ]; then MR_DISP="$(hlink "$MR_URL" "!${MR_IID}")"; else MR_DISP="!${MR_IID}"; fi | |
| MR_SEG=" · ${MC}🔀 ${MR_DISP}${RESET}" | |
| fi | |
| # ── working / idle status ────────────────────────────────────────────────── | |
| if [ -n "$BRANCH" ]; then | |
| STATUS="${GREEN}●${RESET}" | |
| else | |
| STATUS="${DIM}○${RESET}" | |
| fi | |
| # ── project name (linked to repo) + relative dir segment ─────────────────── | |
| PROJECT_NAME="${PROJECT##*/}" | |
| if [ -n "$REPO_URL" ]; then | |
| PROJ_DISP="$(hlink "$REPO_URL" "$PROJECT_NAME")" | |
| else | |
| PROJ_DISP="$PROJECT_NAME" | |
| fi | |
| # At root the project name is already on line 1 (status badge); only show 📁 once | |
| # you've navigated into a subdir/submodule, so it reads as a "you moved" signal. | |
| if [ "$CURDIR" = "$PROJECT" ]; then | |
| DIRSEG="" | |
| else | |
| REL="${CURDIR#"$PROJECT"/}" | |
| if [ "$REL" = "$CURDIR" ]; then DIRSEG="📁 ${CURDIR##*/}"; else DIRSEG="📁 › ${REL}"; fi | |
| fi | |
| # ── badges ───────────────────────────────────────────────────────────────── | |
| EFFORT_BADGE="" | |
| if [ -n "$EFFORT" ]; then | |
| case "$EFFORT" in | |
| low) EC="$DIM" ;; medium) EC="$GREEN" ;; high) EC="$YELLOW" ;; | |
| xhigh|max) EC="$RED" ;; *) EC="$RESET" ;; | |
| esac | |
| EFFORT_BADGE=" · ${EC}⚡${EFFORT}${RESET}" | |
| fi | |
| THINK_BADGE="" | |
| [ "$THINK" = "true" ] && THINK_BADGE=" · ${MAGENTA}🧠${RESET}" | |
| # work segment: ticket+branch when working, dim "idle" otherwise | |
| if [ -n "$BRANCH" ]; then | |
| if [ -n "$TICKET" ]; then | |
| if [ -n "$TICKET_URL" ]; then TKT_DISP="$(hlink "$TICKET_URL" "$TICKET")"; else TKT_DISP="$TICKET"; fi | |
| WORK_SEG=" · 🏷 ${TKT_DISP} ${DIM}(${BRANCH_DISP})${RESET}${MR_SEG}" | |
| else | |
| WORK_SEG=" · ${DIM}(${BRANCH_DISP})${RESET}${MR_SEG}" | |
| fi | |
| else | |
| WORK_SEG=" · ${DIM}idle${RESET}" | |
| fi | |
| # ── rate limits (Claude.ai Pro/Max; absent for API-key users) ────────────── | |
| # rate bars: same glyphs/colors/width as the context bar | |
| rate_bar() { | |
| local p=$1 c | |
| if [ "$p" -ge 90 ]; then c="$RED" | |
| elif [ "$p" -ge 70 ]; then c="$YELLOW" | |
| else c="$GREEN"; fi | |
| local f=$((p / 10)); [ "$f" -gt 10 ] && f=10 | |
| printf '%s%s%s%s' "$c" "$(bar "$f" '█')" "$(bar $((10 - f)) '░')" "$RESET" | |
| } | |
| # fmt_epoch <epoch> <format>: BSD date first, GNU date fallback | |
| fmt_epoch() { date -r "$1" "$2" 2>/dev/null || date -d "@$1" "$2" 2>/dev/null; } | |
| # reset markers: 5h window resets within hours (clock time), 7d within days (weekday) | |
| R5=""; R7="" | |
| [ "$RESET5" -gt 0 ] 2>/dev/null && R5=" ${DIM}→$(fmt_epoch "$RESET5" +%H:%M)${RESET}" | |
| [ "$RESET7" -gt 0 ] 2>/dev/null && R7=" ${DIM}→$(fmt_epoch "$RESET7" +%a)${RESET}" | |
| RATE_SEG="" | |
| [ "$RATE5" -ge 0 ] 2>/dev/null && RATE_SEG=" · 5h $(rate_bar "$RATE5") ${RATE5}%${R5}" | |
| [ "$RATE7" -ge 0 ] 2>/dev/null && RATE_SEG="${RATE_SEG} · 7d $(rate_bar "$RATE7") ${DIM}${RATE7}%${RESET}${R7}" | |
| # ── render ───────────────────────────────────────────────────────────────── | |
| printf '%s %s · ✦ %s%s%s%s%s\n' \ | |
| "$STATUS" "$PROJ_DISP" "${CYAN}${MODEL}${RESET}" "$EFFORT_BADGE" "$THINK_BADGE" "$WORK_SEG" "" | |
| DIR_PREFIX=""; [ -n "$DIRSEG" ] && DIR_PREFIX="${DIRSEG} · " | |
| printf '%s%s%s%s %s%% %s(%s/%s)%s%s · ⏱ %sm\n' \ | |
| "$DIR_PREFIX" "$BAR_COLOR" "$BAR" "$RESET" "$PCT" "$DIM" "$TOK_H" "$WIN_H" "$RESET" "$RATE_SEG" "$MINS" |
