Created
August 17, 2026 07:15
-
-
Save Piotr1215/66ccb16f3510e17bb12e1035ac0c6b65 to your computer and use it in GitHub Desktop.
Claude Code statusline: git state, kube context, context-vs-autocompact percentage, and 5h rate-limit burn projection
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 statusline. | |
| # | |
| # ~/dev/dotfiles [master][!?] ⛵ homelab | Opus 5 43% | 61% of 5h window ⏰ 2h16m(1h45m) | |
| # | |
| # Install: | |
| # 1. save as ~/.claude/statusline-script.sh, chmod +x | |
| # 2. add to ~/.claude/settings.json: | |
| # "statusLine": { "type": "command", | |
| # "command": "bash /home/YOU/.claude/statusline-script.sh" } | |
| # | |
| # Requires: bash 4.4+ (mapfile -d), jq, git. Optional: fd, findmnt, kubectl. | |
| # | |
| # Segments you may want to delete, they are specific to my machine: | |
| # - design-kit indicator, reads .claude/specs/<branch> | |
| # - spawn-mode indicator, gated on a tmux pane named "triage" | |
| # - kube context via `kctx`, falls back to `kubectl config current-context` | |
| # | |
| # The long comments are the reasoning behind each number. Keep or strip as you | |
| # like; nothing below depends on them. | |
| set -eo pipefail | |
| # Read Claude Code session data | |
| input=$(cat) | |
| # One jq pass, not one per field. This runs on a 300ms debounce and Claude Code | |
| # kills an in-flight render when the next update arrives, so every process spawn | |
| # here competes with the line actually appearing. Nine invocations each re-parsed | |
| # the same payload; a single tab-separated read costs one. | |
| # | |
| # NUL-separated, NOT tab. Tab is an IFS *whitespace* character, so `read` with | |
| # IFS=$'\t' collapses a run of them into one delimiter and every field after a | |
| # pair of empty values shifts up. Most of these are empty in a normal session | |
| # (no PR, no project_dir), and the shift put a unix timestamp in the context | |
| # percent slot and the window size in the token count: "1000K 1786753800%". | |
| # NUL is not IFS whitespace and cannot occur inside any value, so mapfile -d '' | |
| # preserves empty fields positionally. | |
| mapfile -d '' -t _f < <(echo "$input" | jq -j '[ | |
| .model.display_name // "", | |
| (.workspace.current_dir // .cwd // ""), | |
| .workspace.project_dir // "", | |
| .output_style.name // "default", | |
| .pr.number // "", | |
| .pr.url // "", | |
| .context_window.total_input_tokens // 0, | |
| .context_window.total_output_tokens // 0, | |
| .context_window.used_percentage // "", | |
| .rate_limits.five_hour.used_percentage // "", | |
| .rate_limits.five_hour.resets_at // "" | |
| ] | map(tostring) | join("\u0000")') | |
| model_name="${_f[0]}" current_dir="${_f[1]}" project_dir="${_f[2]}" | |
| output_style="${_f[3]}" pr_number="${_f[4]}" pr_url="${_f[5]}" | |
| total_input="${_f[6]}" total_output="${_f[7]}" ctx_pct="${_f[8]}" | |
| rate_5h="${_f[9]}" rate_5h_reset="${_f[10]}" | |
| unset _f | |
| # Get directory for display (relative to project if available, otherwise basename) | |
| if [[ -n "$project_dir" && "$current_dir" == "$project_dir"* ]]; then | |
| rel_dir="${current_dir#$project_dir}" | |
| rel_dir="${rel_dir#/}" | |
| display_dir="${rel_dir:-$(basename "$project_dir")}" | |
| else | |
| display_dir=$(basename "$current_dir") | |
| fi | |
| # Change to the current directory for git operations | |
| cd "$current_dir" 2>/dev/null || true | |
| # Git information (matching Starship format exactly) | |
| git_info="" | |
| if git rev-parse --git-dir >/dev/null 2>&1; then | |
| branch=$(git branch --show-current 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "detached") | |
| # Git status indicators (matching Starship default symbols) | |
| status="" | |
| if ! git diff --quiet 2>/dev/null; then | |
| status="${status}!" # Modified files | |
| fi | |
| if ! git diff --cached --quiet 2>/dev/null; then | |
| status="${status}+" # Staged files | |
| fi | |
| if [[ -n $(git ls-files --others --exclude-standard 2>/dev/null) ]]; then | |
| status="${status}?" # Untracked files | |
| fi | |
| # Check for ahead/behind | |
| ahead_behind="" | |
| if upstream=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null); then | |
| local_commits=$(git rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0") | |
| remote_commits=$(git rev-list --count @{upstream}..HEAD 2>/dev/null || echo "0") | |
| if [[ $local_commits -gt 0 ]]; then | |
| ahead_behind="${ahead_behind}⇣${local_commits}" | |
| fi | |
| if [[ $remote_commits -gt 0 ]]; then | |
| ahead_behind="${ahead_behind}⇡${remote_commits}" | |
| fi | |
| fi | |
| git_info="[${branch}]" | |
| if [[ -n "$status" || -n "$ahead_behind" ]]; then | |
| git_info="${git_info}[${status}${ahead_behind}]" | |
| fi | |
| # Persist the PR number keyed by repo root so external tools can read it | |
| # without a gh round-trip. Absent .pr means no open PR -> drop the cache. | |
| repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true) | |
| if [[ -n "$repo_root" ]]; then | |
| pr_cache_dir="/tmp/claude-pr-cache" | |
| mkdir -p "$pr_cache_dir" 2>/dev/null || true | |
| pr_key=$(printf '%s' "$repo_root" | md5sum | cut -d' ' -f1) | |
| if [[ -n "$pr_number" ]]; then | |
| printf '%s' "$pr_number" > "$pr_cache_dir/$pr_key" 2>/dev/null || true | |
| printf '%s' "$pr_url" > "$pr_cache_dir/$pr_key.url" 2>/dev/null || true | |
| else | |
| rm -f "$pr_cache_dir/$pr_key" "$pr_cache_dir/$pr_key.url" 2>/dev/null || true | |
| fi | |
| fi | |
| fi | |
| # Kubernetes context. In tmux, read the authoritative pane selection rather | |
| # than Claude's inherited environment. Claude redraws this line on its own | |
| # event cycle; the tmux pane border remains the immediate indicator. | |
| kube_info="" | |
| if [[ -n "${TMUX:-}" ]] && command -v kctx >/dev/null 2>&1; then | |
| context=$(kctx current --output label 2>/dev/null || kctx current --output id 2>/dev/null || true) | |
| elif command -v kubectl >/dev/null 2>&1; then | |
| context=$(kubectl config current-context 2>/dev/null || true) | |
| else | |
| context="" | |
| fi | |
| if [[ -n "$context" && "$context" != "docker-desktop" ]]; then | |
| kube_info="⛵ ${context}" | |
| fi | |
| # AWS context - only show profile name if explicitly set (no slow API calls) | |
| aws_info="" | |
| if [[ -n "$AWS_PROFILE" ]]; then | |
| aws_info=" ${AWS_PROFILE}" | |
| fi | |
| # Python virtual environment | |
| python_info="" | |
| if [[ -n "$VIRTUAL_ENV" ]]; then | |
| venv_name=$(basename "$VIRTUAL_ENV") | |
| python_info="🐍${venv_name}" | |
| fi | |
| # Node.js version (if in a Node project) | |
| node_info="" | |
| if [[ -f "package.json" ]] && command -v node >/dev/null 2>&1; then | |
| node_version=$(node --version 2>/dev/null | sed 's/v//') | |
| node_info="⬢${node_version}" | |
| fi | |
| # Container indicator | |
| container_info="" | |
| if [[ -f "Dockerfile" ]]; then | |
| container_info="🐋" | |
| fi | |
| # Design-kit indicator (check for branch design-kit progress) | |
| design_info="" | |
| if [[ -d ".claude/specs" ]] && git rev-parse --git-dir >/dev/null 2>&1; then | |
| current_branch=$(git branch --show-current 2>/dev/null || echo "detached") | |
| safe_branch=$(echo "$current_branch" | sed 's/[^a-zA-Z0-9._-]/-/g') | |
| design_dir=".claude/specs/$safe_branch" | |
| if [[ -d "$design_dir" ]]; then | |
| # Check design-kit components | |
| design_parts="" | |
| # Check for PLAN.md (Phase 0) | |
| [[ -f "$design_dir/PLAN.md" ]] && design_parts="${design_parts}P" | |
| # Check for proofs/ directory (Phase 1) | |
| if [[ -d "$design_dir/proofs" ]]; then | |
| proof_count=$(fd -t f . "$design_dir/proofs" 2>/dev/null | wc -l) | |
| if [[ $proof_count -gt 0 ]]; then | |
| design_parts="${design_parts}✓${proof_count}" | |
| else | |
| design_parts="${design_parts}✓0" | |
| fi | |
| fi | |
| # Check for tasks/ directory (Phase 2) | |
| if [[ -d "$design_dir/tasks" ]]; then | |
| task_count=$(fd -t f . "$design_dir/tasks" 2>/dev/null | wc -l) | |
| if [[ $task_count -gt 0 ]]; then | |
| design_parts="${design_parts}T${task_count}" | |
| else | |
| design_parts="${design_parts}T0" | |
| fi | |
| fi | |
| if [[ -n "$design_parts" ]]; then | |
| design_info="🎨[${design_parts}]" | |
| else | |
| design_info="🎨[empty]" | |
| fi | |
| fi | |
| fi | |
| # Mount indicator (matching your Starship custom.mount_indicator) | |
| mount_info="" | |
| if findmnt -T . -o FSTYPE -n 2>/dev/null | grep -q -E '^(nfs|fuse|cifs|smb)'; then | |
| mount_source=$(findmnt -T . -o SOURCE -n 2>/dev/null || echo "") | |
| if [[ -n "$mount_source" ]]; then | |
| mount_info="🐎 (${mount_source})" | |
| else | |
| mount_info="🐎" | |
| fi | |
| fi | |
| # Command duration indicator (if available from previous commands) | |
| duration_info="" | |
| if [[ -n "$HISTTIMEFORMAT" ]] && command -v fc >/dev/null 2>&1; then | |
| # This would need shell integration to work properly, skipping for now | |
| : | |
| fi | |
| # Output style indicator (if not default) | |
| style_info="" | |
| if [[ "$output_style" != "default" && -n "$output_style" ]]; then | |
| style_info="[${output_style}]" | |
| fi | |
| # Gordon spawn-mode indicator: Piotr's dispatch switch, ~/.claude/gordon-spawn-mode. | |
| # This script is shared by EVERY Claude pane, so the segment is gated hard on the | |
| # triage pane. Discriminator is the @agent_name tmux pane option, which | |
| # __tmux_agent_status.sh sets to the registered agent name on agent_register; | |
| # Gordon registers as exactly "triage" (workers register as <repo>-<ISSUE-ID>). | |
| # Fallback covers the gap between pane start and registration: a tmux session or | |
| # window named exactly "triage". Cost is one tmux call plus one short file read. | |
| spawn_info="" | |
| if [[ -n "${TMUX:-}" && -n "${TMUX_PANE:-}" ]] && command -v tmux >/dev/null 2>&1; then | |
| pane_ident=$(tmux display-message -p -t "$TMUX_PANE" \ | |
| '#{@agent_name}|#{session_name}|#{window_name}' 2>/dev/null || true) | |
| agent_tag="${pane_ident%%|*}" | |
| rest_ident="${pane_ident#*|}" | |
| sess_name="${rest_ident%%|*}" | |
| win_name="${rest_ident##*|}" | |
| if [[ "$agent_tag" == "triage" || "$sess_name" == "triage" || "$win_name" == "triage" ]]; then | |
| spawn_mode="" | |
| if [[ -r "$HOME/.claude/gordon-spawn-mode" ]]; then | |
| IFS= read -r spawn_mode < "$HOME/.claude/gordon-spawn-mode" 2>/dev/null || spawn_mode="" | |
| fi | |
| spawn_mode="${spawn_mode//[[:space:]]/}" | |
| # Missing, unreadable, or anything other than "manual" is treated as auto, | |
| # matching the contract in triage-agent-system-prompt.md. | |
| if [[ "$spawn_mode" == "manual" ]]; then | |
| spawn_info=$'\033[1;33m✋manual\033[0m' | |
| else | |
| spawn_info=$'\033[0;90m⇢auto\033[0m' | |
| fi | |
| fi | |
| fi | |
| # One scale for every percentage on the line. Both figures answer the same | |
| # question (how much of a budget is gone) so they must grade identically, or the | |
| # eye learns two conventions and trusts neither. Green through 50, yellow to 75, | |
| # red beyond. Colour lands on the number alone: the surrounding words are | |
| # constant and do not change meaning with the value, so tinting them only makes | |
| # the segment shout. | |
| # Format seconds as a duration into $_dur_out. Sets a variable rather than | |
| # printing, because a command substitution forks and this runs on every render. | |
| _fmt_dur() { | |
| if [[ $1 -ge 3600 ]]; then printf -v _dur_out '%dh%02dm' $(( $1 / 3600 )) $(( ($1 % 3600) / 60 )) | |
| elif [[ $1 -ge 60 ]]; then printf -v _dur_out '%dm' $(( $1 / 60 )) | |
| else _dur_out='<1m' | |
| fi | |
| } | |
| _pct_color() { # $1 = raw percentage; prints an SGR colour | |
| local pct=${1%%.*} | |
| if [[ ! "$pct" =~ ^[0-9]+$ ]]; then printf '%s' $'\033[90m' | |
| elif [[ $pct -ge 76 ]]; then printf '%s' $'\033[31m' | |
| elif [[ $pct -ge 51 ]]; then printf '%s' $'\033[33m' | |
| else printf '%s' $'\033[32m' | |
| fi | |
| } | |
| # Context, measured against the budget that actually binds. | |
| # | |
| # The payload's used_percentage is a fraction of context_window_size, and on a | |
| # 1M-window model that number is decorative: nothing happens at 1M, because | |
| # auto-compaction fires long before it. Measured across every compact_boundary | |
| # record in ~/.claude/projects (17 auto-compactions, versions 2.1.220 through | |
| # 2.1.232), preTokens landed between 366,761 and 369,359. A 2,598-token spread, | |
| # under 0.7%, flat across five versions. Auto-compact fires at ~368K, which is | |
| # about 92% of a 400K effective budget, NOT a fraction of the model's window. | |
| # | |
| # So the honest reading of "how full am I" is total_input/368K. On the old | |
| # denominator this session would have shown a comfortable green 37% at the exact | |
| # moment it compacted, which is why compaction kept arriving unannounced. | |
| # | |
| # EVIDENCE BOUND: all 17 samples came from Opus 5 sessions. Whether a 200K model | |
| # carries the same budget is unknown, so this constant may read wrong there. | |
| # Override with CLAUDE_COMPACT_BUDGET_TOKENS if a different model proves to | |
| # differ; correcting it needs only one compact_boundary record from that model: | |
| # rg -N --no-filename compact_boundary -g '*.jsonl' ~/.claude/projects \ | |
| # | jq -r 'select(.compactMetadata.trigger=="auto") | .compactMetadata.preTokens' | |
| compact_budget="${CLAUDE_COMPACT_BUDGET_TOKENS:-368000}" | |
| token_info="" | |
| if [[ $total_input -gt 0 || $total_output -gt 0 ]]; then | |
| if [[ $total_input -gt 0 ]]; then | |
| compact_pct=$(( total_input * 100 / compact_budget )) | |
| # Just "Opus 5 43%". Both words that once qualified this number have now | |
| # been learned and dropped, in the same order they were added. | |
| # | |
| # "used" went first: it answered "43% consumed or 43% left?" for one | |
| # round, then became noise once the colour ramp was doing the work, since | |
| # green cannot mean nearly-exhausted. | |
| # | |
| # "-> compaction" went second, for the reason the line exists at all. It | |
| # named the destination of the percentage, which mattered while the | |
| # denominator was surprising (368K, not the 1M badge beside it). Once | |
| # that is known it is a constant string repeated on every render, and on | |
| # a split pane it is the widest constant on the line: nine columns spent | |
| # restating a fact the reader has already learned. Everything on this | |
| # line has to earn its width against the narrowest pane it appears in. | |
| # | |
| # What is deliberately NOT compensated for: the "(1M context)" badge can | |
| # now sit beside a percentage that is not a fraction of it. That badge is | |
| # model spec, this is consumption, and nothing labels the seam. The bet | |
| # is that a reader who knows what the number means also knows what it is | |
| # measured against, which is exactly the knowledge this edit assumes. If | |
| # a fresh reader misreads 43% as 43% of 1M, the label comes back. | |
| # | |
| # Dim the name, colour the number. The model is constant, the percentage | |
| # is the only part that carries information that changes. | |
| # No hint glyph beside this. One was tried at half the budget, meaning | |
| # "if you are switching tasks, clear now", and was rejected on sight: | |
| # the colour ramp already crosses to yellow at 51% and red at 76%, which | |
| # is the same signal arriving through a channel the reader is watching | |
| # anyway. A glyph that fires exactly when the number changes colour adds | |
| # a symbol to decode, not a fact to act on. | |
| token_info=$'\033[90m'"${model_name}"$'\033[0m'" $(_pct_color "$compact_pct")${compact_pct}%"$'\033[0m' | |
| else | |
| # No input count yet. Fall back to the payload's own figure against the | |
| # raw window, which is at least true even though it is not actionable. | |
| ctx_int=${ctx_pct%%.*} | |
| [[ "$ctx_int" =~ ^[0-9]+$ ]] \ | |
| && token_info="$(_pct_color "$ctx_int")${ctx_int}%"$'\033[0m'" of ${model_name}" \ | |
| || token_info="${model_name}" | |
| fi | |
| fi | |
| # Subscription rate limits. Absent for non-subscribers and until the first API | |
| # response of a session, and each window is documented as independently absent, | |
| # so every branch here treats missing as "render nothing" rather than as zero. | |
| # | |
| # This exists because the delegation policy says to route new work to another | |
| # backend as the current provider approaches a limit, and nothing on screen said | |
| # when that was. The failure mode it replaces is discovering the wall through a | |
| # worker that died on it. | |
| # | |
| # Both windows render, each independently. Showing only the higher one hid the | |
| # weekly number exactly when the 5h window was busy, which is when "am I about | |
| # to spend the week's budget" is the question actually worth asking. They cap | |
| # different things and either can be the binding one. | |
| # | |
| # Every present window renders, always, with both its percentage and the time | |
| # until it resets. An earlier cut hid a window below 50% and hid the reset time | |
| # below 75%, on the theory that a comfortable limit is noise. That was wrong for | |
| # this use: the number you want at a glance is not "am I in trouble" but "how | |
| # much of the window is left and when does it refill", and a gauge that appears | |
| # only during trouble cannot be read as a trend. | |
| # | |
| # One number per window, and deliberately a different KIND of number for each, | |
| # because the two windows are asked different questions. Inside a session the | |
| # 5h window is a wait: the useful fact is when it refills, not what fraction is | |
| # gone. Across a week the 7d window is a budget: the useful fact is how much is | |
| # spent, since its reset is days out and never a choice you make today. | |
| # | |
| # Both still colour by percentage, so the 5h countdown reddens as the window | |
| # fills even though the percentage itself is not printed. | |
| rate_info="" | |
| _r5="" | |
| if [[ "$rate_5h" =~ ^[0-9]+ ]]; then | |
| _r5="$(_pct_color "$rate_5h")${rate_5h%%.*}%"$'\033[0m'" of 5h window" | |
| # Countdown, not wall clock. This reverses an earlier decision recorded | |
| # against this file, on the grounds the decision got the need wrong: a fixed | |
| # point in the day answers "when", but the question being asked is "how much | |
| # longer", and only a shrinking number conveys that something is running. | |
| # | |
| # The risk the old decision guarded against is real and unfixed: "5h window" | |
| # and "2h26m" are both durations of the same form, which is what made the | |
| # original "⏳5h 44% ↻2h55m" unreadable. What makes it survive here is that | |
| # there is now exactly one duration, at the end, behind a clock emoji, in a | |
| # sentence rather than a run of figures. Adding a second time value back to | |
| # this segment would break it again. | |
| # | |
| # An alarm clock stands in for "resets in". It is an emoji, not a symbol | |
| # glyph: ↻ was tried and fell back to a substitute font, because the terminal | |
| # font ships colour emoji but not the arrows block. Anything added here must | |
| # come from the emoji range for that reason. | |
| # | |
| # Steady cyan, deliberately off the green/yellow/red scale. Time remaining is | |
| # not graded on the same axis as consumption: painting it green or yellow | |
| # would imply a severity it does not carry, and would contradict itself on | |
| # the days the two disagree (a red 93% beside a green countdown). Cyan says | |
| # "different kind of value" while still pulling the eye. | |
| # | |
| # Minutes are zero-padded, so it reads 2h06m rather than 2h6m and the field | |
| # keeps a constant width as it ticks down. The line redraws on session | |
| # events rather than on a clock tick, so this advances when you interact, | |
| # not second by second. | |
| _now=$(date +%s) | |
| if [[ "$rate_5h_reset" =~ ^[0-9]+$ ]] && [[ $rate_5h_reset -gt $_now ]]; then | |
| _rem=$(( rate_5h_reset - _now )) | |
| _fmt_dur "$_rem" | |
| _r5="${_r5} ⏰ "$'\033[36m'"${_dur_out}"$'\033[0m' | |
| # Pace verdict: at the current burn rate, does the quota outlast the | |
| # window or not. The window is five hours by definition, so elapsed time | |
| # is 18000 - remaining, the rate is spent/elapsed, and the quota runs out | |
| # in (100 - spent)/rate. Compare that against the time left. | |
| # | |
| # A SIGNED DURATION, read as a delta on the countdown beside it: "2h16m | |
| # -31m" means the quota runs dry 31 minutes before the window resets, | |
| # and "+40m" means it would have outlasted the window by 40. | |
| # | |
| # Two earlier forms were tried and both failed on the same point. A | |
| # verdict ("won't last") answered only yes or no. A projected total | |
| # ("-> 112%") made the reader subtract 100 and then translate a | |
| # percentage back into the thing they actually plan in, which is time. | |
| # The question being asked is "when do I stop", so answer it in minutes. | |
| # | |
| # This deliberately breaks the rule recorded against this file that the | |
| # segment carries exactly one duration. "5h window", "2h16m" and "-31m" | |
| # is three, which is the shape that made "⏳5h 44% ↻2h55m" unreadable. | |
| # Two things keep it readable. The sign marks it as an offset rather | |
| # than an independent quantity, and the parentheses bind it visually to | |
| # the countdown it modifies, so the eye takes "2h16m (-31m)" as one | |
| # figure with a correction rather than as two competing durations. | |
| # | |
| # The calculation: at the current rate the remaining quota lasts | |
| # (100 - spent) * elapsed / spent seconds. Subtract the time left in the | |
| # window and the sign falls out. | |
| # | |
| # The old guards suppressed the figure entirely below 1200s elapsed or | |
| # 3% spent, because a thin sample projects wildly. That was the wrong | |
| # trade for the thing this number is for. It is not a readout, it is an | |
| # input to a decision (drop to a cheaper model, hand the task to another | |
| # tool, stop for lunch), and a blank slot supplies nothing to decide | |
| # with. A noisy figure at least carries direction, which is what "am I | |
| # burning fast" needs. So the guards no longer gate the number, they | |
| # grade its confidence: a thin sample still renders, marked provisional. | |
| # | |
| # Tenths, not whole percent. ${rate_5h%%.*} truncated 3.9 to 3, and the | |
| # error lands where it hurts most: at the bottom of the range that is a | |
| # 23% error in the rate, and it biases one way, understating spend and | |
| # so overstating how long the quota lasts. Rounded to tenths from two | |
| # decimals, which cuts the worst case to a rounding error nothing on | |
| # this line can display anyway. 10# on the decimal digits because "08" | |
| # and "09" are not octal. | |
| _int=${rate_5h%%.*} | |
| _dec="" | |
| [[ "$rate_5h" == *.* ]] && _dec="${rate_5h#*.}" | |
| _dec="${_dec}00"; _dec="${_dec:0:2}" | |
| _p10=$(( _int * 10 + (10#$_dec + 5) / 10 )) | |
| _elapsed=$(( 18000 - _rem )) | |
| # Rate is undefined at zero elapsed and unstable just after it, so the | |
| # window opens on a floor rather than a division. One minute of | |
| # manufactured history is a smaller lie than a projection off a | |
| # denominator of three seconds. | |
| [[ $_elapsed -lt 60 ]] && _elapsed=60 | |
| # PACE FROM RECENT SPEND, NOT FROM THE WHOLE WINDOW. | |
| # | |
| # Averaging everything since the window opened makes this a function of | |
| # the clock instead of behaviour. Measured: 11% spent reads 33m at four | |
| # minutes in and 6h45m at fifty minutes in, with no work done between | |
| # them. Twelvefold movement while the user sits still. A figure that | |
| # swings that far is not describing a pace, and this one exists to be | |
| # acted on (drop to a cheaper model, stop for lunch), so it has to track | |
| # what is actually happening now. | |
| # | |
| # The burst case is the one that matters and it is the one the average | |
| # gets worst. Several agents on one account can spend 8% in two minutes; | |
| # from window start that reads as the new normal forever, when in fact | |
| # it ended when they did. | |
| # | |
| # So the slope comes from the oldest retained sample rather than from | |
| # zero. History is keyed by the reset stamp, so a new window starts | |
| # clean instead of inheriting the previous one's slope, and samples | |
| # older than 30 minutes fall out so the figure stays responsive. | |
| _hist="${XDG_STATE_HOME:-$HOME/.local/state}/claude-statusline-pace" | |
| _base_ts=""; _base_p10=""; _keep=""; _maxp10=0 | |
| if [[ -r "$_hist" ]]; then | |
| while read -r _h_reset _h_ts _h_p10; do | |
| [[ "$_h_reset" == "$rate_5h_reset" ]] || continue | |
| [[ "$_h_ts" =~ ^[0-9]+$ && "$_h_p10" =~ ^[0-9]+$ ]] || continue | |
| (( _now - _h_ts > 1800 )) && continue | |
| # BY MINIMUM TIMESTAMP, NOT BY POSITION. File order is not | |
| # chronological and cannot be made so: concurrent renders race | |
| # the read-copy-swap, and a writer holding an earlier clock can | |
| # win the mv later, appending its older sample after a newer | |
| # one. Sorting on write does not help, because the race is | |
| # between writers. | |
| # | |
| # Taking the first line looks safe while the seeded head is | |
| # alive, since copy-forward keeps that head genuinely oldest. | |
| # It breaks when the head ages past the prune and promotes an | |
| # unordered tail: same samples, no corruption, and the figure | |
| # reads a third low with nothing in the file to show for it. | |
| if [[ -z "$_base_ts" ]] || (( _h_ts < _base_ts )); then | |
| _base_ts="$_h_ts"; _base_p10="$_h_p10" | |
| fi | |
| (( _h_p10 > _maxp10 )) && _maxp10="$_h_p10" | |
| _keep+="${_h_reset} ${_h_ts} ${_h_p10}"$'\n' | |
| done < "$_hist" | |
| fi | |
| # SPEND IS NON-DECREASING INSIDE A WINDOW, THE READINGS ARE NOT. | |
| # | |
| # Every session gets its own snapshot of the same account-wide rate | |
| # limit, and they disagree: one file here held 420, 430, 420, 450, 420, | |
| # 450 within four minutes. Merging several sessions into one series and | |
| # subtracting endpoints therefore produced whatever the noise chose. A | |
| # high baseline against a low current reads as negative burn and clamps | |
| # to "outlasts the window"; the reverse reads as a burst. Observed live | |
| # as the figure flipping between 2h and 19m with nothing happening. | |
| # | |
| # A percentage that cannot fall is the fact that repairs it: carry the | |
| # running maximum forward, so a low reading is treated as a stale | |
| # snapshot rather than as spend being refunded. Burn can then never be | |
| # negative and the endpoints are comparable. | |
| (( _maxp10 > _p10 )) && _p10="$_maxp10" | |
| printf '%s%s %s %s\n' "$_keep" "$rate_5h_reset" "$_now" "$_p10" \ | |
| > "$_hist.$$" 2>/dev/null && mv -f "$_hist.$$" "$_hist" 2>/dev/null | |
| rm -f "$_hist.$$" 2>/dev/null | |
| _prov=0 | |
| _span=0; _burn=0 | |
| [[ -n "$_base_ts" ]] && { _span=$(( _now - _base_ts )); _burn=$(( _p10 - _base_p10 )); } | |
| if [[ $_p10 -le 0 ]]; then | |
| # Nothing spent at all: the projection is unbounded, which is the | |
| # clamped case by definition. Stated here rather than reached by | |
| # dividing by zero. | |
| _life=$(( _rem + 1 )) | |
| [[ $_elapsed -lt 1200 || $_p10 -lt 30 ]] && _prov=1 | |
| elif (( _span >= 600 )); then | |
| if (( _burn <= 0 )); then | |
| # Spend has not moved across a real interval. The honest reading | |
| # is that at THIS pace the quota outlasts the window, which the | |
| # clamp below renders as ">". Reporting the window-start average | |
| # here instead would show a shrinking number while nothing is | |
| # being consumed, which is the exact lie this block removes. | |
| _life=$(( _rem + 1 )) | |
| else | |
| _life=$(( (1000 - _p10) * _span / _burn )) | |
| (( _span < 300 )) && _prov=1 | |
| fi | |
| else | |
| # Not enough history yet, so fall back to the window-start average. | |
| # | |
| # TEN MINUTES, NOT TWO. The first cut used 120s on the grounds that | |
| # it was arithmetically valid, which it is and which is not the | |
| # bar. The reading is quantised to whole percent, so a two-minute | |
| # span sees either zero ticks or one, and a single tick is the | |
| # whole answer. Measured against the same window: a 129s span | |
| # projected 23m while the window average said 50m, and the account | |
| # figure agreed with 50m. Two minutes tracks the last burst; ten | |
| # tracks a pace, which is the thing being asked for. | |
| # | |
| # The fallback is not a degraded mode here. Once a window has been | |
| # running a while its average is steady and close to right, and the | |
| # short-span slope is the noisy one. The old model was only wrong | |
| # in the opening minutes, which is exactly where this now uses it | |
| # least. | |
| _life=$(( (1000 - _p10) * _elapsed / _p10 )) | |
| [[ $_elapsed -lt 1200 || $_p10 -lt 30 ]] && _prov=1 | |
| fi | |
| # TIME TO EMPTY, not the gap between empty and the reset. The signed | |
| # offset that shipped first was arithmetic the reader had to finish: | |
| # "-2h55m" beside "4h40m" means dry in 1h45m, and it was read as "dry in | |
| # 2h55m" twice in one morning, by the person who specified it. The | |
| # number exists to answer one question, how long can I keep working at | |
| # this pace, so it now states that directly and the subtraction happens | |
| # here instead of in the reader's head. | |
| # | |
| # The comparison the offset used to carry is not lost, it is implied: | |
| # a figure smaller than the countdown beside it means the quota goes | |
| # first. Colour makes it immediate without a second reading. | |
| # | |
| # Clamped at the countdown, with ">" for anything past it. Beyond that | |
| # bound the window resets and the quota refills, so a larger figure | |
| # would be describing a quota that no longer exists. It also puts a | |
| # ceiling on the low-spend explosion that once printed "+61h40m": the | |
| # projection carries elapsed/spent and grows without limit as spent | |
| # approaches zero. Nothing here may state a duration the window cannot | |
| # contain. | |
| # | |
| # No label on the figure. "dry" carried it for one round, the same | |
| # single round "used" and "-> compaction" each got before being dropped: | |
| # a word is worth its width until the meaning is learned, and colour | |
| # already says which way this one runs. Red is the quota going first, | |
| # green is the window going first, and the two are never ambiguous | |
| # because they are never both true. | |
| if [[ $_life -gt $_rem ]]; then | |
| _fmt_dur "$_rem"; _pre=">"; _col=$'\033[32m' | |
| else | |
| _fmt_dur "$_life"; _pre=""; _col=$'\033[31m' | |
| fi | |
| # Provisional: real arithmetic on a sample too thin to trust, so it | |
| # renders in the grey that already means "no confident reading" here | |
| # (_pct_color returns the same grey for a percentage it cannot parse) | |
| # behind a "~". Colour alone would carry it, but the tilde survives a | |
| # colour-blind glance and a light-theme pane, and this is the one state | |
| # where mistaking which reading you are looking at costs a decision. | |
| if [[ $_prov -eq 1 ]]; then | |
| _col=$'\033[90m'; _pre="~${_pre}" | |
| fi | |
| _r5="${_r5}${_col}(${_pre}${_dur_out})"$'\033[0m' | |
| unset _life _pre _col _prov _int _dec _p10 _hist _base_ts _base_p10 _keep _span _burn _h_reset _h_ts _h_p10 | |
| unset _rem _elapsed | |
| fi | |
| unset _now | |
| fi | |
| # Only the 5h window. The weekly figure was dropped for the reason it was never | |
| # actionable: by the time the week is genuinely at risk the 5h window has been | |
| # hitting its cap all day, so the weekly number restates something already | |
| # obvious rather than warning about it first. Two percentages side by side also | |
| # forced a comparison that had no answer, since neither predicts the other. | |
| # | |
| # "N% of X", the same shape as the context segment, so both figures read as one | |
| # pattern. Key-value shapes failed on parts of speech: in "ctx 29% 5h 45%" the | |
| # label "ctx" is a noun but "5h" is a quantity, so the eye saw four numbers | |
| # rather than two labelled ones. | |
| # | |
| # Consumption, not headroom, and no word says so. The colour ramp does: green | |
| # cannot mean "nearly exhausted", so a green number can only read as spent. | |
| [[ -n "$_r5" ]] && rate_info="${_r5}" | |
| unset _r5 | |
| worktree_info="" | |
| if [[ "$current_dir" == */.claude/worktrees/* ]]; then | |
| worktree_info="🌳" | |
| fi | |
| parts=() | |
| [[ -n "$worktree_info" ]] && parts+=("$worktree_info") | |
| parts+=("$display_dir") | |
| [[ -n "$git_info" ]] && parts+=("$git_info") | |
| [[ -n "$design_info" ]] && parts+=("$design_info") | |
| [[ -n "$aws_info" ]] && parts+=("$aws_info") | |
| [[ -n "$mount_info" ]] && parts+=("$mount_info") | |
| [[ -n "$kube_info" ]] && parts+=("$kube_info") | |
| [[ -n "$python_info" ]] && parts+=("$python_info") | |
| [[ -n "$node_info" ]] && parts+=("$node_info") | |
| [[ -n "$container_info" ]] && parts+=("$container_info") | |
| [[ -n "$style_info" ]] && parts+=("$style_info") | |
| [[ -n "$spawn_info" ]] && parts+=("$spawn_info") | |
| # One row, with a rule before the budget group. The line carries two unrelated | |
| # kinds of fact: where I am (account, directory, branch, cluster, model), which | |
| # barely changes, and what I am spending (context, quota windows, cost), which | |
| # changes every turn. Separated by spaces alone they ran together and read as a | |
| # string of numbers however the values were labelled. A single divider gives the | |
| # eye the boundary it was missing without spending a second row on it. | |
| # | |
| # ASCII pipe, not a box-drawing character: the earlier cycle arrow was a | |
| # non-emoji glyph and font-fell-back visibly, and a divider that renders wrong | |
| # is worse than no divider. | |
| # Three segments, divided by scope rather than by topic. | |
| # | |
| # <machine and tree> | <this session> | <this account> | |
| # | |
| # Left: account, directory, branch, cluster. Where the work is happening. | |
| # Middle: context, phrased as a fraction of the named model's window. Both | |
| # halves describe THIS conversation and change as it grows. | |
| # Right: the rate windows. These are account-wide, shared with every other | |
| # session on the machine, and outlive this conversation entirely. That makes | |
| # them a different kind of fact from everything to their left, which is why | |
| # they read as an interruption anywhere but the end. | |
| # | |
| # The middle segment SPEAKS the model name, so printing the badge again beside | |
| # it says "Opus 5 (1M context)" twice on one line. The badge stands alone only | |
| # when there is no percentage to attach it to: a fresh session, or a payload | |
| # with no context figures. | |
| # | |
| # Each rule appears only when its segment does, so a fresh session with no API | |
| # response yet is still just the environment and the model. | |
| _rule=$'\033[90m|\033[0m' | |
| if [[ -n "$token_info" ]]; then | |
| parts+=("$_rule") | |
| parts+=("$token_info") | |
| else | |
| parts+=("$model_name") | |
| fi | |
| if [[ -n "$rate_info" ]]; then | |
| parts+=("$_rule") | |
| parts+=("$rate_info") | |
| fi | |
| unset _rule | |
| IFS=' ' | |
| echo "${parts[*]}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment