cc-context-stats 1.6.1 → 1.7.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.
@@ -21,7 +21,10 @@ Create/edit ~/.claude/statusline.conf and set:
21
21
  When AC is enabled, 22.5% of context window is reserved for autocompact buffer.
22
22
 
23
23
  State file format (CSV):
24
- timestamp,total_input_tokens,total_output_tokens,current_usage_input_tokens,current_usage_output_tokens,current_usage_cache_creation,current_usage_cache_read,total_cost_usd,total_lines_added,total_lines_removed,session_id,model_id,workspace_project_dir
24
+ timestamp,total_input_tokens,total_output_tokens,current_usage_input_tokens,
25
+ current_usage_output_tokens,current_usage_cache_creation,current_usage_cache_read,
26
+ total_cost_usd,total_lines_added,total_lines_removed,session_id,model_id,
27
+ workspace_project_dir,context_window_size
25
28
  """
26
29
 
27
30
  import json
@@ -30,8 +33,41 @@ import re
30
33
  import shutil
31
34
  import subprocess
32
35
  import sys
36
+ import tempfile
33
37
 
34
- # ANSI Colors
38
+ ROTATION_THRESHOLD = 10_000
39
+ ROTATION_KEEP = 5_000
40
+
41
+
42
+ def maybe_rotate_state_file(state_file):
43
+ """Rotate a state file if it exceeds ROTATION_THRESHOLD lines.
44
+
45
+ Keeps the most recent ROTATION_KEEP lines via atomic temp-file + rename.
46
+ """
47
+ try:
48
+ if not os.path.exists(state_file):
49
+ return
50
+ with open(state_file) as f:
51
+ lines = f.readlines()
52
+ if len(lines) <= ROTATION_THRESHOLD:
53
+ return
54
+ keep = lines[-ROTATION_KEEP:]
55
+ fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(state_file), suffix=".tmp")
56
+ try:
57
+ with os.fdopen(fd, "w") as tmp_f:
58
+ tmp_f.writelines(keep)
59
+ os.replace(tmp_path, state_file)
60
+ except BaseException:
61
+ try:
62
+ os.unlink(tmp_path)
63
+ except OSError:
64
+ pass
65
+ raise
66
+ except OSError as e:
67
+ sys.stderr.write(f"[statusline] warning: failed to rotate state file: {e}\n")
68
+
69
+
70
+ # ANSI Colors (defaults, overridable via config)
35
71
  BLUE = "\033[0;34m"
36
72
  MAGENTA = "\033[0;35m"
37
73
  CYAN = "\033[0;36m"
@@ -41,6 +77,48 @@ RED = "\033[0;31m"
41
77
  DIM = "\033[2m"
42
78
  RESET = "\033[0m"
43
79
 
80
+ # Named colors for config parsing
81
+ _COLOR_NAMES = {
82
+ "black": "\033[0;30m",
83
+ "red": "\033[0;31m",
84
+ "green": "\033[0;32m",
85
+ "yellow": "\033[0;33m",
86
+ "blue": "\033[0;34m",
87
+ "magenta": "\033[0;35m",
88
+ "cyan": "\033[0;36m",
89
+ "white": "\033[0;37m",
90
+ "bright_black": "\033[0;90m",
91
+ "bright_red": "\033[0;91m",
92
+ "bright_green": "\033[0;92m",
93
+ "bright_yellow": "\033[0;93m",
94
+ "bright_blue": "\033[0;94m",
95
+ "bright_magenta": "\033[0;95m",
96
+ "bright_cyan": "\033[0;96m",
97
+ "bright_white": "\033[0;97m",
98
+ }
99
+
100
+
101
+ def _parse_color(value):
102
+ """Parse a color name or #rrggbb hex into an ANSI escape code."""
103
+ value = value.strip().lower()
104
+ if value in _COLOR_NAMES:
105
+ return _COLOR_NAMES[value]
106
+ if re.match(r"^#[0-9a-f]{6}$", value):
107
+ r, g, b = int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16)
108
+ return f"\033[38;2;{r};{g};{b}m"
109
+ return None
110
+
111
+
112
+ # Color config keys and which color slot they map to
113
+ _COLOR_KEYS = {
114
+ "color_green": "green",
115
+ "color_yellow": "yellow",
116
+ "color_red": "red",
117
+ "color_blue": "blue",
118
+ "color_magenta": "magenta",
119
+ "color_cyan": "cyan",
120
+ }
121
+
44
122
  # Pattern to strip ANSI escape sequences
45
123
  _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
46
124
 
@@ -93,8 +171,12 @@ def fit_to_width(parts, max_width):
93
171
  return result
94
172
 
95
173
 
96
- def get_git_info(project_dir):
174
+ def get_git_info(project_dir, magenta=None, cyan=None):
97
175
  """Get git branch and change count"""
176
+ if magenta is None:
177
+ magenta = MAGENTA
178
+ if cyan is None:
179
+ cyan = CYAN
98
180
  git_dir = os.path.join(project_dir, ".git")
99
181
  if not os.path.isdir(git_dir):
100
182
  return ""
@@ -106,6 +188,7 @@ def get_git_info(project_dir):
106
188
  cwd=project_dir,
107
189
  capture_output=True,
108
190
  text=True,
191
+ timeout=5,
109
192
  )
110
193
  branch = result.stdout.strip()
111
194
 
@@ -118,13 +201,14 @@ def get_git_info(project_dir):
118
201
  cwd=project_dir,
119
202
  capture_output=True,
120
203
  text=True,
204
+ timeout=5,
121
205
  )
122
206
  changes = len([line for line in result.stdout.split("\n") if line.strip()])
123
207
 
124
208
  if changes > 0:
125
- return f" | {MAGENTA}{branch}{RESET} {CYAN}[{changes}]{RESET}"
126
- return f" | {MAGENTA}{branch}{RESET}"
127
- except Exception:
209
+ return f" | {magenta}{branch}{RESET} {cyan}[{changes}]{RESET}"
210
+ return f" | {magenta}{branch}{RESET}"
211
+ except (subprocess.TimeoutExpired, OSError):
128
212
  return ""
129
213
 
130
214
 
@@ -136,6 +220,7 @@ def read_config():
136
220
  "show_delta": True,
137
221
  "show_session": True,
138
222
  "show_io_tokens": True,
223
+ "colors": {},
139
224
  }
140
225
  config_path = os.path.expanduser("~/.claude/statusline.conf")
141
226
 
@@ -157,6 +242,12 @@ show_delta=true
157
242
 
158
243
  # Show session_id in status line
159
244
  show_session=true
245
+
246
+ # Custom colors - use named colors or hex (#rrggbb)
247
+ # Available: color_green, color_yellow, color_red, color_blue, color_magenta, color_cyan
248
+ # Examples:
249
+ # color_green=#7dcfff
250
+ # color_red=#f7768e
160
251
  """
161
252
  )
162
253
  except Exception as e:
@@ -171,17 +262,22 @@ show_session=true
171
262
  continue
172
263
  key, value = line.split("=", 1)
173
264
  key = key.strip()
174
- value = value.strip().lower()
265
+ raw_value = value.strip()
266
+ value_lower = raw_value.lower()
175
267
  if key == "autocompact":
176
- config["autocompact"] = value != "false"
268
+ config["autocompact"] = value_lower != "false"
177
269
  elif key == "token_detail":
178
- config["token_detail"] = value != "false"
270
+ config["token_detail"] = value_lower != "false"
179
271
  elif key == "show_delta":
180
- config["show_delta"] = value != "false"
272
+ config["show_delta"] = value_lower != "false"
181
273
  elif key == "show_session":
182
- config["show_session"] = value != "false"
274
+ config["show_session"] = value_lower != "false"
183
275
  elif key == "show_io_tokens":
184
- config["show_io_tokens"] = value != "false"
276
+ config["show_io_tokens"] = value_lower != "false"
277
+ elif key in _COLOR_KEYS:
278
+ ansi = _parse_color(raw_value)
279
+ if ansi:
280
+ config["colors"][_COLOR_KEYS[key]] = ansi
185
281
  except (OSError, UnicodeDecodeError) as e:
186
282
  sys.stderr.write(f"[statusline] warning: failed to read config: {e}\n")
187
283
  return config
@@ -200,9 +296,6 @@ def main():
200
296
  model = data.get("model", {}).get("display_name", "Claude")
201
297
  dir_name = os.path.basename(cwd) or "~"
202
298
 
203
- # Git info
204
- git_info = get_git_info(project_dir)
205
-
206
299
  # Read settings from config file
207
300
  config = read_config()
208
301
  autocompact_enabled = config["autocompact"]
@@ -211,6 +304,18 @@ def main():
211
304
  show_session = config["show_session"]
212
305
  # Note: show_io_tokens setting is read but not yet implemented
213
306
 
307
+ # Apply color overrides from config
308
+ c = config.get("colors", {})
309
+ c_green = c.get("green", GREEN)
310
+ c_yellow = c.get("yellow", YELLOW)
311
+ c_red = c.get("red", RED)
312
+ c_blue = c.get("blue", BLUE)
313
+ c_magenta = c.get("magenta", MAGENTA)
314
+ c_cyan = c.get("cyan", CYAN)
315
+
316
+ # Git info (pass configurable colors)
317
+ git_info = get_git_info(project_dir, magenta=c_magenta, cyan=c_cyan)
318
+
214
319
  # Extract session_id once for reuse
215
320
  session_id = data.get("session_id")
216
321
 
@@ -267,11 +372,11 @@ def main():
267
372
 
268
373
  # Color based on free percentage
269
374
  if free_pct_int > 50:
270
- ctx_color = GREEN
375
+ ctx_color = c_green
271
376
  elif free_pct_int > 25:
272
- ctx_color = YELLOW
377
+ ctx_color = c_yellow
273
378
  else:
274
- ctx_color = RED
379
+ ctx_color = c_red
275
380
 
276
381
  context_info = f" | {ctx_color}{free_display} free ({free_pct:.1f}%){RESET}"
277
382
 
@@ -302,19 +407,27 @@ def main():
302
407
  try:
303
408
  if os.path.exists(state_file):
304
409
  has_prev = True
305
- # Read last line to get previous token count
410
+ # Read last line to get previous context usage
306
411
  with open(state_file) as f:
307
412
  lines = f.readlines()
308
413
  if lines:
309
414
  last_line = lines[-1].strip()
310
415
  if "," in last_line:
311
- prev_tokens = int(last_line.split(",")[1])
416
+ parts = last_line.split(",")
417
+ # Calculate previous context usage:
418
+ # cur_input + cache_creation + cache_read
419
+ # CSV indices: cur_in[3], cache_create[5], cache_read[6]
420
+ prev_cur_input = int(parts[3]) if len(parts) > 3 else 0
421
+ prev_cache_creation = int(parts[5]) if len(parts) > 5 else 0
422
+ prev_cache_read = int(parts[6]) if len(parts) > 6 else 0
423
+ prev_tokens = prev_cur_input + prev_cache_creation + prev_cache_read
312
424
  else:
425
+ # Old format - single value
313
426
  prev_tokens = int(last_line or 0)
314
- except Exception as e:
427
+ except (OSError, ValueError) as e:
315
428
  sys.stderr.write(f"[statusline] warning: failed to read state file: {e}\n")
316
429
  prev_tokens = 0
317
- # Calculate delta
430
+ # Calculate delta (difference in context window usage)
318
431
  delta = used_tokens - prev_tokens
319
432
  # Only show positive delta (and skip first run when no previous state)
320
433
  if has_prev and delta > 0:
@@ -323,41 +436,46 @@ def main():
323
436
  else:
324
437
  delta_display = f"{delta / 1000:.1f}k"
325
438
  delta_info = f" {DIM}[+{delta_display}]{RESET}"
326
- # Append current usage with comprehensive format
327
- # Format: timestamp,total_input_tokens,total_output_tokens,current_usage_input_tokens,current_usage_output_tokens,current_usage_cache_creation,current_usage_cache_read,total_cost_usd,total_lines_added,total_lines_removed,session_id,model_id,workspace_project_dir
328
- try:
329
- cur_input_tokens = current_usage.get("input_tokens", 0)
330
- cur_output_tokens = current_usage.get("output_tokens", 0)
331
- state_data = ",".join(
332
- str(x)
333
- for x in [
334
- int(time.time()),
335
- total_input_tokens,
336
- total_output_tokens,
337
- cur_input_tokens,
338
- cur_output_tokens,
339
- cache_creation,
340
- cache_read,
341
- cost_usd,
342
- lines_added,
343
- lines_removed,
344
- session_id or "",
345
- model_id,
346
- workspace_project_dir.replace(",", "_"),
347
- total_size,
348
- ]
349
- )
350
- with open(state_file, "a") as f:
351
- f.write(f"{state_data}\n")
352
- except Exception as e:
353
- sys.stderr.write(f"[statusline] warning: failed to write state file: {e}\n")
439
+ # Only append if context usage changed (avoid duplicates from multiple refreshes)
440
+ if not has_prev or used_tokens != prev_tokens:
441
+ # Append current usage with comprehensive format
442
+ # Format: ts,total_in,total_out,cur_in,cur_out,cache_create,cache_read,
443
+ # cost_usd,lines_added,lines_removed,session_id,model_id,project_dir,
444
+ # context_window_size
445
+ try:
446
+ cur_input_tokens = current_usage.get("input_tokens", 0)
447
+ cur_output_tokens = current_usage.get("output_tokens", 0)
448
+ state_data = ",".join(
449
+ str(x)
450
+ for x in [
451
+ int(time.time()),
452
+ total_input_tokens,
453
+ total_output_tokens,
454
+ cur_input_tokens,
455
+ cur_output_tokens,
456
+ cache_creation,
457
+ cache_read,
458
+ cost_usd,
459
+ lines_added,
460
+ lines_removed,
461
+ session_id or "",
462
+ model_id,
463
+ workspace_project_dir.replace(",", "_"),
464
+ total_size,
465
+ ]
466
+ )
467
+ with open(state_file, "a") as f:
468
+ f.write(f"{state_data}\n")
469
+ maybe_rotate_state_file(state_file)
470
+ except OSError as e:
471
+ sys.stderr.write(f"[statusline] warning: failed to write state file: {e}\n")
354
472
 
355
473
  # Display session_id if enabled
356
474
  if show_session and session_id:
357
475
  session_info = f" {DIM}{session_id}{RESET}"
358
476
 
359
477
  # Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [S:session_id]
360
- base = f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
478
+ base = f"{DIM}[{model}]{RESET} {c_blue}{dir_name}{RESET}"
361
479
  max_width = get_terminal_width()
362
480
  parts = [base, git_info, context_info, delta_info, ac_info, session_info]
363
481
  print(fit_to_width(parts, max_width))
@@ -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.6.1"
6
+ __version__ = "1.7.0"
7
7
 
8
8
  from claude_statusline.core.config import Config
9
9
  from claude_statusline.core.state import StateFile
@@ -46,10 +46,14 @@ def show_help() -> None:
46
46
 
47
47
  USAGE:
48
48
  context-stats [session_id] [options]
49
+ context-stats explain
49
50
 
50
51
  ARGUMENTS:
51
52
  session_id Optional session ID. If not provided, uses the latest session.
52
53
 
54
+ COMMANDS:
55
+ explain Diagnostic dump of Claude Code's JSON context (pipe JSON to stdin)
56
+
53
57
  OPTIONS:
54
58
  --type <type> Graph type to display:
55
59
  - delta: Context growth per interaction (default)
@@ -89,6 +93,9 @@ EXAMPLES:
89
93
  # Output to file (no colors, single run)
90
94
  context-stats --no-watch --no-color > output.txt
91
95
 
96
+ # Diagnostic dump (pipe Claude Code JSON context)
97
+ echo '{"model":{"display_name":"Opus"},...}' | context-stats explain
98
+
92
99
  DATA SOURCE:
93
100
  Reads token history from ~/.claude/statusline/statusline.<session_id>.state
94
101
  """
@@ -345,16 +352,23 @@ def run_watch_mode(
345
352
  # Show waiting message for new session
346
353
  reduced_motion = config.reduced_motion if config else False
347
354
  text = get_waiting_text(cycle_counter, reduced_motion)
348
- buf_lines.append(_format_waiting_message(
349
- colors,
350
- state_file.session_id,
351
- text,
352
- ))
355
+ buf_lines.append(
356
+ _format_waiting_message(
357
+ colors,
358
+ state_file.session_id,
359
+ text,
360
+ )
361
+ )
353
362
  else:
354
363
  # Render graphs (returns buffered string in watch mode)
355
364
  result = render_once(
356
- state_file, graph_type, renderer, colors,
357
- watch_mode=True, config=config, cycle_index=cycle_counter,
365
+ state_file,
366
+ graph_type,
367
+ renderer,
368
+ colors,
369
+ watch_mode=True,
370
+ config=config,
371
+ cycle_index=cycle_counter,
358
372
  )
359
373
  if isinstance(result, str):
360
374
  buf_lines.append(result)
@@ -400,7 +414,9 @@ def _format_waiting_message(
400
414
  lines.append(
401
415
  f" {colors.dim}The session has just started and no data has been recorded yet.{colors.reset}"
402
416
  )
403
- lines.append(f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}")
417
+ lines.append(
418
+ f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}"
419
+ )
404
420
  lines.append("")
405
421
  return "\n".join(lines)
406
422
 
@@ -420,16 +436,42 @@ def show_waiting_message(
420
436
  print(_format_waiting_message(colors, session_id, message))
421
437
 
422
438
 
439
+ def _ensure_utf8_stdout() -> None:
440
+ """Reconfigure stdout/stderr to UTF-8 on Windows where cp1252 is the default."""
441
+ if sys.stdout.encoding and sys.stdout.encoding.lower().replace("-", "") != "utf8":
442
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
443
+ if sys.stderr.encoding and sys.stderr.encoding.lower().replace("-", "") != "utf8":
444
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
445
+
446
+
423
447
  def main() -> None:
424
448
  """Main entry point for context-stats CLI."""
449
+ _ensure_utf8_stdout()
450
+
451
+ # Handle 'explain' subcommand before argparse (it expects stdin JSON, not flags)
452
+ if len(sys.argv) > 1 and sys.argv[1] == "explain":
453
+ import json
454
+
455
+ from claude_statusline.cli.explain import run_explain
456
+
457
+ no_color = "--no-color" in sys.argv
458
+ try:
459
+ data = json.load(sys.stdin)
460
+ except json.JSONDecodeError as e:
461
+ sys.stderr.write(f"Error: invalid JSON on stdin: {e}\n")
462
+ sys.stderr.write("Usage: echo '{...}' | context-stats explain\n")
463
+ sys.exit(1)
464
+ run_explain(data, no_color=no_color)
465
+ return
466
+
425
467
  args = parse_args()
426
468
 
427
469
  # Load config for token_detail setting
428
470
  config = Config.load()
429
471
 
430
- # Setup colors
472
+ # Setup colors with any user overrides from config
431
473
  color_enabled = not args.no_color and sys.stdout.isatty()
432
- colors = ColorManager(enabled=color_enabled)
474
+ colors = ColorManager(enabled=color_enabled, overrides=config.color_overrides)
433
475
 
434
476
  # Setup state file
435
477
  state_file = StateFile(args.session_id)