@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.
- package/assets/statusline.conf.example +14 -0
- package/assets/statusline.sh +1064 -0
- package/bin/cli.js +35 -0
- package/lib/install.js +51 -0
- package/lib/patch-settings.js +63 -0
- package/lib/uninstall.js +44 -0
- package/package.json +27 -0
|
@@ -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 };
|
package/lib/uninstall.js
ADDED
|
@@ -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
|
+
}
|