cc-context-stats 1.3.0 → 1.5.1
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/.claude/settings.local.json +36 -1
- package/.github/workflows/ci.yml +4 -2
- package/.github/workflows/release.yml +3 -1
- package/CHANGELOG.md +17 -0
- package/README.md +33 -11
- package/RELEASE_NOTES.md +10 -0
- package/assets/logo/favicon.svg +19 -0
- package/assets/logo/logo-black.svg +24 -0
- package/assets/logo/logo-full.svg +40 -0
- package/assets/logo/logo-icon.svg +27 -0
- package/assets/logo/logo-mark.svg +28 -0
- package/assets/logo/logo-white.svg +24 -0
- package/assets/logo/logo-wordmark.svg +6 -0
- package/install.sh +21 -2
- package/package.json +7 -3
- package/pyproject.toml +6 -4
- package/scripts/context-stats.sh +194 -26
- package/scripts/statusline-full.sh +65 -2
- package/scripts/statusline-git.sh +57 -1
- package/scripts/statusline-minimal.sh +57 -1
- package/scripts/statusline.js +51 -4
- package/scripts/statusline.py +57 -3
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +106 -34
- package/src/claude_statusline/cli/statusline.py +5 -4
- package/src/claude_statusline/core/config.py +7 -0
- package/src/claude_statusline/formatters/layout.py +67 -0
- package/src/claude_statusline/graphs/renderer.py +44 -24
- package/src/claude_statusline/graphs/statistics.py +34 -0
- package/src/claude_statusline/ui/__init__.py +1 -0
- package/src/claude_statusline/ui/icons.py +93 -0
- package/src/claude_statusline/ui/waiting.py +62 -0
- package/tests/bash/test_statusline_full.bats +30 -0
- package/tests/node/statusline.test.js +44 -3
- package/tests/python/test_icons.py +152 -0
- package/tests/python/test_layout.py +127 -0
- package/tests/python/test_statusline.py +64 -3
- package/tests/python/test_waiting.py +127 -0
- package/PUBLISHING_GUIDE.md +0 -69
- package/npm-publish.sh +0 -33
- package/publish.sh +0 -24
- package/show_raw_claude_code_api.js +0 -11
package/scripts/context-stats.sh
CHANGED
|
@@ -57,6 +57,8 @@ COLOR_ENABLED=true
|
|
|
57
57
|
TOKEN_DETAIL_ENABLED=true
|
|
58
58
|
WATCH_MODE=true
|
|
59
59
|
WATCH_INTERVAL=2
|
|
60
|
+
REDUCED_MOTION=false
|
|
61
|
+
CYCLE_COUNTER=0
|
|
60
62
|
|
|
61
63
|
# === UTILITY FUNCTIONS ===
|
|
62
64
|
|
|
@@ -218,6 +220,144 @@ format_duration() {
|
|
|
218
220
|
fi
|
|
219
221
|
}
|
|
220
222
|
|
|
223
|
+
# === ACTIVITY ICONS & WAITING TEXT ===
|
|
224
|
+
|
|
225
|
+
# Waiting messages for rotating display
|
|
226
|
+
WAITING_MESSAGES="Thinking... Cooking... Crunching_tokens... Compiling_plan... Running_steps... Processing... Working_on_it... Analyzing..."
|
|
227
|
+
WAITING_MSG_COUNT=8
|
|
228
|
+
|
|
229
|
+
get_waiting_text() {
|
|
230
|
+
local cycle=$1
|
|
231
|
+
if [ "$REDUCED_MOTION" = "true" ]; then
|
|
232
|
+
echo "Working..."
|
|
233
|
+
return
|
|
234
|
+
fi
|
|
235
|
+
# Rotate every 2 cycles
|
|
236
|
+
local msg_idx=$(( (cycle / 2) % WAITING_MSG_COUNT + 1 ))
|
|
237
|
+
local msg
|
|
238
|
+
msg=$(echo "$WAITING_MESSAGES" | awk -v n="$msg_idx" '{ print $n }')
|
|
239
|
+
# Replace underscores with spaces
|
|
240
|
+
echo "$msg" | tr '_' ' '
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Check if session is active (last entry within timeout seconds)
|
|
244
|
+
is_session_active() {
|
|
245
|
+
local last_ts=$1
|
|
246
|
+
local timeout=${2:-30}
|
|
247
|
+
local now
|
|
248
|
+
now=$(date +%s)
|
|
249
|
+
local diff=$((now - last_ts))
|
|
250
|
+
[ "$diff" -le "$timeout" ]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Detect spike: latest delta > 15% of context window OR > 3x rolling avg of previous deltas
|
|
254
|
+
detect_spike() {
|
|
255
|
+
local deltas_str=$1
|
|
256
|
+
local context_window=$2
|
|
257
|
+
local window=${3:-5}
|
|
258
|
+
|
|
259
|
+
local count
|
|
260
|
+
count=$(echo "$deltas_str" | wc -w | tr -d ' ')
|
|
261
|
+
[ "$count" -eq 0 ] && return 1
|
|
262
|
+
|
|
263
|
+
local latest
|
|
264
|
+
latest=$(echo "$deltas_str" | awk '{ print $NF }')
|
|
265
|
+
|
|
266
|
+
# Absolute threshold: > 15% of context window
|
|
267
|
+
if [ "$context_window" -gt 0 ]; then
|
|
268
|
+
local threshold=$((context_window * 15 / 100))
|
|
269
|
+
[ "$latest" -gt "$threshold" ] && return 0
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
# Relative threshold: > 3x rolling avg of previous deltas
|
|
273
|
+
if [ "$count" -ge 2 ]; then
|
|
274
|
+
# Get previous deltas (exclude last)
|
|
275
|
+
local prev_count=$((count - 1))
|
|
276
|
+
local start=$((prev_count > window ? count - window - 1 : 0))
|
|
277
|
+
local prev_sum=0
|
|
278
|
+
local prev_n=0
|
|
279
|
+
local idx=0
|
|
280
|
+
for d in $deltas_str; do
|
|
281
|
+
if [ "$idx" -ge "$start" ] && [ "$idx" -lt "$prev_count" ]; then
|
|
282
|
+
prev_sum=$((prev_sum + d))
|
|
283
|
+
prev_n=$((prev_n + 1))
|
|
284
|
+
fi
|
|
285
|
+
idx=$((idx + 1))
|
|
286
|
+
done
|
|
287
|
+
if [ "$prev_n" -gt 0 ]; then
|
|
288
|
+
local avg=$((prev_sum / prev_n))
|
|
289
|
+
if [ "$avg" -gt 0 ] && [ "$latest" -gt $((avg * 3)) ]; then
|
|
290
|
+
return 0
|
|
291
|
+
fi
|
|
292
|
+
fi
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
return 1
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Determine activity tier: idle, low, medium, high, spike
|
|
299
|
+
get_activity_tier() {
|
|
300
|
+
local last_ts=$1
|
|
301
|
+
local context_window=$2
|
|
302
|
+
local deltas_str=$3
|
|
303
|
+
|
|
304
|
+
# Check if idle (>30s since last entry)
|
|
305
|
+
if ! is_session_active "$last_ts"; then
|
|
306
|
+
echo "idle"
|
|
307
|
+
return
|
|
308
|
+
fi
|
|
309
|
+
|
|
310
|
+
local count
|
|
311
|
+
count=$(echo "$deltas_str" | wc -w | tr -d ' ')
|
|
312
|
+
if [ "$count" -eq 0 ]; then
|
|
313
|
+
echo "idle"
|
|
314
|
+
return
|
|
315
|
+
fi
|
|
316
|
+
|
|
317
|
+
local latest
|
|
318
|
+
latest=$(echo "$deltas_str" | awk '{ print $NF }')
|
|
319
|
+
|
|
320
|
+
if [ "$latest" -le 0 ]; then
|
|
321
|
+
echo "idle"
|
|
322
|
+
return
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
# Check spike first
|
|
326
|
+
if detect_spike "$deltas_str" "$context_window"; then
|
|
327
|
+
echo "spike"
|
|
328
|
+
return
|
|
329
|
+
fi
|
|
330
|
+
|
|
331
|
+
if [ "$context_window" -le 0 ]; then
|
|
332
|
+
echo "low"
|
|
333
|
+
return
|
|
334
|
+
fi
|
|
335
|
+
|
|
336
|
+
# Delta as percentage of context window (x100 for integer math)
|
|
337
|
+
local delta_pct_x100=$((latest * 10000 / context_window))
|
|
338
|
+
|
|
339
|
+
if [ "$delta_pct_x100" -gt 500 ]; then
|
|
340
|
+
echo "high"
|
|
341
|
+
elif [ "$delta_pct_x100" -gt 200 ]; then
|
|
342
|
+
echo "medium"
|
|
343
|
+
else
|
|
344
|
+
echo "low"
|
|
345
|
+
fi
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# Get text label for tier
|
|
349
|
+
get_tier_label() {
|
|
350
|
+
local tier=$1
|
|
351
|
+
case "$tier" in
|
|
352
|
+
idle) echo "Idle" ;;
|
|
353
|
+
low) echo "Low activity" ;;
|
|
354
|
+
medium) echo "Active" ;;
|
|
355
|
+
high) echo "High activity" ;;
|
|
356
|
+
spike) echo "Spike!" ;;
|
|
357
|
+
*) echo "Idle" ;;
|
|
358
|
+
esac
|
|
359
|
+
}
|
|
360
|
+
|
|
221
361
|
# === DATA FUNCTIONS ===
|
|
222
362
|
|
|
223
363
|
migrate_old_state_files() {
|
|
@@ -814,6 +954,11 @@ load_config() {
|
|
|
814
954
|
TOKEN_DETAIL_ENABLED=false
|
|
815
955
|
fi
|
|
816
956
|
;;
|
|
957
|
+
reduced_motion)
|
|
958
|
+
if [ "$value" = "true" ]; then
|
|
959
|
+
REDUCED_MOTION=true
|
|
960
|
+
fi
|
|
961
|
+
;;
|
|
817
962
|
esac
|
|
818
963
|
done <"$CONFIG_FILE"
|
|
819
964
|
fi
|
|
@@ -841,6 +986,26 @@ render_once() {
|
|
|
841
986
|
echo -e "${BOLD}${MAGENTA}Context Stats${RESET} ${DIM}(Session: $session_name)${RESET}"
|
|
842
987
|
fi
|
|
843
988
|
|
|
989
|
+
# Activity indicator (waiting text + label)
|
|
990
|
+
local last_ts
|
|
991
|
+
last_ts=$(get_element "$TIMESTAMPS" "$DATA_COUNT")
|
|
992
|
+
local last_context
|
|
993
|
+
last_context=$(get_element "$CONTEXT_SIZES" "$DATA_COUNT")
|
|
994
|
+
[ -z "$last_context" ] && last_context=0
|
|
995
|
+
|
|
996
|
+
local tier
|
|
997
|
+
tier=$(get_activity_tier "$last_ts" "$last_context" "$DELTAS")
|
|
998
|
+
local label
|
|
999
|
+
label=$(get_tier_label "$tier")
|
|
1000
|
+
|
|
1001
|
+
if is_session_active "$last_ts"; then
|
|
1002
|
+
local wait_text
|
|
1003
|
+
wait_text=$(get_waiting_text "$CYCLE_COUNTER")
|
|
1004
|
+
echo -e " ${DIM}${wait_text} [${label}]${RESET}"
|
|
1005
|
+
else
|
|
1006
|
+
echo -e " ${DIM}${label}${RESET}"
|
|
1007
|
+
fi
|
|
1008
|
+
|
|
844
1009
|
# Render graphs (use CURRENT_USED_TOKENS for actual context window usage)
|
|
845
1010
|
case "$GRAPH_TYPE" in
|
|
846
1011
|
cumulative)
|
|
@@ -893,39 +1058,42 @@ run_watch_mode() {
|
|
|
893
1058
|
printf "%s%s" "${CLEAR_SCREEN}" "${CURSOR_HOME}"
|
|
894
1059
|
|
|
895
1060
|
while true; do
|
|
896
|
-
# Move cursor to home position (top-left) instead of clearing
|
|
897
|
-
# This prevents flickering by overwriting in place
|
|
898
|
-
printf "%s" "${CURSOR_HOME}"
|
|
899
|
-
|
|
900
1061
|
# Re-read terminal dimensions in case of resize
|
|
901
1062
|
get_terminal_dimensions
|
|
902
1063
|
|
|
903
|
-
#
|
|
904
|
-
local
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1064
|
+
# Capture all output into a variable for atomic write
|
|
1065
|
+
local output
|
|
1066
|
+
output=$(
|
|
1067
|
+
# Show watch mode indicator with live timestamp
|
|
1068
|
+
local current_time
|
|
1069
|
+
current_time=$(date +%H:%M:%S)
|
|
1070
|
+
echo -e "${DIM}[LIVE ${current_time}] Refresh: ${WATCH_INTERVAL}s | Ctrl+C to exit${RESET}"
|
|
1071
|
+
|
|
1072
|
+
# Handle case where state_file is empty (no sessions found at all)
|
|
1073
|
+
if [ -z "$state_file" ]; then
|
|
1074
|
+
local wait_msg
|
|
1075
|
+
wait_msg=$(get_waiting_text "$CYCLE_COUNTER")
|
|
1076
|
+
show_waiting_message "" "$wait_msg"
|
|
1077
|
+
# Re-validate and render (file might have new data)
|
|
1078
|
+
elif [ -f "$state_file" ]; then
|
|
1079
|
+
local line_count
|
|
1080
|
+
line_count=$(wc -l <"$state_file" | tr -d ' ')
|
|
1081
|
+
if [ "$line_count" -ge 2 ]; then
|
|
1082
|
+
render_once "$state_file"
|
|
1083
|
+
else
|
|
1084
|
+
show_waiting_message "$SESSION_ID" "Waiting for more data points..."
|
|
1085
|
+
echo -e " ${DIM}Current: ${line_count} point(s), need at least 2${RESET}"
|
|
1086
|
+
fi
|
|
917
1087
|
else
|
|
918
|
-
|
|
919
|
-
|
|
1088
|
+
# File doesn't exist yet (new session)
|
|
1089
|
+
show_waiting_message "$SESSION_ID" "Waiting for session data..."
|
|
920
1090
|
fi
|
|
921
|
-
|
|
922
|
-
# File doesn't exist yet (new session)
|
|
923
|
-
show_waiting_message "$SESSION_ID" "Waiting for session data..."
|
|
924
|
-
fi
|
|
1091
|
+
)
|
|
925
1092
|
|
|
926
|
-
#
|
|
927
|
-
printf "%s" "${CLEAR_TO_END}"
|
|
1093
|
+
# Atomic write: CURSOR_HOME + content + CLEAR_TO_END (clean up stale trailing lines)
|
|
1094
|
+
printf "%s%s\n%s" "${CURSOR_HOME}" "$output" "${CLEAR_TO_END}"
|
|
928
1095
|
|
|
1096
|
+
CYCLE_COUNTER=$((CYCLE_COUNTER + 1))
|
|
929
1097
|
sleep "$WATCH_INTERVAL"
|
|
930
1098
|
done
|
|
931
1099
|
}
|
|
@@ -105,9 +105,69 @@ if [[ -f ~/.claude/statusline.conf ]]; then
|
|
|
105
105
|
if [[ "$show_session" == "false" ]]; then
|
|
106
106
|
show_session_enabled=false
|
|
107
107
|
fi
|
|
108
|
-
# Note: show_io_tokens setting is read but not yet implemented
|
|
109
108
|
fi
|
|
110
109
|
|
|
110
|
+
# Width-fitting helpers
|
|
111
|
+
visible_width() {
|
|
112
|
+
# Strip ANSI escape sequences (both literal \033 and actual ESC byte) and return string length
|
|
113
|
+
local stripped
|
|
114
|
+
stripped=$(printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g')
|
|
115
|
+
printf '%s' "$stripped" | wc -m | tr -d ' '
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get_terminal_width() {
|
|
119
|
+
# Return terminal width for fit_to_width truncation.
|
|
120
|
+
# When running inside Claude Code's statusline subprocess, neither $COLUMNS
|
|
121
|
+
# nor tput can detect the real terminal width (they always return 80).
|
|
122
|
+
# If COLUMNS is explicitly set, trust it. Otherwise use 200 as default
|
|
123
|
+
# so no parts are unnecessarily dropped; Claude Code handles overflow.
|
|
124
|
+
if [[ -n "$COLUMNS" ]]; then
|
|
125
|
+
echo "$COLUMNS"
|
|
126
|
+
else
|
|
127
|
+
local cols
|
|
128
|
+
cols=$(tput cols 2>/dev/null || echo 80)
|
|
129
|
+
if [[ "$cols" -eq 80 ]]; then
|
|
130
|
+
echo 200
|
|
131
|
+
else
|
|
132
|
+
echo "$cols"
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fit_to_width() {
|
|
138
|
+
# Assemble parts into a single line that fits within max_width.
|
|
139
|
+
# Usage: fit_to_width max_width part1 part2 part3 ...
|
|
140
|
+
# First part (base) is always included. Subsequent parts are
|
|
141
|
+
# included only if adding them does not exceed max_width.
|
|
142
|
+
local max_width=$1
|
|
143
|
+
shift
|
|
144
|
+
local parts=("$@")
|
|
145
|
+
|
|
146
|
+
if [[ ${#parts[@]} -eq 0 ]]; then
|
|
147
|
+
echo ""
|
|
148
|
+
return
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
local result="${parts[0]}"
|
|
152
|
+
local current_width
|
|
153
|
+
current_width=$(visible_width "$result")
|
|
154
|
+
|
|
155
|
+
for ((i = 1; i < ${#parts[@]}; i++)); do
|
|
156
|
+
local part="${parts[$i]}"
|
|
157
|
+
if [[ -z "$part" ]]; then
|
|
158
|
+
continue
|
|
159
|
+
fi
|
|
160
|
+
local part_width
|
|
161
|
+
part_width=$(visible_width "$part")
|
|
162
|
+
if (( current_width + part_width <= max_width )); then
|
|
163
|
+
result+="$part"
|
|
164
|
+
(( current_width += part_width ))
|
|
165
|
+
fi
|
|
166
|
+
done
|
|
167
|
+
|
|
168
|
+
echo -e "$result"
|
|
169
|
+
}
|
|
170
|
+
|
|
111
171
|
# Calculate context window - show remaining free space
|
|
112
172
|
context_info=""
|
|
113
173
|
total_size=$(echo "$input" | jq -r '.context_window.context_window_size // 0')
|
|
@@ -212,6 +272,7 @@ if [[ "$total_size" -gt 0 && "$current_usage" != "null" ]]; then
|
|
|
212
272
|
fi
|
|
213
273
|
# Calculate delta
|
|
214
274
|
delta=$((used_tokens - prev_tokens))
|
|
275
|
+
# delta calculated for display
|
|
215
276
|
# Only show positive delta (and skip first run when no previous state)
|
|
216
277
|
if [[ "$has_prev" == "true" && "$delta" -gt 0 ]]; then
|
|
217
278
|
if [[ "$token_detail_enabled" == "true" ]]; then
|
|
@@ -238,4 +299,6 @@ if [[ "$show_session_enabled" == "true" && -n "$session_id" ]]; then
|
|
|
238
299
|
fi
|
|
239
300
|
|
|
240
301
|
# Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [S:session_id]
|
|
241
|
-
|
|
302
|
+
base="${DIM}[${model}]${RESET} ${BLUE}${dir_name}${RESET}"
|
|
303
|
+
max_width=$(get_terminal_width)
|
|
304
|
+
fit_to_width "$max_width" "$base" "$git_info" "$context_info" "$delta_info" "$ac_info" "$session_info"
|
|
@@ -14,6 +14,60 @@ MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name // "Claude"')
|
|
|
14
14
|
CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir // "~"')
|
|
15
15
|
DIR_NAME="${CURRENT_DIR##*/}"
|
|
16
16
|
|
|
17
|
+
# Width-fitting helpers
|
|
18
|
+
visible_width() {
|
|
19
|
+
local stripped
|
|
20
|
+
stripped=$(printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g')
|
|
21
|
+
printf '%s' "$stripped" | wc -m | tr -d ' '
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get_terminal_width() {
|
|
25
|
+
# When running inside Claude Code's statusline subprocess, $COLUMNS is not set
|
|
26
|
+
# and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200
|
|
27
|
+
# so no parts are dropped; Claude Code handles overflow.
|
|
28
|
+
if [[ -n "$COLUMNS" ]]; then
|
|
29
|
+
echo "$COLUMNS"
|
|
30
|
+
else
|
|
31
|
+
local cols
|
|
32
|
+
cols=$(tput cols 2>/dev/null || echo 80)
|
|
33
|
+
if [[ "$cols" -eq 80 ]]; then
|
|
34
|
+
echo 200
|
|
35
|
+
else
|
|
36
|
+
echo "$cols"
|
|
37
|
+
fi
|
|
38
|
+
fi
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fit_to_width() {
|
|
42
|
+
local max_width=$1
|
|
43
|
+
shift
|
|
44
|
+
local parts=("$@")
|
|
45
|
+
|
|
46
|
+
if [[ ${#parts[@]} -eq 0 ]]; then
|
|
47
|
+
echo ""
|
|
48
|
+
return
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
local result="${parts[0]}"
|
|
52
|
+
local current_width
|
|
53
|
+
current_width=$(visible_width "$result")
|
|
54
|
+
|
|
55
|
+
for ((i = 1; i < ${#parts[@]}; i++)); do
|
|
56
|
+
local part="${parts[$i]}"
|
|
57
|
+
if [[ -z "$part" ]]; then
|
|
58
|
+
continue
|
|
59
|
+
fi
|
|
60
|
+
local part_width
|
|
61
|
+
part_width=$(visible_width "$part")
|
|
62
|
+
if (( current_width + part_width <= max_width )); then
|
|
63
|
+
result+="$part"
|
|
64
|
+
(( current_width += part_width ))
|
|
65
|
+
fi
|
|
66
|
+
done
|
|
67
|
+
|
|
68
|
+
echo -e "$result"
|
|
69
|
+
}
|
|
70
|
+
|
|
17
71
|
# Git branch detection
|
|
18
72
|
GIT_INFO=""
|
|
19
73
|
if git -C "$CURRENT_DIR" rev-parse --git-dir > /dev/null 2>&1; then
|
|
@@ -29,4 +83,6 @@ if git -C "$CURRENT_DIR" rev-parse --git-dir > /dev/null 2>&1; then
|
|
|
29
83
|
fi
|
|
30
84
|
fi
|
|
31
85
|
|
|
32
|
-
|
|
86
|
+
base="[${MODEL_DISPLAY}] ${BLUE}${DIR_NAME}${RESET}"
|
|
87
|
+
max_width=$(get_terminal_width)
|
|
88
|
+
fit_to_width "$max_width" "$base" "$GIT_INFO"
|
|
@@ -8,4 +8,60 @@ MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name // "Claude"')
|
|
|
8
8
|
CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir // "~"')
|
|
9
9
|
DIR_NAME="${CURRENT_DIR##*/}"
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# Width-fitting helpers
|
|
12
|
+
visible_width() {
|
|
13
|
+
local stripped
|
|
14
|
+
stripped=$(printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g')
|
|
15
|
+
printf '%s' "$stripped" | wc -m | tr -d ' '
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get_terminal_width() {
|
|
19
|
+
# When running inside Claude Code's statusline subprocess, $COLUMNS is not set
|
|
20
|
+
# and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200
|
|
21
|
+
# so no parts are dropped; Claude Code handles overflow.
|
|
22
|
+
if [[ -n "$COLUMNS" ]]; then
|
|
23
|
+
echo "$COLUMNS"
|
|
24
|
+
else
|
|
25
|
+
local cols
|
|
26
|
+
cols=$(tput cols 2>/dev/null || echo 80)
|
|
27
|
+
if [[ "$cols" -eq 80 ]]; then
|
|
28
|
+
echo 200
|
|
29
|
+
else
|
|
30
|
+
echo "$cols"
|
|
31
|
+
fi
|
|
32
|
+
fi
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fit_to_width() {
|
|
36
|
+
local max_width=$1
|
|
37
|
+
shift
|
|
38
|
+
local parts=("$@")
|
|
39
|
+
|
|
40
|
+
if [[ ${#parts[@]} -eq 0 ]]; then
|
|
41
|
+
echo ""
|
|
42
|
+
return
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
local result="${parts[0]}"
|
|
46
|
+
local current_width
|
|
47
|
+
current_width=$(visible_width "$result")
|
|
48
|
+
|
|
49
|
+
for ((i = 1; i < ${#parts[@]}; i++)); do
|
|
50
|
+
local part="${parts[$i]}"
|
|
51
|
+
if [[ -z "$part" ]]; then
|
|
52
|
+
continue
|
|
53
|
+
fi
|
|
54
|
+
local part_width
|
|
55
|
+
part_width=$(visible_width "$part")
|
|
56
|
+
if (( current_width + part_width <= max_width )); then
|
|
57
|
+
result+="$part"
|
|
58
|
+
(( current_width += part_width ))
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
|
|
62
|
+
echo -e "$result"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
base="[$MODEL_DISPLAY] $DIR_NAME"
|
|
66
|
+
max_width=$(get_terminal_width)
|
|
67
|
+
fit_to_width "$max_width" "$base"
|
package/scripts/statusline.js
CHANGED
|
@@ -42,6 +42,49 @@ const RED = '\x1b[0;31m';
|
|
|
42
42
|
const DIM = '\x1b[2m';
|
|
43
43
|
const RESET = '\x1b[0m';
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Return the visible width of a string after stripping ANSI escape sequences.
|
|
47
|
+
*/
|
|
48
|
+
function visibleWidth(s) {
|
|
49
|
+
// eslint-disable-next-line no-control-regex
|
|
50
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return the terminal width in columns, defaulting to 80.
|
|
55
|
+
*/
|
|
56
|
+
function getTerminalWidth() {
|
|
57
|
+
return process.stdout.columns || parseInt(process.env.COLUMNS, 10) || 80;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Assemble parts into a single line that fits within maxWidth.
|
|
62
|
+
* Parts are added in priority order (first = highest priority).
|
|
63
|
+
* The first part (base) is always included.
|
|
64
|
+
*/
|
|
65
|
+
function fitToWidth(parts, maxWidth) {
|
|
66
|
+
if (!parts.length) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let result = parts[0];
|
|
71
|
+
let currentWidth = visibleWidth(result);
|
|
72
|
+
|
|
73
|
+
for (let i = 1; i < parts.length; i++) {
|
|
74
|
+
const part = parts[i];
|
|
75
|
+
if (!part) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const partWidth = visibleWidth(part);
|
|
79
|
+
if (currentWidth + partWidth <= maxWidth) {
|
|
80
|
+
result += part;
|
|
81
|
+
currentWidth += partWidth;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
function getGitInfo(projectDir) {
|
|
46
89
|
const gitDir = path.join(projectDir, '.git');
|
|
47
90
|
if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
|
|
@@ -84,6 +127,7 @@ function readConfig() {
|
|
|
84
127
|
showDelta: true,
|
|
85
128
|
showSession: true,
|
|
86
129
|
showIoTokens: true,
|
|
130
|
+
reducedMotion: false,
|
|
87
131
|
};
|
|
88
132
|
const configPath = path.join(os.homedir(), '.claude', 'statusline.conf');
|
|
89
133
|
|
|
@@ -134,6 +178,8 @@ show_session=true
|
|
|
134
178
|
config.showSession = valueTrimmed !== 'false';
|
|
135
179
|
} else if (keyTrimmed === 'show_io_tokens') {
|
|
136
180
|
config.showIoTokens = valueTrimmed !== 'false';
|
|
181
|
+
} else if (keyTrimmed === 'reduced_motion') {
|
|
182
|
+
config.reducedMotion = valueTrimmed !== 'false';
|
|
137
183
|
}
|
|
138
184
|
}
|
|
139
185
|
} catch {
|
|
@@ -343,8 +389,9 @@ process.stdin.on('end', () => {
|
|
|
343
389
|
sessionInfo = ` ${DIM}${sessionId}${RESET}`;
|
|
344
390
|
}
|
|
345
391
|
|
|
346
|
-
// Output: [Model]
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
392
|
+
// Output: [Model] dir | branch [n] | free (%) [+delta] [AC] session
|
|
393
|
+
const base = `${DIM}[${model}]${RESET} ${BLUE}${dirName}${RESET}`;
|
|
394
|
+
const maxWidth = getTerminalWidth();
|
|
395
|
+
const parts = [base, gitInfo, contextInfo, deltaInfo, acInfo, sessionInfo];
|
|
396
|
+
console.log(fitToWidth(parts, maxWidth));
|
|
350
397
|
});
|
package/scripts/statusline.py
CHANGED
|
@@ -26,6 +26,8 @@ State file format (CSV):
|
|
|
26
26
|
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
|
+
import re
|
|
30
|
+
import shutil
|
|
29
31
|
import subprocess
|
|
30
32
|
import sys
|
|
31
33
|
|
|
@@ -39,6 +41,57 @@ RED = "\033[0;31m"
|
|
|
39
41
|
DIM = "\033[2m"
|
|
40
42
|
RESET = "\033[0m"
|
|
41
43
|
|
|
44
|
+
# Pattern to strip ANSI escape sequences
|
|
45
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def visible_width(s):
|
|
49
|
+
"""Return the visible width of a string after stripping ANSI escape sequences."""
|
|
50
|
+
return len(_ANSI_RE.sub("", s))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_terminal_width():
|
|
54
|
+
"""Return the terminal width in columns.
|
|
55
|
+
|
|
56
|
+
When running inside Claude Code's statusline subprocess, neither $COLUMNS
|
|
57
|
+
nor tput/shutil can detect the real terminal width (they always return 80).
|
|
58
|
+
If COLUMNS is not explicitly set and shutil falls back to 80, we use a
|
|
59
|
+
generous default of 200 so that no parts are unnecessarily dropped;
|
|
60
|
+
Claude Code's own UI handles any overflow/truncation.
|
|
61
|
+
"""
|
|
62
|
+
# If COLUMNS is explicitly set, trust it (real terminal or test override)
|
|
63
|
+
if os.environ.get("COLUMNS"):
|
|
64
|
+
return shutil.get_terminal_size().columns
|
|
65
|
+
# No COLUMNS env var — likely a Claude Code subprocess with no real TTY.
|
|
66
|
+
# shutil will fall back to 80, which is too narrow. Use 200 instead.
|
|
67
|
+
cols = shutil.get_terminal_size(fallback=(200, 24)).columns
|
|
68
|
+
return 200 if cols == 80 else cols
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fit_to_width(parts, max_width):
|
|
72
|
+
"""Assemble parts into a single line that fits within max_width.
|
|
73
|
+
|
|
74
|
+
Parts are added in priority order (first = highest priority).
|
|
75
|
+
The first part (base) is always included. Subsequent parts are
|
|
76
|
+
included only if adding them does not exceed max_width.
|
|
77
|
+
Empty parts are skipped.
|
|
78
|
+
"""
|
|
79
|
+
if not parts:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
result = parts[0]
|
|
83
|
+
current_width = visible_width(result)
|
|
84
|
+
|
|
85
|
+
for part in parts[1:]:
|
|
86
|
+
if not part:
|
|
87
|
+
continue
|
|
88
|
+
part_width = visible_width(part)
|
|
89
|
+
if current_width + part_width <= max_width:
|
|
90
|
+
result += part
|
|
91
|
+
current_width += part_width
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
42
95
|
|
|
43
96
|
def get_git_info(project_dir):
|
|
44
97
|
"""Get git branch and change count"""
|
|
@@ -303,9 +356,10 @@ def main():
|
|
|
303
356
|
session_info = f" {DIM}{session_id}{RESET}"
|
|
304
357
|
|
|
305
358
|
# Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [S:session_id]
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
359
|
+
base = f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
|
|
360
|
+
max_width = get_terminal_width()
|
|
361
|
+
parts = [base, git_info, context_info, delta_info, ac_info, session_info]
|
|
362
|
+
print(fit_to_width(parts, max_width))
|
|
309
363
|
|
|
310
364
|
|
|
311
365
|
if __name__ == "__main__":
|