cc-context-stats 1.3.0 → 1.5.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.
Files changed (41) hide show
  1. package/.claude/settings.local.json +33 -1
  2. package/.github/workflows/ci.yml +4 -2
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +33 -11
  5. package/RELEASE_NOTES.md +9 -0
  6. package/assets/logo/favicon.svg +16 -0
  7. package/assets/logo/logo-black.svg +23 -0
  8. package/assets/logo/logo-full.svg +30 -0
  9. package/assets/logo/logo-icon.svg +26 -0
  10. package/assets/logo/logo-mark.svg +26 -0
  11. package/assets/logo/logo-white.svg +23 -0
  12. package/assets/logo/logo-wordmark.svg +7 -0
  13. package/install.sh +21 -2
  14. package/package.json +7 -3
  15. package/pyproject.toml +6 -4
  16. package/scripts/context-stats.sh +194 -26
  17. package/scripts/statusline-full.sh +57 -2
  18. package/scripts/statusline-git.sh +50 -1
  19. package/scripts/statusline-minimal.sh +50 -1
  20. package/scripts/statusline.js +50 -4
  21. package/scripts/statusline.py +44 -3
  22. package/src/claude_statusline/__init__.py +1 -1
  23. package/src/claude_statusline/cli/context_stats.py +106 -34
  24. package/src/claude_statusline/cli/statusline.py +5 -4
  25. package/src/claude_statusline/core/config.py +7 -0
  26. package/src/claude_statusline/formatters/layout.py +52 -0
  27. package/src/claude_statusline/graphs/renderer.py +44 -24
  28. package/src/claude_statusline/graphs/statistics.py +34 -0
  29. package/src/claude_statusline/ui/__init__.py +1 -0
  30. package/src/claude_statusline/ui/icons.py +93 -0
  31. package/src/claude_statusline/ui/waiting.py +62 -0
  32. package/tests/bash/test_statusline_full.bats +30 -0
  33. package/tests/node/statusline.test.js +44 -3
  34. package/tests/python/test_icons.py +152 -0
  35. package/tests/python/test_layout.py +110 -0
  36. package/tests/python/test_statusline.py +62 -3
  37. package/tests/python/test_waiting.py +127 -0
  38. package/PUBLISHING_GUIDE.md +0 -69
  39. package/npm-publish.sh +0 -33
  40. package/publish.sh +0 -24
  41. 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,61 @@ 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, fallback to 80
120
+ if [[ -n "$COLUMNS" ]]; then
121
+ echo "$COLUMNS"
122
+ else
123
+ local cols
124
+ cols=$(tput cols 2>/dev/null || echo 80)
125
+ echo "$cols"
126
+ fi
127
+ }
128
+
129
+ fit_to_width() {
130
+ # Assemble parts into a single line that fits within max_width.
131
+ # Usage: fit_to_width max_width part1 part2 part3 ...
132
+ # First part (base) is always included. Subsequent parts are
133
+ # included only if adding them does not exceed max_width.
134
+ local max_width=$1
135
+ shift
136
+ local parts=("$@")
137
+
138
+ if [[ ${#parts[@]} -eq 0 ]]; then
139
+ echo ""
140
+ return
141
+ fi
142
+
143
+ local result="${parts[0]}"
144
+ local current_width
145
+ current_width=$(visible_width "$result")
146
+
147
+ for ((i = 1; i < ${#parts[@]}; i++)); do
148
+ local part="${parts[$i]}"
149
+ if [[ -z "$part" ]]; then
150
+ continue
151
+ fi
152
+ local part_width
153
+ part_width=$(visible_width "$part")
154
+ if (( current_width + part_width <= max_width )); then
155
+ result+="$part"
156
+ (( current_width += part_width ))
157
+ fi
158
+ done
159
+
160
+ echo -e "$result"
161
+ }
162
+
111
163
  # Calculate context window - show remaining free space
112
164
  context_info=""
113
165
  total_size=$(echo "$input" | jq -r '.context_window.context_window_size // 0')
@@ -212,6 +264,7 @@ if [[ "$total_size" -gt 0 && "$current_usage" != "null" ]]; then
212
264
  fi
213
265
  # Calculate delta
214
266
  delta=$((used_tokens - prev_tokens))
267
+ # delta calculated for display
215
268
  # Only show positive delta (and skip first run when no previous state)
216
269
  if [[ "$has_prev" == "true" && "$delta" -gt 0 ]]; then
217
270
  if [[ "$token_detail_enabled" == "true" ]]; then
@@ -238,4 +291,6 @@ if [[ "$show_session_enabled" == "true" && -n "$session_id" ]]; then
238
291
  fi
239
292
 
240
293
  # 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}"
294
+ base="${DIM}[${model}]${RESET} ${BLUE}${dir_name}${RESET}"
295
+ max_width=$(get_terminal_width)
296
+ fit_to_width "$max_width" "$base" "$git_info" "$context_info" "$delta_info" "$ac_info" "$session_info"
@@ -14,6 +14,53 @@ 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
+ if [[ -n "$COLUMNS" ]]; then
26
+ echo "$COLUMNS"
27
+ else
28
+ local cols
29
+ cols=$(tput cols 2>/dev/null || echo 80)
30
+ echo "$cols"
31
+ fi
32
+ }
33
+
34
+ fit_to_width() {
35
+ local max_width=$1
36
+ shift
37
+ local parts=("$@")
38
+
39
+ if [[ ${#parts[@]} -eq 0 ]]; then
40
+ echo ""
41
+ return
42
+ fi
43
+
44
+ local result="${parts[0]}"
45
+ local current_width
46
+ current_width=$(visible_width "$result")
47
+
48
+ for ((i = 1; i < ${#parts[@]}; i++)); do
49
+ local part="${parts[$i]}"
50
+ if [[ -z "$part" ]]; then
51
+ continue
52
+ fi
53
+ local part_width
54
+ part_width=$(visible_width "$part")
55
+ if (( current_width + part_width <= max_width )); then
56
+ result+="$part"
57
+ (( current_width += part_width ))
58
+ fi
59
+ done
60
+
61
+ echo -e "$result"
62
+ }
63
+
17
64
  # Git branch detection
18
65
  GIT_INFO=""
19
66
  if git -C "$CURRENT_DIR" rev-parse --git-dir > /dev/null 2>&1; then
@@ -29,4 +76,6 @@ if git -C "$CURRENT_DIR" rev-parse --git-dir > /dev/null 2>&1; then
29
76
  fi
30
77
  fi
31
78
 
32
- echo -e "[${MODEL_DISPLAY}] ${BLUE}${DIR_NAME}${RESET}${GIT_INFO}"
79
+ base="[${MODEL_DISPLAY}] ${BLUE}${DIR_NAME}${RESET}"
80
+ max_width=$(get_terminal_width)
81
+ fit_to_width "$max_width" "$base" "$GIT_INFO"
@@ -8,4 +8,53 @@ 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
+ if [[ -n "$COLUMNS" ]]; then
20
+ echo "$COLUMNS"
21
+ else
22
+ local cols
23
+ cols=$(tput cols 2>/dev/null || echo 80)
24
+ echo "$cols"
25
+ fi
26
+ }
27
+
28
+ fit_to_width() {
29
+ local max_width=$1
30
+ shift
31
+ local parts=("$@")
32
+
33
+ if [[ ${#parts[@]} -eq 0 ]]; then
34
+ echo ""
35
+ return
36
+ fi
37
+
38
+ local result="${parts[0]}"
39
+ local current_width
40
+ current_width=$(visible_width "$result")
41
+
42
+ for ((i = 1; i < ${#parts[@]}; i++)); do
43
+ local part="${parts[$i]}"
44
+ if [[ -z "$part" ]]; then
45
+ continue
46
+ fi
47
+ local part_width
48
+ part_width=$(visible_width "$part")
49
+ if (( current_width + part_width <= max_width )); then
50
+ result+="$part"
51
+ (( current_width += part_width ))
52
+ fi
53
+ done
54
+
55
+ echo -e "$result"
56
+ }
57
+
58
+ base="[$MODEL_DISPLAY] $DIR_NAME"
59
+ max_width=$(get_terminal_width)
60
+ fit_to_width "$max_width" "$base"
@@ -42,6 +42,48 @@ 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
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
50
+ }
51
+
52
+ /**
53
+ * Return the terminal width in columns, defaulting to 80.
54
+ */
55
+ function getTerminalWidth() {
56
+ return process.stdout.columns || parseInt(process.env.COLUMNS, 10) || 80;
57
+ }
58
+
59
+ /**
60
+ * Assemble parts into a single line that fits within maxWidth.
61
+ * Parts are added in priority order (first = highest priority).
62
+ * The first part (base) is always included.
63
+ */
64
+ function fitToWidth(parts, maxWidth) {
65
+ if (!parts.length) {
66
+ return '';
67
+ }
68
+
69
+ let result = parts[0];
70
+ let currentWidth = visibleWidth(result);
71
+
72
+ for (let i = 1; i < parts.length; i++) {
73
+ const part = parts[i];
74
+ if (!part) {
75
+ continue;
76
+ }
77
+ const partWidth = visibleWidth(part);
78
+ if (currentWidth + partWidth <= maxWidth) {
79
+ result += part;
80
+ currentWidth += partWidth;
81
+ }
82
+ }
83
+
84
+ return result;
85
+ }
86
+
45
87
  function getGitInfo(projectDir) {
46
88
  const gitDir = path.join(projectDir, '.git');
47
89
  if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
@@ -84,6 +126,7 @@ function readConfig() {
84
126
  showDelta: true,
85
127
  showSession: true,
86
128
  showIoTokens: true,
129
+ reducedMotion: false,
87
130
  };
88
131
  const configPath = path.join(os.homedir(), '.claude', 'statusline.conf');
89
132
 
@@ -134,6 +177,8 @@ show_session=true
134
177
  config.showSession = valueTrimmed !== 'false';
135
178
  } else if (keyTrimmed === 'show_io_tokens') {
136
179
  config.showIoTokens = valueTrimmed !== 'false';
180
+ } else if (keyTrimmed === 'reduced_motion') {
181
+ config.reducedMotion = valueTrimmed !== 'false';
137
182
  }
138
183
  }
139
184
  } catch {
@@ -343,8 +388,9 @@ process.stdin.on('end', () => {
343
388
  sessionInfo = ` ${DIM}${sessionId}${RESET}`;
344
389
  }
345
390
 
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
- );
391
+ // Output: [Model] dir | branch [n] | free (%) [+delta] [AC] session
392
+ const base = `${DIM}[${model}]${RESET} ${BLUE}${dirName}${RESET}`;
393
+ const maxWidth = getTerminalWidth();
394
+ const parts = [base, gitInfo, contextInfo, deltaInfo, acInfo, sessionInfo];
395
+ console.log(fitToWidth(parts, maxWidth));
350
396
  });
@@ -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,44 @@ 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, defaulting to 80."""
55
+ return shutil.get_terminal_size().columns
56
+
57
+
58
+ def fit_to_width(parts, max_width):
59
+ """Assemble parts into a single line that fits within max_width.
60
+
61
+ Parts are added in priority order (first = highest priority).
62
+ The first part (base) is always included. Subsequent parts are
63
+ included only if adding them does not exceed max_width.
64
+ Empty parts are skipped.
65
+ """
66
+ if not parts:
67
+ return ""
68
+
69
+ result = parts[0]
70
+ current_width = visible_width(result)
71
+
72
+ for part in parts[1:]:
73
+ if not part:
74
+ continue
75
+ part_width = visible_width(part)
76
+ if current_width + part_width <= max_width:
77
+ result += part
78
+ current_width += part_width
79
+
80
+ return result
81
+
42
82
 
43
83
  def get_git_info(project_dir):
44
84
  """Get git branch and change count"""
@@ -303,9 +343,10 @@ def main():
303
343
  session_info = f" {DIM}{session_id}{RESET}"
304
344
 
305
345
  # 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
- )
346
+ base = f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
347
+ max_width = get_terminal_width()
348
+ parts = [base, git_info, context_info, delta_info, ac_info, session_info]
349
+ print(fit_to_width(parts, max_width))
309
350
 
310
351
 
311
352
  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.0"
7
7
 
8
8
  from claude_statusline.core.config import Config
9
9
  from claude_statusline.core.state import StateFile