cc-context-stats 1.6.2 → 1.8.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 (37) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CLAUDE.md +12 -0
  3. package/README.md +34 -24
  4. package/docs/ARCHITECTURE.md +52 -25
  5. package/docs/CSV_FORMAT.md +2 -0
  6. package/docs/DEPLOYMENT.md +19 -8
  7. package/docs/DEVELOPMENT.md +48 -12
  8. package/docs/MODEL_INTELLIGENCE.md +396 -0
  9. package/docs/configuration.md +35 -0
  10. package/docs/context-stats.md +12 -1
  11. package/docs/installation.md +82 -22
  12. package/docs/scripts.md +47 -23
  13. package/docs/troubleshooting.md +93 -4
  14. package/package.json +1 -1
  15. package/pyproject.toml +1 -1
  16. package/scripts/statusline-full.sh +171 -37
  17. package/scripts/statusline.js +214 -32
  18. package/scripts/statusline.py +195 -47
  19. package/src/claude_statusline/__init__.py +1 -1
  20. package/src/claude_statusline/cli/context_stats.py +85 -13
  21. package/src/claude_statusline/cli/explain.py +228 -0
  22. package/src/claude_statusline/cli/statusline.py +41 -30
  23. package/src/claude_statusline/core/colors.py +78 -9
  24. package/src/claude_statusline/core/config.py +68 -9
  25. package/src/claude_statusline/core/git.py +16 -5
  26. package/src/claude_statusline/graphs/intelligence.py +162 -0
  27. package/src/claude_statusline/graphs/renderer.py +38 -3
  28. package/tests/bash/test_statusline_full.bats +5 -5
  29. package/tests/fixtures/mi_test_vectors.json +140 -0
  30. package/tests/node/intelligence.test.js +98 -0
  31. package/tests/node/statusline.test.js +4 -4
  32. package/tests/python/test_colors.py +105 -0
  33. package/tests/python/test_config_colors.py +78 -0
  34. package/tests/python/test_explain.py +177 -0
  35. package/tests/python/test_intelligence.py +314 -0
  36. package/tests/python/test_layout.py +4 -4
  37. package/tests/python/test_statusline.py +4 -4
@@ -38,6 +38,53 @@ import tempfile
38
38
  ROTATION_THRESHOLD = 10_000
39
39
  ROTATION_KEEP = 5_000
40
40
 
41
+ # Model Intelligence constants (hardcoded, not configurable)
42
+ MI_WEIGHT_CPS = 0.60
43
+ MI_WEIGHT_ES = 0.25
44
+ MI_WEIGHT_PS = 0.15
45
+ MI_GREEN_THRESHOLD = 0.65
46
+ MI_YELLOW_THRESHOLD = 0.35
47
+ MI_PRODUCTIVITY_TARGET = 0.2
48
+
49
+
50
+ def compute_mi(used_tokens, context_window_size, cache_read, total_context,
51
+ delta_lines, delta_output, beta=1.5):
52
+ """Compute Model Intelligence score. Returns (mi, cps, es, ps)."""
53
+ # Guard clause
54
+ if context_window_size == 0:
55
+ return (1.0, 1.0, 1.0, 0.5)
56
+
57
+ # CPS
58
+ u = used_tokens / context_window_size
59
+ cps = max(0.0, 1.0 - u ** beta) if u > 0 else 1.0
60
+
61
+ # ES
62
+ if total_context == 0:
63
+ es = 1.0
64
+ else:
65
+ cache_hit_ratio = cache_read / total_context
66
+ es = 0.3 + 0.7 * cache_hit_ratio
67
+
68
+ # PS
69
+ if delta_output is None or delta_output <= 0:
70
+ ps = 0.5
71
+ else:
72
+ ratio = delta_lines / delta_output
73
+ normalized = min(1.0, ratio / MI_PRODUCTIVITY_TARGET)
74
+ ps = 0.2 + 0.8 * normalized
75
+
76
+ mi = MI_WEIGHT_CPS * cps + MI_WEIGHT_ES * es + MI_WEIGHT_PS * ps
77
+ return (mi, cps, es, ps)
78
+
79
+
80
+ def get_mi_color(mi):
81
+ """Return ANSI color code for MI score."""
82
+ if mi > MI_GREEN_THRESHOLD:
83
+ return GREEN
84
+ if mi > MI_YELLOW_THRESHOLD:
85
+ return YELLOW
86
+ return RED
87
+
41
88
 
42
89
  def maybe_rotate_state_file(state_file):
43
90
  """Rotate a state file if it exceeds ROTATION_THRESHOLD lines.
@@ -52,9 +99,7 @@ def maybe_rotate_state_file(state_file):
52
99
  if len(lines) <= ROTATION_THRESHOLD:
53
100
  return
54
101
  keep = lines[-ROTATION_KEEP:]
55
- fd, tmp_path = tempfile.mkstemp(
56
- dir=os.path.dirname(state_file), suffix=".tmp"
57
- )
102
+ fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(state_file), suffix=".tmp")
58
103
  try:
59
104
  with os.fdopen(fd, "w") as tmp_f:
60
105
  tmp_f.writelines(keep)
@@ -66,11 +111,10 @@ def maybe_rotate_state_file(state_file):
66
111
  pass
67
112
  raise
68
113
  except OSError as e:
69
- sys.stderr.write(
70
- f"[statusline] warning: failed to rotate state file: {e}\n"
71
- )
114
+ sys.stderr.write(f"[statusline] warning: failed to rotate state file: {e}\n")
115
+
72
116
 
73
- # ANSI Colors
117
+ # ANSI Colors (defaults, overridable via config)
74
118
  BLUE = "\033[0;34m"
75
119
  MAGENTA = "\033[0;35m"
76
120
  CYAN = "\033[0;36m"
@@ -80,6 +124,48 @@ RED = "\033[0;31m"
80
124
  DIM = "\033[2m"
81
125
  RESET = "\033[0m"
82
126
 
127
+ # Named colors for config parsing
128
+ _COLOR_NAMES = {
129
+ "black": "\033[0;30m",
130
+ "red": "\033[0;31m",
131
+ "green": "\033[0;32m",
132
+ "yellow": "\033[0;33m",
133
+ "blue": "\033[0;34m",
134
+ "magenta": "\033[0;35m",
135
+ "cyan": "\033[0;36m",
136
+ "white": "\033[0;37m",
137
+ "bright_black": "\033[0;90m",
138
+ "bright_red": "\033[0;91m",
139
+ "bright_green": "\033[0;92m",
140
+ "bright_yellow": "\033[0;93m",
141
+ "bright_blue": "\033[0;94m",
142
+ "bright_magenta": "\033[0;95m",
143
+ "bright_cyan": "\033[0;96m",
144
+ "bright_white": "\033[0;97m",
145
+ }
146
+
147
+
148
+ def _parse_color(value):
149
+ """Parse a color name or #rrggbb hex into an ANSI escape code."""
150
+ value = value.strip().lower()
151
+ if value in _COLOR_NAMES:
152
+ return _COLOR_NAMES[value]
153
+ if re.match(r"^#[0-9a-f]{6}$", value):
154
+ r, g, b = int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16)
155
+ return f"\033[38;2;{r};{g};{b}m"
156
+ return None
157
+
158
+
159
+ # Color config keys and which color slot they map to
160
+ _COLOR_KEYS = {
161
+ "color_green": "green",
162
+ "color_yellow": "yellow",
163
+ "color_red": "red",
164
+ "color_blue": "blue",
165
+ "color_magenta": "magenta",
166
+ "color_cyan": "cyan",
167
+ }
168
+
83
169
  # Pattern to strip ANSI escape sequences
84
170
  _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
85
171
 
@@ -132,8 +218,12 @@ def fit_to_width(parts, max_width):
132
218
  return result
133
219
 
134
220
 
135
- def get_git_info(project_dir):
221
+ def get_git_info(project_dir, magenta=None, cyan=None):
136
222
  """Get git branch and change count"""
223
+ if magenta is None:
224
+ magenta = MAGENTA
225
+ if cyan is None:
226
+ cyan = CYAN
137
227
  git_dir = os.path.join(project_dir, ".git")
138
228
  if not os.path.isdir(git_dir):
139
229
  return ""
@@ -163,8 +253,8 @@ def get_git_info(project_dir):
163
253
  changes = len([line for line in result.stdout.split("\n") if line.strip()])
164
254
 
165
255
  if changes > 0:
166
- return f" | {MAGENTA}{branch}{RESET} {CYAN}[{changes}]{RESET}"
167
- return f" | {MAGENTA}{branch}{RESET}"
256
+ return f" | {magenta}{branch}{RESET} {cyan}[{changes}]{RESET}"
257
+ return f" | {magenta}{branch}{RESET}"
168
258
  except (subprocess.TimeoutExpired, OSError):
169
259
  return ""
170
260
 
@@ -177,6 +267,10 @@ def read_config():
177
267
  "show_delta": True,
178
268
  "show_session": True,
179
269
  "show_io_tokens": True,
270
+ "reduced_motion": False,
271
+ "show_mi": True,
272
+ "mi_curve_beta": 1.5,
273
+ "colors": {},
180
274
  }
181
275
  config_path = os.path.expanduser("~/.claude/statusline.conf")
182
276
 
@@ -198,6 +292,12 @@ show_delta=true
198
292
 
199
293
  # Show session_id in status line
200
294
  show_session=true
295
+
296
+ # Custom colors - use named colors or hex (#rrggbb)
297
+ # Available: color_green, color_yellow, color_red, color_blue, color_magenta, color_cyan
298
+ # Examples:
299
+ # color_green=#7dcfff
300
+ # color_red=#f7768e
201
301
  """
202
302
  )
203
303
  except Exception as e:
@@ -212,17 +312,31 @@ show_session=true
212
312
  continue
213
313
  key, value = line.split("=", 1)
214
314
  key = key.strip()
215
- value = value.strip().lower()
315
+ raw_value = value.strip()
316
+ value_lower = raw_value.lower()
216
317
  if key == "autocompact":
217
- config["autocompact"] = value != "false"
318
+ config["autocompact"] = value_lower != "false"
218
319
  elif key == "token_detail":
219
- config["token_detail"] = value != "false"
320
+ config["token_detail"] = value_lower != "false"
220
321
  elif key == "show_delta":
221
- config["show_delta"] = value != "false"
322
+ config["show_delta"] = value_lower != "false"
222
323
  elif key == "show_session":
223
- config["show_session"] = value != "false"
324
+ config["show_session"] = value_lower != "false"
224
325
  elif key == "show_io_tokens":
225
- config["show_io_tokens"] = value != "false"
326
+ config["show_io_tokens"] = value_lower != "false"
327
+ elif key == "reduced_motion":
328
+ config["reduced_motion"] = value_lower != "false"
329
+ elif key == "show_mi":
330
+ config["show_mi"] = value_lower != "false"
331
+ elif key == "mi_curve_beta":
332
+ try:
333
+ config["mi_curve_beta"] = float(raw_value)
334
+ except ValueError:
335
+ pass
336
+ elif key in _COLOR_KEYS:
337
+ ansi = _parse_color(raw_value)
338
+ if ansi:
339
+ config["colors"][_COLOR_KEYS[key]] = ansi
226
340
  except (OSError, UnicodeDecodeError) as e:
227
341
  sys.stderr.write(f"[statusline] warning: failed to read config: {e}\n")
228
342
  return config
@@ -241,17 +355,28 @@ def main():
241
355
  model = data.get("model", {}).get("display_name", "Claude")
242
356
  dir_name = os.path.basename(cwd) or "~"
243
357
 
244
- # Git info
245
- git_info = get_git_info(project_dir)
246
-
247
358
  # Read settings from config file
248
359
  config = read_config()
249
360
  autocompact_enabled = config["autocompact"]
250
361
  token_detail = config["token_detail"]
251
362
  show_delta = config["show_delta"]
252
363
  show_session = config["show_session"]
364
+ show_mi = config["show_mi"]
365
+ mi_curve_beta = config["mi_curve_beta"]
253
366
  # Note: show_io_tokens setting is read but not yet implemented
254
367
 
368
+ # Apply color overrides from config
369
+ c = config.get("colors", {})
370
+ c_green = c.get("green", GREEN)
371
+ c_yellow = c.get("yellow", YELLOW)
372
+ c_red = c.get("red", RED)
373
+ c_blue = c.get("blue", BLUE)
374
+ c_magenta = c.get("magenta", MAGENTA)
375
+ c_cyan = c.get("cyan", CYAN)
376
+
377
+ # Git info (pass configurable colors)
378
+ git_info = get_git_info(project_dir, magenta=c_magenta, cyan=c_cyan)
379
+
255
380
  # Extract session_id once for reuse
256
381
  session_id = data.get("session_id")
257
382
 
@@ -259,6 +384,7 @@ def main():
259
384
  context_info = ""
260
385
  ac_info = ""
261
386
  delta_info = ""
387
+ mi_info = ""
262
388
  session_info = ""
263
389
  total_size = data.get("context_window", {}).get("context_window_size", 0)
264
390
  current_usage = data.get("context_window", {}).get("current_usage")
@@ -308,16 +434,16 @@ def main():
308
434
 
309
435
  # Color based on free percentage
310
436
  if free_pct_int > 50:
311
- ctx_color = GREEN
437
+ ctx_color = c_green
312
438
  elif free_pct_int > 25:
313
- ctx_color = YELLOW
439
+ ctx_color = c_yellow
314
440
  else:
315
- ctx_color = RED
441
+ ctx_color = c_red
316
442
 
317
- context_info = f" | {ctx_color}{free_display} free ({free_pct:.1f}%){RESET}"
443
+ context_info = f" | {ctx_color}{free_display} ({free_pct:.1f}%){RESET}"
318
444
 
319
- # Calculate and display token delta if enabled
320
- if show_delta:
445
+ # Read previous entry if needed for delta OR MI
446
+ if show_delta or show_mi:
321
447
  import glob
322
448
  import shutil
323
449
  import time
@@ -340,44 +466,66 @@ def main():
340
466
  state_file = os.path.join(state_dir, "statusline.state")
341
467
  has_prev = False
342
468
  prev_tokens = 0
469
+ prev_lines_added = 0
470
+ prev_lines_removed = 0
471
+ prev_output_tokens = 0
343
472
  try:
344
473
  if os.path.exists(state_file):
345
474
  has_prev = True
346
- # Read last line to get previous context usage
475
+ # Read last line to get previous state
347
476
  with open(state_file) as f:
348
- lines = f.readlines()
349
- if lines:
350
- last_line = lines[-1].strip()
477
+ file_lines = f.readlines()
478
+ if file_lines:
479
+ last_line = file_lines[-1].strip()
351
480
  if "," in last_line:
352
- parts = last_line.split(",")
481
+ csv_parts = last_line.split(",")
353
482
  # Calculate previous context usage:
354
483
  # cur_input + cache_creation + cache_read
355
484
  # CSV indices: cur_in[3], cache_create[5], cache_read[6]
356
- prev_cur_input = int(parts[3]) if len(parts) > 3 else 0
357
- prev_cache_creation = int(parts[5]) if len(parts) > 5 else 0
358
- prev_cache_read = int(parts[6]) if len(parts) > 6 else 0
485
+ prev_cur_input = int(csv_parts[3]) if len(csv_parts) > 3 else 0
486
+ prev_cache_creation = int(csv_parts[5]) if len(csv_parts) > 5 else 0
487
+ prev_cache_read = int(csv_parts[6]) if len(csv_parts) > 6 else 0
359
488
  prev_tokens = prev_cur_input + prev_cache_creation + prev_cache_read
489
+ # For MI productivity score
490
+ prev_output_tokens = int(csv_parts[2]) if len(csv_parts) > 2 else 0
491
+ prev_lines_added = int(csv_parts[8]) if len(csv_parts) > 8 else 0
492
+ prev_lines_removed = int(csv_parts[9]) if len(csv_parts) > 9 else 0
360
493
  else:
361
494
  # Old format - single value
362
495
  prev_tokens = int(last_line or 0)
363
496
  except (OSError, ValueError) as e:
364
497
  sys.stderr.write(f"[statusline] warning: failed to read state file: {e}\n")
365
498
  prev_tokens = 0
366
- # Calculate delta (difference in context window usage)
367
- delta = used_tokens - prev_tokens
368
- # Only show positive delta (and skip first run when no previous state)
369
- if has_prev and delta > 0:
370
- if token_detail:
371
- delta_display = f"{delta:,}"
499
+
500
+ # Calculate and display token delta if enabled
501
+ if show_delta:
502
+ delta = used_tokens - prev_tokens
503
+ if has_prev and delta > 0:
504
+ if token_detail:
505
+ delta_display = f"{delta:,}"
506
+ else:
507
+ delta_display = f"{delta / 1000:.1f}k"
508
+ delta_info = f" {DIM}[+{delta_display}]{RESET}"
509
+
510
+ # Calculate and display MI score if enabled
511
+ if show_mi:
512
+ if has_prev:
513
+ delta_la = lines_added - prev_lines_added
514
+ delta_lr = lines_removed - prev_lines_removed
515
+ delta_lines = delta_la + delta_lr
516
+ delta_output = total_output_tokens - prev_output_tokens
372
517
  else:
373
- delta_display = f"{delta / 1000:.1f}k"
374
- delta_info = f" {DIM}[+{delta_display}]{RESET}"
518
+ delta_lines = 0
519
+ delta_output = None
520
+ mi_val, _, _, _ = compute_mi(
521
+ used_tokens, total_size, cache_read, used_tokens,
522
+ delta_lines, delta_output, mi_curve_beta,
523
+ )
524
+ mi_color = get_mi_color(mi_val)
525
+ mi_info = f" {mi_color}MI:{mi_val:.2f}{RESET}"
526
+
375
527
  # Only append if context usage changed (avoid duplicates from multiple refreshes)
376
528
  if not has_prev or used_tokens != prev_tokens:
377
- # Append current usage with comprehensive format
378
- # Format: ts,total_in,total_out,cur_in,cur_out,cache_create,cache_read,
379
- # cost_usd,lines_added,lines_removed,session_id,model_id,project_dir,
380
- # context_window_size
381
529
  try:
382
530
  cur_input_tokens = current_usage.get("input_tokens", 0)
383
531
  cur_output_tokens = current_usage.get("output_tokens", 0)
@@ -411,9 +559,9 @@ def main():
411
559
  session_info = f" {DIM}{session_id}{RESET}"
412
560
 
413
561
  # Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [S:session_id]
414
- base = f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
562
+ base = f"{DIM}[{model}]{RESET} {c_blue}{dir_name}{RESET}"
415
563
  max_width = get_terminal_width()
416
- parts = [base, git_info, context_info, delta_info, ac_info, session_info]
564
+ parts = [base, git_info, context_info, delta_info, mi_info, ac_info, session_info]
417
565
  print(fit_to_width(parts, max_width))
418
566
 
419
567
 
@@ -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.2"
6
+ __version__ = "1.8.0"
7
7
 
8
8
  from claude_statusline.core.config import Config
9
9
  from claude_statusline.core.state import StateFile
@@ -46,17 +46,22 @@ 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)
56
60
  - cumulative: Total context usage over time
57
61
  - io: Input/output tokens over time
62
+ - mi: Model Intelligence score over time
58
63
  - both: Show cumulative and delta graphs
59
- - all: Show all graphs including I/O
64
+ - all: Show all graphs including I/O and MI
60
65
  -w [interval] Set refresh interval in seconds (default: 2)
61
66
  --no-watch Show graphs once and exit (disable live monitoring)
62
67
  --no-color Disable color output
@@ -89,6 +94,9 @@ EXAMPLES:
89
94
  # Output to file (no colors, single run)
90
95
  context-stats --no-watch --no-color > output.txt
91
96
 
97
+ # Diagnostic dump (pipe Claude Code JSON context)
98
+ echo '{"model":{"display_name":"Opus"},...}' | context-stats explain
99
+
92
100
  DATA SOURCE:
93
101
  Reads token history from ~/.claude/statusline/statusline.<session_id>.state
94
102
  """
@@ -104,7 +112,7 @@ def parse_args() -> argparse.Namespace:
104
112
  parser.add_argument("session_id", nargs="?", default=None, help="Session ID")
105
113
  parser.add_argument(
106
114
  "--type",
107
- choices=["cumulative", "delta", "io", "both", "all"],
115
+ choices=["cumulative", "delta", "io", "mi", "both", "all"],
108
116
  default="delta",
109
117
  help="Graph type to display (default: delta)",
110
118
  )
@@ -277,8 +285,37 @@ def render_once(
277
285
  current_output, timestamps, "Output Tokens (per request)", colors.magenta
278
286
  )
279
287
 
288
+ # Compute MI scores for graph and summary
289
+ mi_score = None
290
+ if graph_type in ("mi", "all") or True: # Always compute for summary
291
+ from claude_statusline.graphs.intelligence import calculate_intelligence
292
+
293
+ mi_config = config if config else Config.load()
294
+ beta = mi_config.mi_curve_beta if config else 1.5
295
+
296
+ mi_scores = []
297
+ for i, entry in enumerate(entries):
298
+ prev = entries[i - 1] if i > 0 else None
299
+ ctx_window = entry.context_window_size
300
+ score = calculate_intelligence(entry, prev, ctx_window, beta)
301
+ mi_scores.append(score)
302
+
303
+ if mi_scores:
304
+ mi_score = mi_scores[-1]
305
+
306
+ if graph_type in ("mi", "all") and len(mi_scores) > 0:
307
+ # Scale MI scores to [0, 1000] for integer renderer
308
+ mi_data = [int(s.mi * 1000) for s in mi_scores]
309
+ renderer.render_timeseries(
310
+ mi_data,
311
+ timestamps,
312
+ "Model Intelligence Over Time",
313
+ colors.yellow,
314
+ label_fn=lambda v: f"{v / 1000:.2f}",
315
+ )
316
+
280
317
  # Summary and footer
281
- renderer.render_summary(entries, deltas)
318
+ renderer.render_summary(entries, deltas, mi_score=mi_score)
282
319
  renderer.render_footer(__version__)
283
320
 
284
321
  if watch_mode:
@@ -345,16 +382,23 @@ def run_watch_mode(
345
382
  # Show waiting message for new session
346
383
  reduced_motion = config.reduced_motion if config else False
347
384
  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
- ))
385
+ buf_lines.append(
386
+ _format_waiting_message(
387
+ colors,
388
+ state_file.session_id,
389
+ text,
390
+ )
391
+ )
353
392
  else:
354
393
  # Render graphs (returns buffered string in watch mode)
355
394
  result = render_once(
356
- state_file, graph_type, renderer, colors,
357
- watch_mode=True, config=config, cycle_index=cycle_counter,
395
+ state_file,
396
+ graph_type,
397
+ renderer,
398
+ colors,
399
+ watch_mode=True,
400
+ config=config,
401
+ cycle_index=cycle_counter,
358
402
  )
359
403
  if isinstance(result, str):
360
404
  buf_lines.append(result)
@@ -400,7 +444,9 @@ def _format_waiting_message(
400
444
  lines.append(
401
445
  f" {colors.dim}The session has just started and no data has been recorded yet.{colors.reset}"
402
446
  )
403
- lines.append(f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}")
447
+ lines.append(
448
+ f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}"
449
+ )
404
450
  lines.append("")
405
451
  return "\n".join(lines)
406
452
 
@@ -420,16 +466,42 @@ def show_waiting_message(
420
466
  print(_format_waiting_message(colors, session_id, message))
421
467
 
422
468
 
469
+ def _ensure_utf8_stdout() -> None:
470
+ """Reconfigure stdout/stderr to UTF-8 on Windows where cp1252 is the default."""
471
+ if sys.stdout.encoding and sys.stdout.encoding.lower().replace("-", "") != "utf8":
472
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
473
+ if sys.stderr.encoding and sys.stderr.encoding.lower().replace("-", "") != "utf8":
474
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
475
+
476
+
423
477
  def main() -> None:
424
478
  """Main entry point for context-stats CLI."""
479
+ _ensure_utf8_stdout()
480
+
481
+ # Handle 'explain' subcommand before argparse (it expects stdin JSON, not flags)
482
+ if len(sys.argv) > 1 and sys.argv[1] == "explain":
483
+ import json
484
+
485
+ from claude_statusline.cli.explain import run_explain
486
+
487
+ no_color = "--no-color" in sys.argv
488
+ try:
489
+ data = json.load(sys.stdin)
490
+ except json.JSONDecodeError as e:
491
+ sys.stderr.write(f"Error: invalid JSON on stdin: {e}\n")
492
+ sys.stderr.write("Usage: echo '{...}' | context-stats explain\n")
493
+ sys.exit(1)
494
+ run_explain(data, no_color=no_color)
495
+ return
496
+
425
497
  args = parse_args()
426
498
 
427
499
  # Load config for token_detail setting
428
500
  config = Config.load()
429
501
 
430
- # Setup colors
502
+ # Setup colors with any user overrides from config
431
503
  color_enabled = not args.no_color and sys.stdout.isatty()
432
- colors = ColorManager(enabled=color_enabled)
504
+ colors = ColorManager(enabled=color_enabled, overrides=config.color_overrides)
433
505
 
434
506
  # Setup state file
435
507
  state_file = StateFile(args.session_id)