@andrewkent/claude-statusline 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ # Claude Code Status Line Configuration
2
+ # Copy to ~/.claude/statusline.conf
3
+
4
+ # Billing rate in $/hour (enables billable amount display)
5
+ # HOURLY_RATE=150
6
+
7
+ # Daily cost ceiling in $ (enables budget progress bar)
8
+ # DAILY_BUDGET=20
9
+
10
+ # Render format: default | sigil | sparkline | iterm2 | rprompt
11
+ # FORMAT=default
12
+
13
+ # Account labels: map email patterns to short names
14
+ # ACCOUNT_LABELS="work:*@yourcompany.com personal:you@gmail.com"
@@ -0,0 +1,1064 @@
1
+ #!/bin/bash
2
+ # shellcheck disable=SC2059,SC2034,SC2154,SC2153,SC1090,SC2329,SC2016
3
+ set -f
4
+
5
+ input=$(cat)
6
+
7
+ # Config: ~/.claude/statusline.conf (sourced as bash)
8
+ # HOURLY_RATE=150 # Billing rate in $/hour (enables cost tracking)
9
+ # DAILY_BUDGET=20 # Daily cost ceiling in $ (enables budget bar)
10
+
11
+ if [ -z "$input" ]; then
12
+ printf "Claude"
13
+ exit 0
14
+ fi
15
+
16
+ # ── Config ──────────────────────────────────────────────
17
+ CONFIG_FILE="$HOME/.claude/statusline.conf"
18
+ HOURLY_RATE=0
19
+ DAILY_BUDGET=0
20
+ [ -f "$CONFIG_FILE" ] && source "$CONFIG_FILE"
21
+
22
+ FOCUS_FILE="$HOME/.claude/focus"
23
+
24
+ # ── True Colors (24-bit RGB) ───────────────────────────
25
+ blue='\033[38;2;0;153;255m'
26
+ orange='\033[38;2;255;176;85m'
27
+ green='\033[38;2;0;175;80m'
28
+ cyan='\033[38;2;86;182;194m'
29
+ red='\033[38;2;255;85;85m'
30
+ yellow='\033[38;2;230;200;0m'
31
+ white='\033[38;2;220;220;220m'
32
+ magenta='\033[38;2;180;140;255m'
33
+ dim='\033[2m'
34
+ reset='\033[0m'
35
+
36
+ sep=" ${dim}│${reset} "
37
+
38
+ # ── Helpers ─────────────────────────────────────────────
39
+ format_tokens() {
40
+ local num=$1
41
+ if [ "$num" -ge 1000000 ] 2>/dev/null; then
42
+ awk "BEGIN {printf \"%.1fm\", $num / 1000000}"
43
+ elif [ "$num" -ge 1000 ] 2>/dev/null; then
44
+ awk "BEGIN {printf \"%.0fk\", $num / 1000}"
45
+ else
46
+ printf "%d" "$num"
47
+ fi
48
+ }
49
+
50
+ color_for_pct() {
51
+ local pct=$1
52
+ if [ "$pct" -ge 90 ] 2>/dev/null; then printf "$red"
53
+ elif [ "$pct" -ge 70 ] 2>/dev/null; then printf "$yellow"
54
+ elif [ "$pct" -ge 50 ] 2>/dev/null; then printf "$orange"
55
+ else printf "$green"
56
+ fi
57
+ }
58
+
59
+ build_bar() {
60
+ local pct=$1
61
+ local width=$2
62
+ [ "$pct" -lt 0 ] 2>/dev/null && pct=0
63
+ [ "$pct" -gt 100 ] 2>/dev/null && pct=100
64
+
65
+ local filled=$(( pct * width / 100 ))
66
+ local empty=$(( width - filled ))
67
+ local bar_color
68
+ bar_color=$(color_for_pct "$pct")
69
+
70
+ local filled_str="" empty_str=""
71
+ for ((i=0; i<filled; i++)); do filled_str+="●"; done
72
+ for ((i=0; i<empty; i++)); do empty_str+="○"; done
73
+
74
+ printf "${bar_color}${filled_str}${dim}${empty_str}${reset}"
75
+ }
76
+
77
+ # Atomic jq update: reads file, applies jq filter, writes back atomically.
78
+ jq_update() {
79
+ local file="$1"; shift
80
+ local tmpfile
81
+ tmpfile=$(mktemp "${file}.XXXXXX") || return 1
82
+ if jq "$@" "$file" > "$tmpfile" 2>/dev/null; then
83
+ mv "$tmpfile" "$file"
84
+ else
85
+ rm -f "$tmpfile"
86
+ return 1
87
+ fi
88
+ }
89
+
90
+ # ── Reusable ledger function ─────────────────────────────
91
+ # Usage: update_ledger <file> <session_id> <value> <today>
92
+ # Returns the daily delta (sum of all session deltas) via LEDGER_RESULT
93
+ LEDGER_RESULT=0
94
+ update_ledger() {
95
+ local file="$1" sid="$2" value="$3" today="$4"
96
+
97
+ if [ -f "$file" ]; then
98
+ # Single jq call to get date + baseline existence
99
+ local info
100
+ info=$(jq -r --arg sid "$sid" '[.date // "", (.sessions[$sid].baseline // empty | tostring)] | join("|")' "$file" 2>/dev/null)
101
+ local ledger_date="${info%%|*}"
102
+ local has_baseline="${info#*|}"
103
+
104
+ if [ "$ledger_date" = "$today" ]; then
105
+ if [ -z "$has_baseline" ]; then
106
+ jq_update "$file" --arg sid "$sid" --argjson val "$value" \
107
+ '.sessions[$sid] = {"baseline": $val, "current": $val}'
108
+ else
109
+ jq_update "$file" --arg sid "$sid" --argjson val "$value" \
110
+ '.sessions[$sid].current = $val'
111
+ fi
112
+ LEDGER_RESULT=$(jq '[.sessions[] | .current - .baseline] | add // 0' "$file" 2>/dev/null)
113
+ [ -z "$LEDGER_RESULT" ] && LEDGER_RESULT=0
114
+ else
115
+ printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s}}}' \
116
+ "$today" "$sid" "$value" "$value" > "$file"
117
+ LEDGER_RESULT=0
118
+ fi
119
+ else
120
+ printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s}}}' \
121
+ "$today" "$sid" "$value" "$value" > "$file"
122
+ LEDGER_RESULT=0
123
+ fi
124
+ }
125
+
126
+ iso_to_epoch() {
127
+ local iso_str="$1"
128
+
129
+ # GNU date
130
+ local epoch
131
+ epoch=$(date -d "${iso_str}" +%s 2>/dev/null)
132
+ if [ -n "$epoch" ]; then
133
+ echo "$epoch"
134
+ return 0
135
+ fi
136
+
137
+ # macOS date
138
+ local stripped="${iso_str%%.*}"
139
+ stripped="${stripped%%Z}"
140
+ stripped="${stripped%%+*}"
141
+ stripped="${stripped%%-[0-9][0-9]:[0-9][0-9]}"
142
+
143
+ if [[ "$iso_str" == *"Z"* ]] || [[ "$iso_str" == *"+00:00"* ]] || [[ "$iso_str" == *"-00:00"* ]]; then
144
+ epoch=$(env TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%S" "$stripped" +%s 2>/dev/null)
145
+ else
146
+ epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$stripped" +%s 2>/dev/null)
147
+ fi
148
+
149
+ if [ -n "$epoch" ]; then
150
+ echo "$epoch"
151
+ return 0
152
+ fi
153
+
154
+ return 1
155
+ }
156
+
157
+ format_reset_time() {
158
+ local iso_str="$1"
159
+ local style="$2"
160
+ [ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return
161
+
162
+ local epoch
163
+ epoch=$(iso_to_epoch "$iso_str")
164
+ [ -z "$epoch" ] && return
165
+
166
+ # If the reset time is in the past, project forward in 5-hour increments
167
+ local now
168
+ now=$(date +%s)
169
+ while [ "$epoch" -le "$now" ]; do
170
+ epoch=$((epoch + 18000))
171
+ done
172
+
173
+ case "$style" in
174
+ time)
175
+ date -j -r "$epoch" +"%l:%M%p" 2>/dev/null | sed 's/^ //; s/\.//g' | tr '[:upper:]' '[:lower:]' || \
176
+ date -d "@$epoch" +"%l:%M%P" 2>/dev/null | sed 's/^ //; s/\.//g'
177
+ ;;
178
+ datetime)
179
+ date -j -r "$epoch" +"%b %-d, %l:%M%p" 2>/dev/null | sed 's/ / /g; s/^ //; s/\.//g' | tr '[:upper:]' '[:lower:]' || \
180
+ date -d "@$epoch" +"%b %-d, %l:%M%P" 2>/dev/null | sed 's/ / /g; s/^ //; s/\.//g'
181
+ ;;
182
+ esac
183
+ }
184
+
185
+ # ── Parse JSON (single jq call for performance) ────────
186
+ eval "$(echo "$input" | jq -r '
187
+ "MODEL=" + (.model.display_name // "unknown" | @sh),
188
+ "COST=" + (.cost.total_cost_usd // 0 | tostring | @sh),
189
+ "DURATION_MS=" + (.cost.total_duration_ms // 0 | tostring | @sh),
190
+ "CONTEXT_PCT=" + (.context_window.used_percentage // 0 | tostring | @sh),
191
+ "LINES_ADDED=" + (.cost.total_lines_added // 0 | tostring | @sh),
192
+ "LINES_REMOVED=" + (.cost.total_lines_removed // 0 | tostring | @sh),
193
+ "CWD=" + (.workspace.current_dir // "" | @sh),
194
+ "INPUT_TOKENS=" + (.context_window.total_input_tokens // 0 | tostring | @sh),
195
+ "OUTPUT_TOKENS=" + (.context_window.total_output_tokens // 0 | tostring | @sh),
196
+ "CACHE_READ=" + (.context_window.current_usage.cache_read_input_tokens // 0 | tostring | @sh),
197
+ "CACHE_CREATE=" + (.context_window.current_usage.cache_creation_input_tokens // 0 | tostring | @sh),
198
+ "SESSION_ID=" + (.session_id // "" | @sh),
199
+ "CTX_SIZE=" + (.context_window.context_window_size // 200000 | tostring | @sh)
200
+ ' 2>/dev/null)"
201
+
202
+ # ── Daily cost ledger ──────────────────────────────────
203
+ DAILY_LEDGER="$HOME/.claude/daily-cost.json"
204
+ TODAY=$(date +%Y-%m-%d)
205
+ DAILY_COST="$COST"
206
+ if [ -n "$SESSION_ID" ] && [ "$(awk "BEGIN {print ($COST > 0)}")" = "1" ]; then
207
+ update_ledger "$DAILY_LEDGER" "$SESSION_ID" "$COST" "$TODAY"
208
+ DAILY_COST="${LEDGER_RESULT:-0}"
209
+ fi
210
+ DAILY_FMT=$(printf "%.2f" "$DAILY_COST")
211
+
212
+ # ── Token challenge tracker ───────────────────────────────
213
+ TOKEN_LEDGER="$HOME/.claude/token-challenge.json"
214
+ TOKEN_DISPLAY=""
215
+
216
+ if [ -n "$SESSION_ID" ]; then
217
+ SESSION_TOKENS=$((INPUT_TOKENS + OUTPUT_TOKENS))
218
+
219
+ if [ -f "$TOKEN_LEDGER" ]; then
220
+ TL_HAS_BASE=$(jq --arg sid "$SESSION_ID" '.sessions[$sid].baseline // empty' "$TOKEN_LEDGER" 2>/dev/null)
221
+ if [ -z "$TL_HAS_BASE" ]; then
222
+ jq_update "$TOKEN_LEDGER" --arg sid "$SESSION_ID" --argjson tok "$SESSION_TOKENS" \
223
+ '.sessions[$sid] = {"baseline": $tok, "current": $tok}'
224
+ else
225
+ jq_update "$TOKEN_LEDGER" --arg sid "$SESSION_ID" --argjson tok "$SESSION_TOKENS" \
226
+ '.sessions[$sid].current = $tok'
227
+ fi
228
+ CHALLENGE_TOKENS=$(jq '.starting_total + ([.sessions[] | .current - .baseline] | add // 0)' "$TOKEN_LEDGER" 2>/dev/null)
229
+ else
230
+ STARTING=0
231
+ if [ -f "$HOME/.claude/stats-cache.json" ]; then
232
+ STARTING=$(jq '[.modelUsage[].inputTokens, .modelUsage[].outputTokens] | add // 0' "$HOME/.claude/stats-cache.json" 2>/dev/null)
233
+ [ -z "$STARTING" ] && STARTING=0
234
+ fi
235
+ printf '{"starting_total":%s,"sessions":{"%s":{"baseline":%s,"current":%s}}}' \
236
+ "$STARTING" "$SESSION_ID" "$SESSION_TOKENS" "$SESSION_TOKENS" > "$TOKEN_LEDGER"
237
+ CHALLENGE_TOKENS="$STARTING"
238
+ fi
239
+
240
+ if [ -n "$CHALLENGE_TOKENS" ] && [ "$CHALLENGE_TOKENS" != "null" ]; then
241
+ TOKEN_M=$(awk "BEGIN {printf \"%.1f\", $CHALLENGE_TOKENS / 1000000}")
242
+ GOAL_M="100"
243
+ TOKEN_PCT=$(awk "BEGIN {printf \"%.0f\", $CHALLENGE_TOKENS / (${GOAL_M} * 10000)}")
244
+ [ "$TOKEN_PCT" -gt 100 ] 2>/dev/null && TOKEN_PCT=100
245
+ TOKEN_BAR=$(build_bar "$TOKEN_PCT" 10)
246
+
247
+ # Daily token tracking
248
+ DAILY_TOKEN_LEDGER="$HOME/.claude/daily-tokens.json"
249
+ update_ledger "$DAILY_TOKEN_LEDGER" "$SESSION_ID" "$SESSION_TOKENS" "$TODAY"
250
+ DAILY_TOKENS="${LEDGER_RESULT:-0}"
251
+
252
+ # Session token delta
253
+ SESSION_DELTA=0
254
+ if [ -f "$DAILY_TOKEN_LEDGER" ]; then
255
+ SESSION_DELTA=$(jq --arg sid "$SESSION_ID" '(.sessions[$sid].current // 0) - (.sessions[$sid].baseline // 0)' "$DAILY_TOKEN_LEDGER" 2>/dev/null)
256
+ [ -z "$SESSION_DELTA" ] && SESSION_DELTA=0
257
+ fi
258
+
259
+ SESSION_TOKEN_FMT=$(awk "BEGIN {
260
+ t=$SESSION_DELTA;
261
+ if (t >= 1000000) printf \"%.1fM\", t/1000000;
262
+ else if (t >= 1000) printf \"%.0fk\", t/1000;
263
+ else printf \"%d\", t
264
+ }")
265
+
266
+ DAILY_TOKEN_FMT=$(awk "BEGIN {
267
+ t=$DAILY_TOKENS;
268
+ if (t >= 1000000) printf \"%.1fM\", t/1000000;
269
+ else if (t >= 1000) printf \"%.0fk\", t/1000;
270
+ else printf \"%d\", t
271
+ }")
272
+
273
+ TOKEN_SUFFIX=" ${magenta}+${SESSION_TOKEN_FMT}${reset}"
274
+ if [ "$DAILY_TOKENS" -gt "$SESSION_DELTA" ] 2>/dev/null; then
275
+ TOKEN_SUFFIX+=" ${dim}(+${DAILY_TOKEN_FMT}/d)${reset}"
276
+ fi
277
+
278
+ TOKEN_PCT_COLOR=$(color_for_pct "$TOKEN_PCT")
279
+ TOKEN_DISPLAY="${TOKEN_BAR} ${TOKEN_PCT_COLOR}$(printf "%3d" "$TOKEN_PCT")%${reset} ${magenta}${TOKEN_M}M${reset}${dim}/${GOAL_M}M${reset}${TOKEN_SUFFIX}"
280
+ fi
281
+ fi
282
+
283
+ # ── Cost & burn rate ────────────────────────────────────
284
+ COST_FMT=$(printf "%.2f" "$COST")
285
+
286
+ BURN_RATE=""
287
+ if [ "$DURATION_MS" -gt 0 ] 2>/dev/null; then
288
+ RATE=$(awk "BEGIN {h=$DURATION_MS/3600000; if(h>0.001) printf \"%.2f\", $COST/h}")
289
+ [ -n "$RATE" ] && BURN_RATE=" ${magenta}@\$${RATE}/h${reset}"
290
+ fi
291
+
292
+ # ── Session timer ───────────────────────────────────────
293
+ SESSION_TIME=""
294
+ if [ "$DURATION_MS" -gt 0 ] 2>/dev/null; then
295
+ TOTAL_SECS=$((DURATION_MS / 1000))
296
+ H=$((TOTAL_SECS / 3600))
297
+ M=$(((TOTAL_SECS % 3600) / 60))
298
+ S=$((TOTAL_SECS % 60))
299
+ if [ "$H" -gt 0 ]; then
300
+ SESSION_TIME=$(printf "%d:%02d:%02d" $H $M $S)
301
+ else
302
+ SESSION_TIME=$(printf "%d:%02d" $M $S)
303
+ fi
304
+ fi
305
+
306
+ # ── Billable amount ────────────────────────────────────
307
+ BILLABLE=""
308
+ if [ "$HOURLY_RATE" -gt 0 ] 2>/dev/null && [ "$DURATION_MS" -gt 0 ] 2>/dev/null; then
309
+ BILL=$(awk "BEGIN {printf \"%.2f\", ($DURATION_MS/3600000) * $HOURLY_RATE}")
310
+ BILLABLE=" ${orange}\$${BILL}b${reset}"
311
+ fi
312
+
313
+ # ── Context % with visual bar ──────────────────────────
314
+ CONTEXT_INT=$(printf "%.0f" "$CONTEXT_PCT")
315
+ CTX_BAR=$(build_bar "$CONTEXT_INT" 10)
316
+ CTX_COLOR=$(color_for_pct "$CONTEXT_INT")
317
+
318
+ # ── Context badge for line 1 (surfaces at 70%+) ───────
319
+ CTX_BADGE=""
320
+ if [ "$CONTEXT_INT" -ge 70 ] 2>/dev/null; then
321
+ CTX_BADGE=" ${CTX_COLOR}ctx:${CONTEXT_INT}%${reset}"
322
+ fi
323
+
324
+ # ── Effort level ───────────────────────────────────────
325
+ EFFORT=""
326
+ effort_val=$(echo "$input" | jq -r '.effort_level // empty' 2>/dev/null)
327
+ if [ -z "$effort_val" ]; then
328
+ settings_path="$HOME/.claude/settings.json"
329
+ [ -f "$settings_path" ] && effort_val=$(jq -r '.effortLevel // empty' "$settings_path" 2>/dev/null)
330
+ fi
331
+ case "$effort_val" in
332
+ low) EFFORT="${dim}.low${reset}" ;;
333
+ medium) EFFORT="${orange}.medium${reset}" ;;
334
+ high) EFFORT="${red}.high${reset}" ;;
335
+ max) EFFORT="${red}.max${reset}" ;;
336
+ esac
337
+
338
+ # ── Focus mode ──────────────────────────────────────────
339
+ FOCUS=""
340
+ [ -f "$FOCUS_FILE" ] && FOCUS=" ${red}[FOCUS]${reset}"
341
+
342
+ # ── Cache hit ratio ─────────────────────────────────────
343
+ CACHE_TOTAL=$((CACHE_READ + CACHE_CREATE))
344
+ if [ "$CACHE_TOTAL" -gt 0 ] 2>/dev/null; then
345
+ CACHE_PCT=$((CACHE_READ * 100 / CACHE_TOTAL))
346
+ CACHE_COLOR=$(color_for_pct $((100 - CACHE_PCT))) # invert: high cache = good
347
+ CACHE_STR="${CACHE_COLOR}cache:${CACHE_PCT}%${reset}"
348
+ else
349
+ CACHE_STR="${dim}cache:--${reset}"
350
+ fi
351
+
352
+ # ── Git info ────────────────────────────────────────────
353
+ GIT_INFO=""
354
+ IS_DIRTY=false
355
+ BRANCH=""
356
+ if [ -d "$CWD" ] && git -C "$CWD" rev-parse --git-dir > /dev/null 2>&1; then
357
+ BRANCH=$(git -C "$CWD" branch --show-current 2>/dev/null)
358
+ if [ -n "$BRANCH" ]; then
359
+ # Use git diff-index for fast dirty check (single call, no untracked scan)
360
+ if ! git -C "$CWD" diff-index --quiet HEAD -- 2>/dev/null; then
361
+ IS_DIRTY=true
362
+ fi
363
+
364
+ if $IS_DIRTY; then
365
+ GIT_INFO=" ${orange}(${BRANCH}${red}*${orange})${reset}"
366
+ else
367
+ GIT_INFO=" ${green}(${BRANCH})${reset}"
368
+ fi
369
+ # Ahead/behind
370
+ UPSTREAM=$(git -C "$CWD" rev-parse --abbrev-ref "${BRANCH}@{upstream}" 2>/dev/null)
371
+ if [ -n "$UPSTREAM" ]; then
372
+ COUNTS=$(git -C "$CWD" rev-list --left-right --count HEAD..."${UPSTREAM}" 2>/dev/null)
373
+ AHEAD=$(echo "$COUNTS" | cut -f1)
374
+ BEHIND=$(echo "$COUNTS" | cut -f2)
375
+ AB=""
376
+ [ "$AHEAD" -gt 0 ] 2>/dev/null && AB="↑${AHEAD}"
377
+ [ "$BEHIND" -gt 0 ] 2>/dev/null && AB="${AB}↓${BEHIND}"
378
+ [ -n "$AB" ] && GIT_INFO="${GIT_INFO}${cyan}${AB}${reset}"
379
+ fi
380
+ fi
381
+ fi
382
+
383
+ # ── PR state indicator (cached 90s) ────────────────────
384
+ PR_BADGE=""
385
+ if [ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && command -v gh >/dev/null 2>&1; then
386
+ pr_cache_file="/tmp/claude/statusline-pr-${BRANCH//\//_}.json"
387
+ pr_cache_max_age=90
388
+ pr_needs_refresh=true
389
+
390
+ if [ -f "$pr_cache_file" ]; then
391
+ pr_mtime=$(stat -f %m "$pr_cache_file" 2>/dev/null || stat -c %Y "$pr_cache_file" 2>/dev/null)
392
+ pr_now=$(date +%s)
393
+ pr_age=$(( pr_now - pr_mtime ))
394
+ [ "$pr_age" -lt "$pr_cache_max_age" ] && pr_needs_refresh=false
395
+ fi
396
+
397
+ if $pr_needs_refresh; then
398
+ # Fire-and-forget background refresh
399
+ (
400
+ pr_data=$(gh pr view "$BRANCH" --json state,isDraft,reviewDecision,statusCheckRollup 2>/dev/null)
401
+ if [ -n "$pr_data" ] && echo "$pr_data" | jq -e '.state' >/dev/null 2>&1; then
402
+ echo "$pr_data" > "$pr_cache_file"
403
+ else
404
+ echo '{"state":"NONE"}' > "$pr_cache_file"
405
+ fi
406
+ ) &
407
+ fi
408
+
409
+ # Always read from cache
410
+ if [ -f "$pr_cache_file" ]; then
411
+ pr_info=$(jq -r '[.state // "NONE", .isDraft // false | tostring, .reviewDecision // "NONE", ([.statusCheckRollup[]? | .status] | if any(. == "FAILURE") then "FAIL" elif any(. == "PENDING") then "PENDING" else "PASS" end)] | join("|")' "$pr_cache_file" 2>/dev/null)
412
+ pr_state="${pr_info%%|*}"
413
+ pr_rest="${pr_info#*|}"
414
+ pr_draft="${pr_rest%%|*}"
415
+ pr_rest2="${pr_rest#*|}"
416
+ pr_review="${pr_rest2%%|*}"
417
+ pr_checks="${pr_rest2#*|}"
418
+
419
+ if [ "$pr_state" = "OPEN" ]; then
420
+ if [ "$pr_draft" = "true" ]; then
421
+ PR_BADGE="${dim}[draft]${reset}"
422
+ elif [ "$pr_checks" = "FAIL" ]; then
423
+ PR_BADGE="${red}[PR✗]${reset}"
424
+ elif [ "$pr_review" = "CHANGES_REQUESTED" ]; then
425
+ PR_BADGE="${orange}[PR△]${reset}"
426
+ elif [ "$pr_review" = "APPROVED" ] && [ "$pr_checks" != "FAIL" ]; then
427
+ PR_BADGE="${green}[PR✓]${reset}"
428
+ elif [ "$pr_checks" = "PENDING" ]; then
429
+ PR_BADGE="${yellow}[PR⋯]${reset}"
430
+ else
431
+ PR_BADGE="${cyan}[PR]${reset}"
432
+ fi
433
+ fi
434
+ fi
435
+ fi
436
+
437
+ # ── Session ID ──────────────────────────────────────────
438
+ SID=""
439
+ [ -n "$SESSION_ID" ] && SID=" ${dim}${SESSION_ID:0:8}${reset}"
440
+
441
+ # ── Directory name ──────────────────────────────────────
442
+ DIR_NAME="${CWD##*/}"
443
+
444
+ # ── OAuth token resolution ──────────────────────────────
445
+ get_oauth_token() {
446
+ if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
447
+ echo "$CLAUDE_CODE_OAUTH_TOKEN"
448
+ return 0
449
+ fi
450
+
451
+ if command -v security >/dev/null 2>&1; then
452
+ local blob
453
+ # Timeout guard: cap keychain call at 2 seconds
454
+ blob=$(timeout 2 security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null || \
455
+ security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)
456
+ if [ -n "$blob" ]; then
457
+ local token
458
+ token=$(echo "$blob" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
459
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
460
+ echo "$token"
461
+ return 0
462
+ fi
463
+ fi
464
+ fi
465
+
466
+ local creds_file="${HOME}/.claude/.credentials.json"
467
+ if [ -f "$creds_file" ]; then
468
+ local token
469
+ token=$(jq -r '.claudeAiOauth.accessToken // empty' "$creds_file" 2>/dev/null)
470
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
471
+ echo "$token"
472
+ return 0
473
+ fi
474
+ fi
475
+
476
+ echo ""
477
+ }
478
+
479
+ # ── Fetch rate limits + profile (background, never blocking) ──
480
+ cache_file="/tmp/claude/statusline-usage-cache.json"
481
+ profile_cache_file="/tmp/claude/statusline-profile-cache.json"
482
+ cache_max_age=60
483
+ profile_cache_max_age=300
484
+ lock_file="/tmp/claude/statusline-refresh.lock"
485
+ mkdir -p /tmp/claude
486
+
487
+ # Check if caches need refresh
488
+ needs_refresh=false
489
+ needs_profile_refresh=false
490
+ now=$(date +%s)
491
+
492
+ if [ -f "$cache_file" ]; then
493
+ cache_mtime=$(stat -f %m "$cache_file" 2>/dev/null || stat -c %Y "$cache_file" 2>/dev/null)
494
+ cache_age=$(( now - cache_mtime ))
495
+ [ "$cache_age" -ge "$cache_max_age" ] && needs_refresh=true
496
+ else
497
+ needs_refresh=true
498
+ fi
499
+
500
+ if [ -f "$profile_cache_file" ]; then
501
+ p_mtime=$(stat -f %m "$profile_cache_file" 2>/dev/null || stat -c %Y "$profile_cache_file" 2>/dev/null)
502
+ p_age=$(( now - p_mtime ))
503
+ [ "$p_age" -ge "$profile_cache_max_age" ] && needs_profile_refresh=true
504
+ else
505
+ needs_profile_refresh=true
506
+ fi
507
+
508
+ # Fire-and-forget background refresh (never blocks the status line)
509
+ if $needs_refresh || $needs_profile_refresh; then
510
+ # Use a lock file to prevent concurrent refreshes from racing
511
+ if (set -o noclobber; echo $$ > "$lock_file") 2>/dev/null; then
512
+ (
513
+ trap 'rm -f "$lock_file"' EXIT
514
+ token=$(get_oauth_token)
515
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
516
+ if $needs_refresh; then
517
+ response=$(curl -s --max-time 5 \
518
+ -H "Accept: application/json" \
519
+ -H "Content-Type: application/json" \
520
+ -H "Authorization: Bearer $token" \
521
+ -H "anthropic-beta: oauth-2025-04-20" \
522
+ -H "User-Agent: claude-code/2.1.34" \
523
+ "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
524
+ if [ -n "$response" ] && echo "$response" | jq -e '.five_hour' >/dev/null 2>&1; then
525
+ echo "$response" > "$cache_file"
526
+ fi
527
+ fi
528
+ if $needs_profile_refresh; then
529
+ p_response=$(curl -s --max-time 5 \
530
+ -H "Accept: application/json" \
531
+ -H "Content-Type: application/json" \
532
+ -H "Authorization: Bearer $token" \
533
+ -H "anthropic-beta: oauth-2025-04-20" \
534
+ -H "User-Agent: claude-code/2.1.34" \
535
+ "https://api.anthropic.com/api/oauth/profile" 2>/dev/null)
536
+ if [ -n "$p_response" ] && echo "$p_response" | jq -e '.account' >/dev/null 2>&1; then
537
+ echo "$p_response" > "$profile_cache_file"
538
+ fi
539
+ fi
540
+ fi
541
+ ) &
542
+ fi
543
+ fi
544
+
545
+ # Always read from cache (may be stale by one cycle — imperceptible)
546
+ usage_data=""
547
+ [ -f "$cache_file" ] && usage_data=$(cat "$cache_file" 2>/dev/null)
548
+ profile_data=""
549
+ [ -f "$profile_cache_file" ] && profile_data=$(cat "$profile_cache_file" 2>/dev/null)
550
+
551
+ # ── Account label ──────────────────────────────────────
552
+ ACCOUNT_LABEL=""
553
+ if [ -n "$profile_data" ]; then
554
+ acct_email=$(echo "$profile_data" | jq -r '.account.email // empty' 2>/dev/null)
555
+ case "$acct_email" in
556
+ *@coram.ai) ACCOUNT_LABEL="${cyan}work${reset}" ;;
557
+ andrewkent10@gmail.com) ACCOUNT_LABEL="${magenta}personal${reset}" ;;
558
+ ?*) ACCOUNT_LABEL="${dim}${acct_email}${reset}" ;;
559
+ esac
560
+ fi
561
+
562
+ # ── Build rate limit lines ─────────────────────────────
563
+ rate_lines=""
564
+
565
+ if [ -n "$usage_data" ] && echo "$usage_data" | jq -e . >/dev/null 2>&1; then
566
+ bar_width=10
567
+
568
+ # Parse all usage data in a single jq call
569
+ eval "$(echo "$usage_data" | jq -r '
570
+ "five_hour_pct=" + (.five_hour.utilization // 0 | tostring | @sh),
571
+ "five_hour_reset_iso=" + (.five_hour.resets_at // "" | @sh),
572
+ "five_hour_prev_pct=" + (.five_hour.previous_utilization // 0 | tostring | @sh),
573
+ "seven_day_pct_raw=" + (.seven_day.utilization // 0 | tostring | @sh),
574
+ "seven_day_reset_iso=" + (.seven_day.resets_at // "" | @sh),
575
+ "extra_enabled=" + (.extra_usage.is_enabled // false | tostring | @sh),
576
+ "extra_pct_raw=" + (.extra_usage.utilization // 0 | tostring | @sh),
577
+ "extra_used_raw=" + (.extra_usage.used_credits // 0 | tostring | @sh),
578
+ "extra_limit_raw=" + (.extra_usage.monthly_limit // 0 | tostring | @sh)
579
+ ' 2>/dev/null)"
580
+
581
+ five_hour_pct=$(printf "%.0f" "$five_hour_pct" 2>/dev/null || echo 0)
582
+ five_hour_reset=$(format_reset_time "$five_hour_reset_iso" "time")
583
+ five_hour_bar=$(build_bar "$five_hour_pct" "$bar_width")
584
+ five_hour_pct_color=$(color_for_pct "$five_hour_pct")
585
+
586
+ rate_lines+="${white}$(printf "%-7s" "current")${reset} ${five_hour_bar} ${five_hour_pct_color}$(printf "%3d" "$five_hour_pct")%${reset}"
587
+ [ -n "$five_hour_reset" ] && rate_lines+=" ${white}${five_hour_reset}${reset}"
588
+
589
+ # ── Burn-down projection ──────────────────────────
590
+ # Estimate minutes until 100% based on utilization velocity
591
+ if [ "$five_hour_pct" -gt 5 ] 2>/dev/null && [ -n "$five_hour_reset_iso" ] && [ "$five_hour_reset_iso" != "" ]; then
592
+ reset_epoch=$(iso_to_epoch "$five_hour_reset_iso")
593
+ if [ -n "$reset_epoch" ]; then
594
+ now_bd=$(date +%s)
595
+ # The 5-hour window: reset is 5h from window start
596
+ # Time elapsed in window = 18000 - (reset - now)
597
+ secs_to_reset=$(( reset_epoch - now_bd ))
598
+ [ "$secs_to_reset" -lt 0 ] && secs_to_reset=0
599
+ secs_elapsed=$(( 18000 - secs_to_reset ))
600
+ [ "$secs_elapsed" -lt 60 ] && secs_elapsed=60
601
+
602
+ # Rate: pct per second
603
+ if [ "$secs_elapsed" -gt 0 ] && [ "$five_hour_pct" -gt 0 ] 2>/dev/null; then
604
+ remaining_pct=$(( 100 - five_hour_pct ))
605
+ if [ "$remaining_pct" -gt 0 ]; then
606
+ # Minutes to full = remaining_pct / (pct / secs_elapsed) / 60
607
+ mins_to_full=$(awk "BEGIN {
608
+ rate = $five_hour_pct / $secs_elapsed;
609
+ if (rate > 0) printf \"%.0f\", $remaining_pct / rate / 60;
610
+ else print 999
611
+ }")
612
+ if [ "$mins_to_full" -le 120 ] 2>/dev/null && [ "$mins_to_full" -gt 0 ] 2>/dev/null; then
613
+ bd_color="$green"
614
+ [ "$mins_to_full" -le 60 ] && bd_color="$orange"
615
+ [ "$mins_to_full" -le 30 ] && bd_color="$yellow"
616
+ [ "$mins_to_full" -le 15 ] && bd_color="$red"
617
+ rate_lines+=" ${bd_color}→full ~${mins_to_full}m${reset}"
618
+ fi
619
+ fi
620
+ fi
621
+ fi
622
+ fi
623
+
624
+ seven_day_pct=$(printf "%.0f" "$seven_day_pct_raw" 2>/dev/null || echo 0)
625
+ seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime")
626
+ seven_day_bar=$(build_bar "$seven_day_pct" "$bar_width")
627
+ seven_day_pct_color=$(color_for_pct "$seven_day_pct")
628
+
629
+ rate_lines+="\n${white}$(printf "%-7s" "weekly")${reset} ${seven_day_bar} ${seven_day_pct_color}$(printf "%3d" "$seven_day_pct")%${reset}"
630
+ [ -n "$seven_day_reset" ] && rate_lines+=" ${white}${seven_day_reset}${reset}"
631
+
632
+ # Extra usage credits
633
+ if [ "$extra_enabled" = "true" ]; then
634
+ extra_pct=$(printf "%.0f" "$extra_pct_raw" 2>/dev/null || echo 0)
635
+ extra_used=$(awk "BEGIN {printf \"%.2f\", $extra_used_raw / 100}" 2>/dev/null)
636
+ extra_limit=$(awk "BEGIN {printf \"%.2f\", $extra_limit_raw / 100}" 2>/dev/null)
637
+ extra_bar=$(build_bar "$extra_pct" "$bar_width")
638
+ extra_pct_color=$(color_for_pct "$extra_pct")
639
+
640
+ rate_lines+="\n${white}$(printf "%-7s" "extra")${reset} ${extra_bar} ${extra_pct_color}$(printf "%3d" "$extra_pct")%${reset} ${white}\$${extra_used}${dim}/${reset}${white}\$${extra_limit}${reset}"
641
+ fi
642
+ fi
643
+
644
+ # ── Daily budget line ──────────────────────────────────
645
+ BUDGET_DISPLAY=""
646
+ if [ "$DAILY_BUDGET" -gt 0 ] 2>/dev/null; then
647
+ budget_pct=$(awk "BEGIN {p=$DAILY_COST * 100 / $DAILY_BUDGET; printf \"%.0f\", (p > 100 ? 100 : p)}")
648
+ budget_bar=$(build_bar "$budget_pct" 10)
649
+ budget_color=$(color_for_pct "$budget_pct")
650
+ BUDGET_DISPLAY="${white}$(printf "%-7s" "budget")${reset} ${budget_bar} ${budget_color}$(printf "%3d" "$budget_pct")%${reset} ${white}\$${DAILY_FMT}${dim}/${reset}${white}\$${DAILY_BUDGET}${reset}"
651
+ fi
652
+
653
+ # ── Terminal width detection ──────────────────────────────
654
+ COLS=$(tput cols 2>/dev/null || echo 120)
655
+
656
+ # ── Branch name compression ──────────────────────────────
657
+ SHORT_GIT_INFO="$GIT_INFO"
658
+ TINY_GIT_INFO=""
659
+ if [ -n "$BRANCH" ]; then
660
+ SHORT_BRANCH="$BRANCH"
661
+ [[ "$BRANCH" == */* ]] && SHORT_BRANCH="${BRANCH##*/}"
662
+
663
+ MAX_BRANCH=20
664
+ CAPPED_BRANCH="$SHORT_BRANCH"
665
+ if [ "${#SHORT_BRANCH}" -gt "$MAX_BRANCH" ]; then
666
+ CAPPED_BRANCH="${SHORT_BRANCH:0:$((MAX_BRANCH-1))}…"
667
+ fi
668
+
669
+ DIRTY=""
670
+ $IS_DIRTY && DIRTY="${red}*"
671
+
672
+ AB_SUFFIX=""
673
+ if [ -n "$UPSTREAM" ]; then
674
+ AB=""
675
+ [ "$AHEAD" -gt 0 ] 2>/dev/null && AB="↑${AHEAD}"
676
+ [ "$BEHIND" -gt 0 ] 2>/dev/null && AB="${AB}↓${BEHIND}"
677
+ [ -n "$AB" ] && AB_SUFFIX="${cyan}${AB}${reset}"
678
+ fi
679
+
680
+ # PR badge appended to branch info
681
+ local_pr_badge=""
682
+ [ -n "$PR_BADGE" ] && local_pr_badge="${PR_BADGE}"
683
+
684
+ if [ -n "$DIRTY" ]; then
685
+ SHORT_GIT_INFO=" ${orange}(${SHORT_BRANCH}${DIRTY}${orange})${reset}${AB_SUFFIX}${local_pr_badge}"
686
+ TINY_GIT_INFO=" ${orange}(${CAPPED_BRANCH}${DIRTY}${orange})${reset}${local_pr_badge}"
687
+ else
688
+ SHORT_GIT_INFO=" ${green}(${SHORT_BRANCH})${reset}${AB_SUFFIX}${local_pr_badge}"
689
+ TINY_GIT_INFO=" ${green}(${CAPPED_BRANCH})${reset}${local_pr_badge}"
690
+ fi
691
+ fi
692
+
693
+ # ── Shared pre-render ──────────────────────────────────────
694
+ DAILY_SUFFIX=""
695
+ if [ "$(awk "BEGIN {print ($DAILY_COST > $COST + 0.01)}")" = "1" ]; then
696
+ DAILY_SUFFIX=" ${dim}(\$${DAILY_FMT}/d)${reset}"
697
+ fi
698
+
699
+ # Write raw JSON sidecar for external consumers (menu bar app, widgets)
700
+ [ -n "$input" ] && echo "$input" > /tmp/claude/statusline-raw.json
701
+
702
+ # ── Render: default (multi-line) ──────────────────────────
703
+ render_default() {
704
+ if [ "$COLS" -ge 100 ]; then
705
+ line1="${blue}${MODEL}${reset}${EFFORT}"
706
+ [ -n "$ACCOUNT_LABEL" ] && line1+="${sep}${ACCOUNT_LABEL}"
707
+ line1+="${sep}${cyan}${DIR_NAME}${reset}${SHORT_GIT_INFO}"
708
+ line1+="${sep}${magenta}\$${COST_FMT}${reset}${DAILY_SUFFIX}${BURN_RATE}"
709
+ [ -n "$SESSION_TIME" ] && line1+="${sep}${dim}⏱${reset} ${white}${SESSION_TIME}${reset}"
710
+ line1+="${CTX_BADGE}${FOCUS}"
711
+ else
712
+ line1="${blue}${MODEL}${reset}${EFFORT}"
713
+ [ -n "$ACCOUNT_LABEL" ] && line1+="${sep}${ACCOUNT_LABEL}"
714
+ [ -n "$TINY_GIT_INFO" ] && line1+="${sep}${TINY_GIT_INFO}"
715
+ line1+="${sep}${magenta}\$${COST_FMT}${reset}${DAILY_SUFFIX}"
716
+ line1+="${CTX_BADGE}${FOCUS}"
717
+ fi
718
+
719
+ printf "%b\n" "$line1"
720
+
721
+ # Detail lines (dimmer for visual hierarchy)
722
+ ctx_line="${dim}${white}$(printf "%-7s" "context")${reset} ${CTX_BAR} ${CTX_COLOR}$(printf "%3d" "$CONTEXT_INT")%${reset}"
723
+ printf "\n%b" "$ctx_line"
724
+ [ -n "$rate_lines" ] && printf "\n%b" "$rate_lines"
725
+ [ -n "$BUDGET_DISPLAY" ] && printf "\n%b" "$BUDGET_DISPLAY"
726
+ [ -n "$TOKEN_DISPLAY" ] && printf "\n${white}$(printf "%-7s" "tokens")${reset} %b" "$TOKEN_DISPLAY"
727
+ }
728
+
729
+ # ── Render: sigil (single dense line) ─────────────────────
730
+ render_sigil() {
731
+ local s=" " # separator (space)
732
+ local dot=" ${dim}·${reset} "
733
+
734
+ # Git segment
735
+ local git_seg=""
736
+ if [ -n "$BRANCH" ]; then
737
+ git_seg="${cyan}⎇${reset} "
738
+ $IS_DIRTY && git_seg+="${orange}${BRANCH}${red}✦${reset}" || git_seg+="${green}${BRANCH}${reset}"
739
+ [ -n "$UPSTREAM" ] && {
740
+ [ "$AHEAD" -gt 0 ] 2>/dev/null && git_seg+="${cyan}↑${AHEAD}${reset}"
741
+ [ "$BEHIND" -gt 0 ] 2>/dev/null && git_seg+="${cyan}↓${BEHIND}${reset}"
742
+ }
743
+ [ -n "$PR_BADGE" ] && git_seg+="${PR_BADGE}"
744
+ fi
745
+
746
+ # Rate limit segment
747
+ local rate_seg=""
748
+ if [ -n "$five_hour_pct" ] && [ "$five_hour_pct" -gt 0 ] 2>/dev/null; then
749
+ local fh_color
750
+ fh_color=$(color_for_pct "$five_hour_pct")
751
+ rate_seg="${fh_color}${five_hour_pct}%${reset}"
752
+ [ -n "$SESSION_TIME" ] && rate_seg+="${dim}⏱${reset}${white}${SESSION_TIME}${reset}"
753
+ fi
754
+
755
+ # Weekly segment
756
+ local weekly_seg=""
757
+ if [ -n "$seven_day_pct" ] && [ "$seven_day_pct" -gt 0 ] 2>/dev/null; then
758
+ local sd_color
759
+ sd_color=$(color_for_pct "$seven_day_pct")
760
+ weekly_seg="${sd_color}${seven_day_pct}%${reset}${dim}w${reset}"
761
+ fi
762
+
763
+ # Context bar (compact: 5 chars)
764
+ local ctx_bar_sm
765
+ ctx_bar_sm=$(build_bar "$CONTEXT_INT" 5)
766
+ local ctx_seg="${ctx_bar_sm} ${CTX_COLOR}${CONTEXT_INT}%${reset}"
767
+
768
+ # Assemble based on width
769
+ local out="${blue}◈${reset} ${blue}${MODEL}${reset}${EFFORT}"
770
+
771
+ if [ "$COLS" -ge 120 ]; then
772
+ out+="${dot}${magenta}\$${COST_FMT}${reset}${DAILY_SUFFIX}"
773
+ out+="${dot}${ctx_seg}"
774
+ [ -n "$git_seg" ] && out+="${dot}${git_seg}"
775
+ [ -n "$rate_seg" ] && out+="${dot}${rate_seg}"
776
+ [ -n "$weekly_seg" ] && out+="${dot}${weekly_seg}"
777
+ elif [ "$COLS" -ge 80 ]; then
778
+ out+="${dot}${magenta}\$${COST_FMT}${reset}"
779
+ out+="${dot}${ctx_seg}"
780
+ [ -n "$git_seg" ] && out+="${dot}${git_seg}"
781
+ [ -n "$rate_seg" ] && out+="${dot}${rate_seg}"
782
+ else
783
+ out+="${dot}${magenta}\$${COST_FMT}${reset}"
784
+ out+="${dot}${CTX_COLOR}${CONTEXT_INT}%${reset}"
785
+ [ -n "$BRANCH" ] && out+="${dot}${BRANCH}"
786
+ [ -n "$five_hour_pct" ] && out+="${dot}${five_hour_pct}%"
787
+ fi
788
+
789
+ printf "%b" "$out"
790
+ }
791
+
792
+ # ── Render: rprompt (zsh right-prompt compatible) ──────────
793
+ render_rprompt() {
794
+ # Zsh prompt color escapes (256-color approximations)
795
+ local zb='%F{39}' # blue
796
+ local zm='%F{141}' # magenta
797
+ local zg='%F{35}' # green
798
+ local zo='%F{215}' # orange
799
+ local zr='%F{203}' # red
800
+ local zy='%F{220}' # yellow
801
+ local zc='%F{73}' # cyan
802
+ local zd='%F{240}' # dim
803
+ local zf='%f' # reset
804
+
805
+ # Context color for zsh
806
+ local zctx_color="$zg"
807
+ [ "$CONTEXT_INT" -ge 90 ] 2>/dev/null && zctx_color="$zr"
808
+ [ "$CONTEXT_INT" -ge 70 ] 2>/dev/null && [ "$CONTEXT_INT" -lt 90 ] && zctx_color="$zy"
809
+ [ "$CONTEXT_INT" -ge 50 ] 2>/dev/null && [ "$CONTEXT_INT" -lt 70 ] && zctx_color="$zo"
810
+
811
+ # Rate limit color for zsh
812
+ local zrl_color="$zg"
813
+ if [ -n "$five_hour_pct" ]; then
814
+ [ "$five_hour_pct" -ge 90 ] 2>/dev/null && zrl_color="$zr"
815
+ [ "$five_hour_pct" -ge 70 ] 2>/dev/null && [ "$five_hour_pct" -lt 90 ] && zrl_color="$zy"
816
+ [ "$five_hour_pct" -ge 50 ] 2>/dev/null && [ "$five_hour_pct" -lt 70 ] && zrl_color="$zo"
817
+ fi
818
+
819
+ # Git segment
820
+ local zgit=""
821
+ if [ -n "$BRANCH" ]; then
822
+ if $IS_DIRTY; then
823
+ zgit="${zo}⎇${BRANCH}${zr}✦${zf}"
824
+ else
825
+ zgit="${zg}⎇${BRANCH}${zf}"
826
+ fi
827
+ fi
828
+
829
+ # Build the rprompt string
830
+ local rp="${zb}◈${zf} ${zm}\$${COST_FMT}${zf}"
831
+ rp+=" ${zctx_color}${CONTEXT_INT}%%${zf}"
832
+ [ -n "$zgit" ] && rp+=" ${zgit}"
833
+ [ -n "$five_hour_pct" ] && rp+=" ${zrl_color}${five_hour_pct}%%${zf}"
834
+
835
+ # Write to file for zsh to pick up
836
+ # Usage: add to .zshrc:
837
+ # _claude_rprompt() {
838
+ # local f=~/.claude/rprompt.txt
839
+ # [[ -f "$f" ]] || return
840
+ # local age=$(( $(date +%s) - $(stat -f %m "$f") ))
841
+ # (( age > 300 )) && { RPROMPT=""; return }
842
+ # RPROMPT="$(cat "$f")"
843
+ # }
844
+ # add-zsh-hook precmd _claude_rprompt
845
+ echo "$rp" > "$HOME/.claude/rprompt.txt"
846
+
847
+ # Also emit sigil format to stdout for Claude Code's own status area
848
+ render_sigil
849
+ }
850
+
851
+ # ── Render: sparkline (default + history strips) ──────────
852
+ render_sparkline() {
853
+ local history_file="$HOME/.claude/session-history.jsonl"
854
+
855
+ # Append current state to history (dedupe by session)
856
+ if [ -n "$SESSION_ID" ]; then
857
+ local ts
858
+ ts=$(date +%s)
859
+ local entry
860
+ entry=$(printf '{"ts":%s,"sid":"%s","cost":%s,"tokens":%s,"ctx":%s,"rate":%s}' \
861
+ "$ts" "$SESSION_ID" "$COST" "$((INPUT_TOKENS + OUTPUT_TOKENS))" "$CONTEXT_INT" "${five_hour_pct:-0}")
862
+ echo "$entry" >> "$history_file"
863
+
864
+ # Prune: keep only last entry per session, max 100 entries
865
+ if [ -f "$history_file" ] && [ "$(wc -l < "$history_file")" -gt 200 ]; then
866
+ # Dedupe by sid (keep last), then tail 100
867
+ local tmpf
868
+ tmpf=$(mktemp "${history_file}.XXXXXX")
869
+ awk -F'"sid":"' '{split($2,a,"\""); sid=a[1]; lines[sid]=$0} END {for(s in lines) print lines[s]}' \
870
+ "$history_file" | tail -100 > "$tmpf" && mv "$tmpf" "$history_file"
871
+ fi
872
+ fi
873
+
874
+ # Build sparkline from history
875
+ local sparkline_cost="" sparkline_rate=""
876
+ if [ -f "$history_file" ]; then
877
+ local spark_chars=(▁ ▂ ▃ ▄ ▅ ▆ ▇ █)
878
+
879
+ # Read last 15 unique sessions' cost values (POSIX awk compatible)
880
+ local costs
881
+ costs=$(awk -F'"sid":"' '{
882
+ split($2,a,"\""); sid=a[1]
883
+ n=split($0,parts,"\"cost\":")
884
+ if(n>1) {split(parts[2],cv,","); sub(/}/,"",cv[1]); lines[sid]=cv[1]+0}
885
+ } END {for(s in lines) print lines[s]}' "$history_file" | tail -15)
886
+
887
+ if [ -n "$costs" ] && [ "$(echo "$costs" | wc -l)" -gt 2 ]; then
888
+ local min_c max_c
889
+ min_c=$(echo "$costs" | sort -n | head -1)
890
+ max_c=$(echo "$costs" | sort -n | tail -1)
891
+ local range_c
892
+ range_c=$(awk "BEGIN {r=$max_c - $min_c; print (r > 0 ? r : 1)}")
893
+
894
+ while IFS= read -r val; do
895
+ local idx
896
+ idx=$(awk "BEGIN {i=int(($val - $min_c) / $range_c * 7); if(i>7) i=7; if(i<0) i=0; print i}")
897
+ sparkline_cost+="${spark_chars[$idx]}"
898
+ done <<< "$costs"
899
+ fi
900
+
901
+ # Rate limit sparkline (POSIX awk compatible)
902
+ local rates
903
+ rates=$(awk -F'"rate":' '{
904
+ split($2,a,"}"); val=a[1]+0
905
+ if(val > 0) {
906
+ n=split($0,parts,"\"sid\":\"")
907
+ if(n>1) {split(parts[2],sv,"\""); lines[sv[1]]=val}
908
+ }
909
+ } END {for(s in lines) print lines[s]}' "$history_file" | tail -15)
910
+
911
+ if [ -n "$rates" ] && [ "$(echo "$rates" | wc -l)" -gt 2 ]; then
912
+ local min_r max_r
913
+ min_r=$(echo "$rates" | sort -n | head -1)
914
+ max_r=$(echo "$rates" | sort -n | tail -1)
915
+ local range_r
916
+ range_r=$(awk "BEGIN {r=$max_r - $min_r; print (r > 0 ? r : 1)}")
917
+
918
+ while IFS= read -r val; do
919
+ local idx
920
+ idx=$(awk "BEGIN {i=int(($val - $min_r) / $range_r * 7); if(i>7) i=7; if(i<0) i=0; print i}")
921
+ sparkline_rate+="${spark_chars[$idx]}"
922
+ done <<< "$rates"
923
+ fi
924
+ fi
925
+
926
+ # Render default format first
927
+ render_default
928
+
929
+ # Append sparklines if we have data
930
+ if [ -n "$sparkline_cost" ]; then
931
+ printf "\n${dim}${white}$(printf "%-7s" "trend")${reset} ${magenta}cost${reset}${dim}${sparkline_cost}${reset}"
932
+ [ -n "$sparkline_rate" ] && printf " ${cyan}rate${reset}${dim}${sparkline_rate}${reset}"
933
+ fi
934
+ }
935
+
936
+ # ── Render: iterm2 (terminal-native status bar) ───────────
937
+ render_iterm2() {
938
+ # Emit iTerm2 user variables via OSC 1337
939
+ emit_iterm2_var() {
940
+ local name="$1" value="$2"
941
+ local encoded
942
+ encoded=$(printf '%s' "$value" | base64 | tr -d '\n')
943
+ printf '\033]1337;SetUserVar=%s=%s\007' "$name" "$encoded"
944
+ }
945
+
946
+ # Emit Kitty window title via OSC 2
947
+ emit_kitty_title() {
948
+ local title="$1"
949
+ printf '\033]2;%s\007' "$title"
950
+ }
951
+
952
+ # Detect terminal
953
+ if [ -n "$ITERM_SESSION_ID" ]; then
954
+ # Push structured data to iTerm2 status bar components
955
+ emit_iterm2_var "claude_model" "${MODEL}${effort_val:+.${effort_val}}"
956
+ emit_iterm2_var "claude_cost" "\$${COST_FMT}${DAILY_SUFFIX:+ ($DAILY_FMT/d)}"
957
+ emit_iterm2_var "claude_ctx" "ctx:${CONTEXT_INT}%"
958
+
959
+ local git_val=""
960
+ [ -n "$BRANCH" ] && {
961
+ git_val="$BRANCH"
962
+ $IS_DIRTY && git_val+="*"
963
+ [ "$AHEAD" -gt 0 ] 2>/dev/null && git_val+=" ↑${AHEAD}"
964
+ [ "$BEHIND" -gt 0 ] 2>/dev/null && git_val+=" ↓${BEHIND}"
965
+ }
966
+ emit_iterm2_var "claude_git" "$git_val"
967
+ emit_iterm2_var "claude_rate" "${five_hour_pct:-0}% / ${seven_day_pct:-0}%"
968
+ emit_iterm2_var "claude_timer" "${SESSION_TIME:-0:00}"
969
+
970
+ elif [ -n "$KITTY_WINDOW_ID" ]; then
971
+ # Set window title to a plain-text sigil line
972
+ local title="◈ ${MODEL}"
973
+ title+=" · \$${COST_FMT}"
974
+ title+=" · ctx:${CONTEXT_INT}%"
975
+ [ -n "$BRANCH" ] && {
976
+ title+=" · ${BRANCH}"
977
+ $IS_DIRTY && title+="*"
978
+ }
979
+ [ -n "$five_hour_pct" ] && title+=" · rate:${five_hour_pct}%"
980
+ emit_kitty_title "$title"
981
+ fi
982
+
983
+ # Always emit sigil format to stdout as fallback for Claude Code's status area
984
+ render_sigil
985
+ }
986
+
987
+ # ── Notification Center alerts ─────────────────────────────
988
+ # Fires macOS notifications at key thresholds with once-per-event dedup.
989
+ # Thresholds: rate limit 80/90/95%, context 80/95%, budget 90/100%
990
+ notify_check() {
991
+ command -v osascript >/dev/null 2>&1 || return
992
+ local state_file="/tmp/claude/statusline-notif-state.json"
993
+ [ ! -f "$state_file" ] && echo '{}' > "$state_file"
994
+
995
+ local state
996
+ state=$(cat "$state_file" 2>/dev/null)
997
+ local changed=false
998
+ local now_ts
999
+ now_ts=$(date +%s)
1000
+
1001
+ # Helper: fire notification if threshold crossed and not already fired at this tier
1002
+ check_threshold() {
1003
+ local key="$1" pct="$2" tier="$3" title="$4" msg="$5"
1004
+ [ -z "$pct" ] || [ "$pct" -lt "$tier" ] 2>/dev/null && return
1005
+ local fired_tier
1006
+ fired_tier=$(echo "$state" | jq -r --arg k "$key" '.[$k].tier // 0' 2>/dev/null)
1007
+ [ "$fired_tier" -ge "$tier" ] 2>/dev/null && return
1008
+
1009
+ # Fire notification
1010
+ osascript -e "display notification \"$msg\" with title \"Claude Code\" subtitle \"$title\"" 2>/dev/null &
1011
+ state=$(echo "$state" | jq --arg k "$key" --argjson t "$tier" --argjson ts "$now_ts" \
1012
+ '.[$k] = {"tier": $t, "ts": $ts}' 2>/dev/null)
1013
+ changed=true
1014
+ }
1015
+
1016
+ # Reset fired state when value drops well below threshold
1017
+ reset_if_below() {
1018
+ local key="$1" pct="$2" reset_below="$3"
1019
+ [ -z "$pct" ] && return
1020
+ [ "$pct" -lt "$reset_below" ] 2>/dev/null && {
1021
+ state=$(echo "$state" | jq --arg k "$key" 'del(.[$k])' 2>/dev/null)
1022
+ changed=true
1023
+ }
1024
+ }
1025
+
1026
+ # Rate limit checks
1027
+ if [ -n "$five_hour_pct" ]; then
1028
+ reset_if_below "rate" "$five_hour_pct" 50
1029
+ check_threshold "rate" "$five_hour_pct" 80 "Rate Limit Warning" "5-hour window at ${five_hour_pct}%"
1030
+ check_threshold "rate" "$five_hour_pct" 90 "Rate Limit High" "5-hour window at ${five_hour_pct}% — consider pausing"
1031
+ check_threshold "rate" "$five_hour_pct" 95 "Rate Limit Critical" "5-hour window at ${five_hour_pct}% — near limit"
1032
+ fi
1033
+
1034
+ # Context checks
1035
+ if [ -n "$CONTEXT_INT" ]; then
1036
+ check_threshold "ctx" "$CONTEXT_INT" 80 "Context Window" "Context at ${CONTEXT_INT}% — consider /compact"
1037
+ check_threshold "ctx" "$CONTEXT_INT" 95 "Context Critical" "Context at ${CONTEXT_INT}% — compact now or lose session"
1038
+ fi
1039
+
1040
+ # Budget checks
1041
+ if [ "$DAILY_BUDGET" -gt 0 ] 2>/dev/null; then
1042
+ local bpct
1043
+ bpct=$(awk "BEGIN {printf \"%.0f\", $DAILY_COST * 100 / $DAILY_BUDGET}")
1044
+ reset_if_below "budget" "$bpct" 50
1045
+ check_threshold "budget" "$bpct" 90 "Budget Warning" "Daily spend at \$${DAILY_FMT} of \$${DAILY_BUDGET} (${bpct}%)"
1046
+ check_threshold "budget" "$bpct" 100 "Budget Exceeded" "Daily spend \$${DAILY_FMT} exceeds \$${DAILY_BUDGET} budget"
1047
+ fi
1048
+
1049
+ $changed && echo "$state" > "$state_file"
1050
+ }
1051
+ notify_check
1052
+
1053
+ # ── Format dispatch ───────────────────────────────────────
1054
+ FORMAT="${STATUSLINE_FORMAT:-${FORMAT:-default}}"
1055
+
1056
+ case "$FORMAT" in
1057
+ sigil) render_sigil ;;
1058
+ rprompt) render_rprompt ;;
1059
+ sparkline) render_sparkline ;;
1060
+ iterm2) render_iterm2 ;;
1061
+ *) render_default ;;
1062
+ esac
1063
+
1064
+ exit 0
package/bin/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync } = require('child_process');
4
+ const path = require('path');
5
+
6
+ const command = process.argv[2];
7
+
8
+ const commands = {
9
+ install: () => require('../lib/install'),
10
+ uninstall: () => require('../lib/uninstall'),
11
+ version: () => {
12
+ const pkg = require('../package.json');
13
+ console.log(`claude-statusline v${pkg.version}`);
14
+ },
15
+ help: showHelp,
16
+ };
17
+
18
+ function showHelp() {
19
+ console.log(`
20
+ claude-statusline — Rich status bar for Claude Code
21
+
22
+ Usage:
23
+ claude-statusline install Install status line
24
+ claude-statusline uninstall Remove status line
25
+ claude-statusline version Show version
26
+ claude-statusline help Show this help
27
+ `);
28
+ }
29
+
30
+ if (!command || !commands[command]) {
31
+ showHelp();
32
+ process.exit(command ? 1 : 0);
33
+ } else {
34
+ commands[command]();
35
+ }
package/lib/install.js ADDED
@@ -0,0 +1,51 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { patchSettings } = require('./patch-settings');
4
+
5
+ const CLAUDE_DIR = path.join(process.env.HOME, '.claude');
6
+ const ASSETS_DIR = path.join(__dirname, '..', 'assets');
7
+
8
+ function install() {
9
+ // Check prerequisites
10
+ if (!fs.existsSync(CLAUDE_DIR)) {
11
+ console.error('Error: ~/.claude/ not found. Install Claude Code first.');
12
+ process.exit(1);
13
+ }
14
+
15
+ try {
16
+ require('child_process').execSync('which jq', { stdio: 'ignore' });
17
+ } catch {
18
+ console.error('Error: jq is required. Install with: brew install jq');
19
+ process.exit(1);
20
+ }
21
+
22
+ console.log('Installing claude-statusline...\n');
23
+
24
+ // Copy script
25
+ const scriptSrc = path.join(ASSETS_DIR, 'statusline.sh');
26
+ const scriptDst = path.join(CLAUDE_DIR, 'statusline.sh');
27
+ fs.copyFileSync(scriptSrc, scriptDst);
28
+ fs.chmodSync(scriptDst, 0o755);
29
+ console.log(' Installed ~/.claude/statusline.sh');
30
+
31
+ // Copy config (if not exists)
32
+ const confDst = path.join(CLAUDE_DIR, 'statusline.conf');
33
+ if (!fs.existsSync(confDst)) {
34
+ const confSrc = path.join(ASSETS_DIR, 'statusline.conf.example');
35
+ fs.copyFileSync(confSrc, confDst);
36
+ console.log(' Created ~/.claude/statusline.conf');
37
+ } else {
38
+ console.log(' Config already exists — skipping');
39
+ }
40
+
41
+ // Patch settings
42
+ patchSettings();
43
+
44
+ console.log('\nDone! Restart Claude Code to see the status line.');
45
+ console.log('\nConfigure: edit ~/.claude/statusline.conf');
46
+ console.log(' HOURLY_RATE=150 # cost tracking');
47
+ console.log(' DAILY_BUDGET=20 # budget bar');
48
+ console.log(' FORMAT=sigil # compact mode');
49
+ }
50
+
51
+ install();
@@ -0,0 +1,63 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const SETTINGS_PATH = path.join(process.env.HOME, '.claude', 'settings.json');
5
+ const STATUSLINE_CONFIG = { type: 'command', command: '~/.claude/statusline.sh' };
6
+
7
+ function patchSettings() {
8
+ let data = {};
9
+
10
+ if (fs.existsSync(SETTINGS_PATH)) {
11
+ const backup = SETTINGS_PATH + '.bak';
12
+ fs.copyFileSync(SETTINGS_PATH, backup);
13
+ console.log(` Backed up settings.json → settings.json.bak`);
14
+
15
+ try {
16
+ data = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
17
+ } catch (e) {
18
+ console.error(` Error parsing settings.json: ${e.message}`);
19
+ return false;
20
+ }
21
+
22
+ if (data.statusLine) {
23
+ if (data.statusLine.command === '~/.claude/statusline.sh') {
24
+ console.log(' settings.json already configured');
25
+ return true;
26
+ }
27
+ console.log(` Warning: settings.json has a different statusLine command: ${data.statusLine.command}`);
28
+ console.log(' Not overwriting. Set manually if desired.');
29
+ return true;
30
+ }
31
+ }
32
+
33
+ data.statusLine = STATUSLINE_CONFIG;
34
+
35
+ const tmp = SETTINGS_PATH + '.tmp';
36
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
37
+ fs.renameSync(tmp, SETTINGS_PATH);
38
+ console.log(' Updated settings.json');
39
+ return true;
40
+ }
41
+
42
+ function unpatchSettings() {
43
+ if (!fs.existsSync(SETTINGS_PATH)) return;
44
+
45
+ let data;
46
+ try {
47
+ data = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
48
+ } catch (e) {
49
+ return;
50
+ }
51
+
52
+ if (!data.statusLine) return;
53
+
54
+ fs.copyFileSync(SETTINGS_PATH, SETTINGS_PATH + '.bak');
55
+ delete data.statusLine;
56
+
57
+ const tmp = SETTINGS_PATH + '.tmp';
58
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
59
+ fs.renameSync(tmp, SETTINGS_PATH);
60
+ console.log(' Removed statusLine from settings.json');
61
+ }
62
+
63
+ module.exports = { patchSettings, unpatchSettings };
@@ -0,0 +1,44 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { unpatchSettings } = require('./patch-settings');
4
+
5
+ const CLAUDE_DIR = path.join(process.env.HOME, '.claude');
6
+
7
+ function uninstall() {
8
+ console.log('Uninstalling claude-statusline...\n');
9
+
10
+ // Remove script
11
+ const script = path.join(CLAUDE_DIR, 'statusline.sh');
12
+ if (fs.existsSync(script)) {
13
+ fs.unlinkSync(script);
14
+ console.log(' Removed ~/.claude/statusline.sh');
15
+ }
16
+
17
+ // Remove statusLine from settings
18
+ unpatchSettings();
19
+
20
+ // Clean runtime files
21
+ const runtimeFiles = [
22
+ path.join(CLAUDE_DIR, 'rprompt.txt'),
23
+ path.join(CLAUDE_DIR, 'session-history.jsonl'),
24
+ ];
25
+ for (const f of runtimeFiles) {
26
+ if (fs.existsSync(f)) fs.unlinkSync(f);
27
+ }
28
+
29
+ // Clean /tmp files
30
+ const tmpDir = '/tmp/claude';
31
+ if (fs.existsSync(tmpDir)) {
32
+ for (const f of fs.readdirSync(tmpDir)) {
33
+ if (f.startsWith('statusline-')) {
34
+ fs.unlinkSync(path.join(tmpDir, f));
35
+ }
36
+ }
37
+ }
38
+ console.log(' Cleaned runtime files');
39
+
40
+ console.log('\n Note: ~/.claude/statusline.conf was kept. Remove manually if desired.');
41
+ console.log('\nUninstalled. Restart Claude Code to take effect.');
42
+ }
43
+
44
+ uninstall();
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@andrewkent/claude-statusline",
3
+ "version": "1.0.0",
4
+ "description": "Rich multi-line status bar for Claude Code — model, cost, context, git, rate limits",
5
+ "keywords": ["claude", "claude-code", "statusline", "status-bar", "terminal", "cli"],
6
+ "author": "Andrew Kent",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/AndrewTKent/claude-statusline.git"
11
+ },
12
+ "homepage": "https://github.com/AndrewTKent/claude-statusline",
13
+ "bin": {
14
+ "claude-statusline": "./bin/cli.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "assets/"
20
+ ],
21
+ "engines": {
22
+ "node": ">=16"
23
+ },
24
+ "scripts": {
25
+ "prepublishOnly": "mkdir -p assets && cp ../bin/statusline.sh assets/ && cp ../config/statusline.conf.example assets/"
26
+ }
27
+ }