@andrewkent/claude-statusline 1.0.0 → 1.1.2

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.
Files changed (2) hide show
  1. package/assets/statusline.sh +189 -47
  2. package/package.json +9 -2
@@ -87,12 +87,34 @@ jq_update() {
87
87
  fi
88
88
  }
89
89
 
90
+ # ── Account label resolution ─────────────────────────────
91
+ # Resolves email to account label (e.g., "work", "personal")
92
+ # Checks ACCOUNT_LABELS config first, then hardcoded fallbacks
93
+ resolve_account_label() {
94
+ local email="$1"
95
+ [ -z "$email" ] && return
96
+
97
+ # Check ACCOUNT_LABELS config: "work:*@company.com personal:me@gmail.com"
98
+ if [ -n "$ACCOUNT_LABELS" ]; then
99
+ local pair label pattern
100
+ for pair in $ACCOUNT_LABELS; do
101
+ label="${pair%%:*}"
102
+ pattern="${pair#*:}"
103
+ # shellcheck disable=SC2254
104
+ case "$email" in $pattern) echo "$label"; return ;; esac
105
+ done
106
+ fi
107
+
108
+ # No config match — use email as label
109
+ echo "$email"
110
+ }
111
+
90
112
  # ── Reusable ledger function ─────────────────────────────
91
- # Usage: update_ledger <file> <session_id> <value> <today>
113
+ # Usage: update_ledger <file> <session_id> <value> <today> [acct]
92
114
  # Returns the daily delta (sum of all session deltas) via LEDGER_RESULT
93
115
  LEDGER_RESULT=0
94
116
  update_ledger() {
95
- local file="$1" sid="$2" value="$3" today="$4"
117
+ local file="$1" sid="$2" value="$3" today="$4" acct="${5:-}"
96
118
 
97
119
  if [ -f "$file" ]; then
98
120
  # Single jq call to get date + baseline existence
@@ -103,26 +125,85 @@ update_ledger() {
103
125
 
104
126
  if [ "$ledger_date" = "$today" ]; then
105
127
  if [ -z "$has_baseline" ]; then
106
- jq_update "$file" --arg sid "$sid" --argjson val "$value" \
107
- '.sessions[$sid] = {"baseline": $val, "current": $val}'
128
+ if [ -n "$acct" ]; then
129
+ jq_update "$file" --arg sid "$sid" --argjson val "$value" --arg acct "$acct" \
130
+ '.sessions[$sid] = {"baseline": $val, "current": $val, "acct": $acct}'
131
+ else
132
+ jq_update "$file" --arg sid "$sid" --argjson val "$value" \
133
+ '.sessions[$sid] = {"baseline": $val, "current": $val}'
134
+ fi
108
135
  else
109
- jq_update "$file" --arg sid "$sid" --argjson val "$value" \
110
- '.sessions[$sid].current = $val'
136
+ if [ -n "$acct" ]; then
137
+ jq_update "$file" --arg sid "$sid" --argjson val "$value" --arg acct "$acct" \
138
+ '.sessions[$sid].current = $val | .sessions[$sid].acct = $acct'
139
+ else
140
+ jq_update "$file" --arg sid "$sid" --argjson val "$value" \
141
+ '.sessions[$sid].current = $val'
142
+ fi
111
143
  fi
112
144
  LEDGER_RESULT=$(jq '[.sessions[] | .current - .baseline] | add // 0' "$file" 2>/dev/null)
113
145
  [ -z "$LEDGER_RESULT" ] && LEDGER_RESULT=0
114
146
  else
115
- printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s}}}' \
116
- "$today" "$sid" "$value" "$value" > "$file"
147
+ if [ -n "$acct" ]; then
148
+ printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s,"acct":"%s"}}}' \
149
+ "$today" "$sid" "$value" "$value" "$acct" > "$file"
150
+ else
151
+ printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s}}}' \
152
+ "$today" "$sid" "$value" "$value" > "$file"
153
+ fi
117
154
  LEDGER_RESULT=0
118
155
  fi
119
156
  else
120
- printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s}}}' \
121
- "$today" "$sid" "$value" "$value" > "$file"
157
+ if [ -n "$acct" ]; then
158
+ printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s,"acct":"%s"}}}' \
159
+ "$today" "$sid" "$value" "$value" "$acct" > "$file"
160
+ else
161
+ printf '{"date":"%s","sessions":{"%s":{"baseline":%s,"current":%s}}}' \
162
+ "$today" "$sid" "$value" "$value" > "$file"
163
+ fi
122
164
  LEDGER_RESULT=0
123
165
  fi
124
166
  }
125
167
 
168
+ # ── Subagent token tracking ──────────────────────────────
169
+ # Sums tokens from subagent JSONL files for the current session.
170
+ # Caches result for 30s to avoid scanning on every render.
171
+ SUBAGENT_TOKENS=0
172
+ get_subagent_tokens() {
173
+ local sid="$1" cwd="$2"
174
+ SUBAGENT_TOKENS=0
175
+ [ -z "$sid" ] || [ -z "$cwd" ] && return
176
+
177
+ local cache_file="/tmp/claude/statusline-subagent-${sid}.txt"
178
+ mkdir -p /tmp/claude
179
+
180
+ # Check cache (30s TTL)
181
+ if [ -f "$cache_file" ]; then
182
+ local cache_age
183
+ local cache_mtime
184
+ cache_mtime=$(stat -f %m "$cache_file" 2>/dev/null || stat -c %Y "$cache_file" 2>/dev/null)
185
+ cache_age=$(( $(date +%s) - cache_mtime ))
186
+ if [ "$cache_age" -lt 30 ]; then
187
+ SUBAGENT_TOKENS=$(cat "$cache_file" 2>/dev/null)
188
+ [ -z "$SUBAGENT_TOKENS" ] && SUBAGENT_TOKENS=0
189
+ return
190
+ fi
191
+ fi
192
+
193
+ # Map CWD to project dir name (Claude's convention: slashes become dashes)
194
+ local project_dir
195
+ project_dir=$(echo "$cwd" | tr '/' '-')
196
+ local subagent_path="$HOME/.claude/projects/${project_dir}/${sid}/subagents"
197
+
198
+ if [ -d "$subagent_path" ]; then
199
+ # Sum input + output tokens across subagent files
200
+ SUBAGENT_TOKENS=$(jq -s '[.[].message.usage | select(.) | (.input_tokens // 0) + (.output_tokens // 0)] | add // 0' "$subagent_path"/agent-*.jsonl 2>/dev/null)
201
+ [ -z "$SUBAGENT_TOKENS" ] && SUBAGENT_TOKENS=0
202
+ fi
203
+
204
+ echo "$SUBAGENT_TOKENS" > "$cache_file"
205
+ }
206
+
126
207
  iso_to_epoch() {
127
208
  local iso_str="$1"
128
209
 
@@ -199,54 +280,69 @@ eval "$(echo "$input" | jq -r '
199
280
  "CTX_SIZE=" + (.context_window.context_window_size // 200000 | tostring | @sh)
200
281
  ' 2>/dev/null)"
201
282
 
283
+ # ── Early account resolution (needed before ledger writes) ──
284
+ ACCT_TAG=""
285
+ ACCT_EMAIL=""
286
+ profile_cache_file="/tmp/claude/statusline-profile-cache.json"
287
+ if [ -f "$profile_cache_file" ]; then
288
+ ACCT_EMAIL=$(jq -r '.account.email // empty' "$profile_cache_file" 2>/dev/null)
289
+ ACCT_TAG=$(resolve_account_label "$ACCT_EMAIL")
290
+ fi
291
+
202
292
  # ── Daily cost ledger ──────────────────────────────────
203
293
  DAILY_LEDGER="$HOME/.claude/daily-cost.json"
204
294
  TODAY=$(date +%Y-%m-%d)
205
295
  DAILY_COST="$COST"
206
296
  if [ -n "$SESSION_ID" ] && [ "$(awk "BEGIN {print ($COST > 0)}")" = "1" ]; then
207
- update_ledger "$DAILY_LEDGER" "$SESSION_ID" "$COST" "$TODAY"
297
+ update_ledger "$DAILY_LEDGER" "$SESSION_ID" "$COST" "$TODAY" "$ACCT_TAG"
208
298
  DAILY_COST="${LEDGER_RESULT:-0}"
209
299
  fi
210
300
  DAILY_FMT=$(printf "%.2f" "$DAILY_COST")
211
301
 
212
- # ── Token challenge tracker ───────────────────────────────
213
- TOKEN_LEDGER="$HOME/.claude/token-challenge.json"
302
+ # ── Token challenge tracker (reads stats-cache directly) ─────
214
303
  TOKEN_DISPLAY=""
215
304
 
216
305
  if [ -n "$SESSION_ID" ]; then
217
306
  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"
307
+ get_subagent_tokens "$SESSION_ID" "$CWD"
308
+
309
+ CHALLENGE_TOKENS=0
310
+ TOTAL_INPUT=0
311
+ TOTAL_OUTPUT=0
312
+ if [ -f "$HOME/.claude/stats-cache.json" ]; then
313
+ eval "$(jq -r '
314
+ "TOTAL_INPUT=" + ([.modelUsage[].inputTokens] | add // 0 | tostring),
315
+ "TOTAL_OUTPUT=" + ([.modelUsage[].outputTokens] | add // 0 | tostring)
316
+ ' "$HOME/.claude/stats-cache.json" 2>/dev/null)"
317
+ CHALLENGE_TOKENS=$((TOTAL_INPUT + TOTAL_OUTPUT))
238
318
  fi
239
319
 
240
- if [ -n "$CHALLENGE_TOKENS" ] && [ "$CHALLENGE_TOKENS" != "null" ]; then
320
+ if [ "$CHALLENGE_TOKENS" -gt 0 ] 2>/dev/null; then
241
321
  TOKEN_M=$(awk "BEGIN {printf \"%.1f\", $CHALLENGE_TOKENS / 1000000}")
242
322
  GOAL_M="100"
243
323
  TOKEN_PCT=$(awk "BEGIN {printf \"%.0f\", $CHALLENGE_TOKENS / (${GOAL_M} * 10000)}")
244
324
  [ "$TOKEN_PCT" -gt 100 ] 2>/dev/null && TOKEN_PCT=100
245
- TOKEN_BAR=$(build_bar "$TOKEN_PCT" 10)
325
+
326
+ # Split bar: cyan for input, magenta for output
327
+ BAR_WIDTH=10
328
+ INPUT_PCT=$(awk "BEGIN {printf \"%.0f\", $TOTAL_INPUT / (${GOAL_M} * 10000)}")
329
+ OUTPUT_PCT=$(awk "BEGIN {printf \"%.0f\", $TOTAL_OUTPUT / (${GOAL_M} * 10000)}")
330
+ INPUT_DOTS=$(( INPUT_PCT * BAR_WIDTH / 100 ))
331
+ OUTPUT_DOTS=$(( OUTPUT_PCT * BAR_WIDTH / 100 ))
332
+ # Clamp so they don't exceed bar width
333
+ [ $((INPUT_DOTS + OUTPUT_DOTS)) -gt "$BAR_WIDTH" ] && OUTPUT_DOTS=$((BAR_WIDTH - INPUT_DOTS))
334
+ EMPTY_DOTS=$((BAR_WIDTH - INPUT_DOTS - OUTPUT_DOTS))
335
+ [ "$EMPTY_DOTS" -lt 0 ] && EMPTY_DOTS=0
336
+
337
+ INPUT_STR="" OUTPUT_STR="" EMPTY_STR=""
338
+ for ((i=0; i<INPUT_DOTS; i++)); do INPUT_STR+="●"; done
339
+ for ((i=0; i<OUTPUT_DOTS; i++)); do OUTPUT_STR+="●"; done
340
+ for ((i=0; i<EMPTY_DOTS; i++)); do EMPTY_STR+="○"; done
341
+ TOKEN_BAR="${cyan}${INPUT_STR}${magenta}${OUTPUT_STR}${dim}${EMPTY_STR}${reset}"
246
342
 
247
343
  # Daily token tracking
248
344
  DAILY_TOKEN_LEDGER="$HOME/.claude/daily-tokens.json"
249
- update_ledger "$DAILY_TOKEN_LEDGER" "$SESSION_ID" "$SESSION_TOKENS" "$TODAY"
345
+ update_ledger "$DAILY_TOKEN_LEDGER" "$SESSION_ID" "$SESSION_TOKENS" "$TODAY" "$ACCT_TAG"
250
346
  DAILY_TOKENS="${LEDGER_RESULT:-0}"
251
347
 
252
348
  # Session token delta
@@ -271,12 +367,18 @@ if [ -n "$SESSION_ID" ]; then
271
367
  }")
272
368
 
273
369
  TOKEN_SUFFIX=" ${magenta}+${SESSION_TOKEN_FMT}${reset}"
370
+ if [ "$SUBAGENT_TOKENS" -gt 0 ] 2>/dev/null; then
371
+ sub_fmt=$(format_tokens "$SUBAGENT_TOKENS")
372
+ TOKEN_SUFFIX+=" ${dim}+${sub_fmt} sub${reset}"
373
+ fi
274
374
  if [ "$DAILY_TOKENS" -gt "$SESSION_DELTA" ] 2>/dev/null; then
275
375
  TOKEN_SUFFIX+=" ${dim}(+${DAILY_TOKEN_FMT}/d)${reset}"
276
376
  fi
277
377
 
278
378
  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}"
379
+ INPUT_M=$(awk "BEGIN {printf \"%.1f\", $TOTAL_INPUT / 1000000}")
380
+ OUTPUT_M=$(awk "BEGIN {printf \"%.1f\", $TOTAL_OUTPUT / 1000000}")
381
+ TOKEN_DISPLAY="${TOKEN_BAR} ${TOKEN_PCT_COLOR}$(printf "%3d" "$TOKEN_PCT")%${reset} ${dim}(${reset}${cyan}${INPUT_M}${reset}${dim}+${reset}${magenta}${OUTPUT_M}M${reset}${dim})/${GOAL_M}M${reset}${TOKEN_SUFFIX}"
280
382
  fi
281
383
  fi
282
384
 
@@ -479,7 +581,7 @@ get_oauth_token() {
479
581
  # ── Fetch rate limits + profile (background, never blocking) ──
480
582
  cache_file="/tmp/claude/statusline-usage-cache.json"
481
583
  profile_cache_file="/tmp/claude/statusline-profile-cache.json"
482
- cache_max_age=60
584
+ cache_max_age=300
483
585
  profile_cache_max_age=300
484
586
  lock_file="/tmp/claude/statusline-refresh.lock"
485
587
  mkdir -p /tmp/claude
@@ -489,6 +591,34 @@ needs_refresh=false
489
591
  needs_profile_refresh=false
490
592
  now=$(date +%s)
491
593
 
594
+ # Detect account switch: if credentials file changed, invalidate caches
595
+ creds_mtime_file="/tmp/claude/statusline-creds-mtime"
596
+ creds_file="$HOME/.claude/.credentials.json"
597
+ if [ -f "$creds_file" ]; then
598
+ creds_mtime=$(stat -f %m "$creds_file" 2>/dev/null || stat -c %Y "$creds_file" 2>/dev/null)
599
+ old_creds_mtime=$(cat "$creds_mtime_file" 2>/dev/null)
600
+ if [ "$old_creds_mtime" != "$creds_mtime" ]; then
601
+ rm -f "$cache_file" "$profile_cache_file" "$lock_file"
602
+ echo "$creds_mtime" > "$creds_mtime_file"
603
+ needs_refresh=true
604
+ # Synchronous profile fetch on account switch — avoids stale label
605
+ token=$(get_oauth_token)
606
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
607
+ p_response=$(curl -s --max-time 2 \
608
+ -H "Accept: application/json" \
609
+ -H "Content-Type: application/json" \
610
+ -H "Authorization: Bearer $token" \
611
+ -H "anthropic-beta: oauth-2025-04-20" \
612
+ -H "User-Agent: claude-code/2.1.34" \
613
+ "https://api.anthropic.com/api/oauth/profile" 2>/dev/null)
614
+ if [ -n "$p_response" ] && echo "$p_response" | jq -e '.account' >/dev/null 2>&1; then
615
+ echo "$p_response" > "$profile_cache_file"
616
+ fi
617
+ fi
618
+ needs_profile_refresh=false
619
+ fi
620
+ fi
621
+
492
622
  if [ -f "$cache_file" ]; then
493
623
  cache_mtime=$(stat -f %m "$cache_file" 2>/dev/null || stat -c %Y "$cache_file" 2>/dev/null)
494
624
  cache_age=$(( now - cache_mtime ))
@@ -507,6 +637,14 @@ fi
507
637
 
508
638
  # Fire-and-forget background refresh (never blocks the status line)
509
639
  if $needs_refresh || $needs_profile_refresh; then
640
+ # Clean up stale lock files (PID dead or lock older than 30s)
641
+ if [ -f "$lock_file" ]; then
642
+ lock_pid=$(cat "$lock_file" 2>/dev/null)
643
+ lock_age=$(( now - $(stat -f %m "$lock_file" 2>/dev/null || stat -c %Y "$lock_file" 2>/dev/null || echo "$now") ))
644
+ if [ "$lock_age" -gt 30 ] || ! kill -0 "$lock_pid" 2>/dev/null; then
645
+ rm -f "$lock_file"
646
+ fi
647
+ fi
510
648
  # Use a lock file to prevent concurrent refreshes from racing
511
649
  if (set -o noclobber; echo $$ > "$lock_file") 2>/dev/null; then
512
650
  (
@@ -548,14 +686,13 @@ usage_data=""
548
686
  profile_data=""
549
687
  [ -f "$profile_cache_file" ] && profile_data=$(cat "$profile_cache_file" 2>/dev/null)
550
688
 
551
- # ── Account label ──────────────────────────────────────
689
+ # ── Account label (colorize ACCT_TAG resolved earlier) ──
552
690
  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}" ;;
691
+ if [ -n "$ACCT_TAG" ]; then
692
+ case "$ACCT_TAG" in
693
+ work) ACCOUNT_LABEL="${cyan}work${reset}" ;;
694
+ personal) ACCOUNT_LABEL="${magenta}personal${reset}" ;;
695
+ *) ACCOUNT_LABEL="${dim}${ACCT_TAG}${reset}" ;;
559
696
  esac
560
697
  fi
561
698
 
@@ -857,8 +994,8 @@ render_sparkline() {
857
994
  local ts
858
995
  ts=$(date +%s)
859
996
  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}")
997
+ entry=$(printf '{"ts":%s,"sid":"%s","cost":%s,"tokens":%s,"sub":%s,"ctx":%s,"rate":%s,"acct":"%s"}' \
998
+ "$ts" "$SESSION_ID" "$COST" "$((INPUT_TOKENS + OUTPUT_TOKENS))" "${SUBAGENT_TOKENS:-0}" "$CONTEXT_INT" "${five_hour_pct:-0}" "${ACCT_TAG:-}")
862
999
  echo "$entry" >> "$history_file"
863
1000
 
864
1001
  # Prune: keep only last entry per session, max 100 entries
@@ -1053,6 +1190,11 @@ notify_check
1053
1190
  # ── Format dispatch ───────────────────────────────────────
1054
1191
  FORMAT="${STATUSLINE_FORMAT:-${FORMAT:-default}}"
1055
1192
 
1193
+ # ── Set terminal tab title ────────────────────────────────
1194
+ TAB_TITLE="${DIR_NAME}"
1195
+ [ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && TAB_TITLE="${DIR_NAME} (${SHORT_BRANCH})"
1196
+ printf '\033]0;%s\007' "$TAB_TITLE"
1197
+
1056
1198
  case "$FORMAT" in
1057
1199
  sigil) render_sigil ;;
1058
1200
  rprompt) render_rprompt ;;
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@andrewkent/claude-statusline",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
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"],
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "statusline",
9
+ "status-bar",
10
+ "terminal",
11
+ "cli"
12
+ ],
6
13
  "author": "Andrew Kent",
7
14
  "license": "MIT",
8
15
  "repository": {