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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +36 -1
  2. package/.github/workflows/ci.yml +4 -2
  3. package/.github/workflows/release.yml +3 -1
  4. package/CHANGELOG.md +17 -0
  5. package/README.md +33 -11
  6. package/RELEASE_NOTES.md +10 -0
  7. package/assets/logo/favicon.svg +19 -0
  8. package/assets/logo/logo-black.svg +24 -0
  9. package/assets/logo/logo-full.svg +40 -0
  10. package/assets/logo/logo-icon.svg +27 -0
  11. package/assets/logo/logo-mark.svg +28 -0
  12. package/assets/logo/logo-white.svg +24 -0
  13. package/assets/logo/logo-wordmark.svg +6 -0
  14. package/install.sh +21 -2
  15. package/package.json +7 -3
  16. package/pyproject.toml +6 -4
  17. package/scripts/context-stats.sh +194 -26
  18. package/scripts/statusline-full.sh +65 -2
  19. package/scripts/statusline-git.sh +57 -1
  20. package/scripts/statusline-minimal.sh +57 -1
  21. package/scripts/statusline.js +51 -4
  22. package/scripts/statusline.py +57 -3
  23. package/src/claude_statusline/__init__.py +1 -1
  24. package/src/claude_statusline/cli/context_stats.py +106 -34
  25. package/src/claude_statusline/cli/statusline.py +5 -4
  26. package/src/claude_statusline/core/config.py +7 -0
  27. package/src/claude_statusline/formatters/layout.py +67 -0
  28. package/src/claude_statusline/graphs/renderer.py +44 -24
  29. package/src/claude_statusline/graphs/statistics.py +34 -0
  30. package/src/claude_statusline/ui/__init__.py +1 -0
  31. package/src/claude_statusline/ui/icons.py +93 -0
  32. package/src/claude_statusline/ui/waiting.py +62 -0
  33. package/tests/bash/test_statusline_full.bats +30 -0
  34. package/tests/node/statusline.test.js +44 -3
  35. package/tests/python/test_icons.py +152 -0
  36. package/tests/python/test_layout.py +127 -0
  37. package/tests/python/test_statusline.py +64 -3
  38. package/tests/python/test_waiting.py +127 -0
  39. package/PUBLISHING_GUIDE.md +0 -69
  40. package/npm-publish.sh +0 -33
  41. package/publish.sh +0 -24
  42. package/show_raw_claude_code_api.js +0 -11
@@ -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
- # Show watch mode indicator with live timestamp
904
- local current_time
905
- current_time=$(date +%H:%M:%S)
906
- echo -e "${DIM}[LIVE ${current_time}] Refresh: ${WATCH_INTERVAL}s | Ctrl+C to exit${RESET}"
907
-
908
- # Handle case where state_file is empty (no sessions found at all)
909
- if [ -z "$state_file" ]; then
910
- show_waiting_message "" "Waiting for session data..."
911
- # Re-validate and render (file might have new data)
912
- elif [ -f "$state_file" ]; then
913
- local line_count
914
- line_count=$(wc -l <"$state_file" | tr -d ' ')
915
- if [ "$line_count" -ge 2 ]; then
916
- render_once "$state_file"
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
- show_waiting_message "$SESSION_ID" "Waiting for more data points..."
919
- echo -e " ${DIM}Current: ${line_count} point(s), need at least 2${RESET}"
1088
+ # File doesn't exist yet (new session)
1089
+ show_waiting_message "$SESSION_ID" "Waiting for session data..."
920
1090
  fi
921
- else
922
- # File doesn't exist yet (new session)
923
- show_waiting_message "$SESSION_ID" "Waiting for session data..."
924
- fi
1091
+ )
925
1092
 
926
- # Clear any remaining lines from previous render (in case terminal resized smaller)
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
- echo -e "${DIM}[${model}]${RESET} ${BLUE}${dir_name}${RESET}${git_info}${context_info}${delta_info}${ac_info}${session_info}"
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
- echo -e "[${MODEL_DISPLAY}] ${BLUE}${DIR_NAME}${RESET}${GIT_INFO}"
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
- echo "[$MODEL_DISPLAY] $DIR_NAME"
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"
@@ -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] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [S:session_id]
347
- console.log(
348
- `${DIM}[${model}]${RESET} ${BLUE}${dirName}${RESET}${gitInfo}${contextInfo}${deltaInfo}${acInfo}${sessionInfo}`
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
  });
@@ -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
- print(
307
- f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}{git_info}{context_info}{delta_info}{ac_info}{session_info}"
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__":
@@ -3,7 +3,7 @@
3
3
  Never run out of context unexpectedly - monitor your session context in real-time.
4
4
  """
5
5
 
6
- __version__ = "1.2.3"
6
+ __version__ = "1.5.1"
7
7
 
8
8
  from claude_statusline.core.config import Config
9
9
  from claude_statusline.core.state import StateFile