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.
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +12 -0
- package/README.md +34 -24
- package/docs/ARCHITECTURE.md +52 -25
- package/docs/CSV_FORMAT.md +2 -0
- package/docs/DEPLOYMENT.md +19 -8
- package/docs/DEVELOPMENT.md +48 -12
- package/docs/MODEL_INTELLIGENCE.md +396 -0
- package/docs/configuration.md +35 -0
- package/docs/context-stats.md +12 -1
- package/docs/installation.md +82 -22
- package/docs/scripts.md +47 -23
- package/docs/troubleshooting.md +93 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/statusline-full.sh +171 -37
- package/scripts/statusline.js +214 -32
- package/scripts/statusline.py +195 -47
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +85 -13
- package/src/claude_statusline/cli/explain.py +228 -0
- package/src/claude_statusline/cli/statusline.py +41 -30
- package/src/claude_statusline/core/colors.py +78 -9
- package/src/claude_statusline/core/config.py +68 -9
- package/src/claude_statusline/core/git.py +16 -5
- package/src/claude_statusline/graphs/intelligence.py +162 -0
- package/src/claude_statusline/graphs/renderer.py +38 -3
- package/tests/bash/test_statusline_full.bats +5 -5
- package/tests/fixtures/mi_test_vectors.json +140 -0
- package/tests/node/intelligence.test.js +98 -0
- package/tests/node/statusline.test.js +4 -4
- package/tests/python/test_colors.py +105 -0
- package/tests/python/test_config_colors.py +78 -0
- package/tests/python/test_explain.py +177 -0
- package/tests/python/test_intelligence.py +314 -0
- package/tests/python/test_layout.py +4 -4
- package/tests/python/test_statusline.py +4 -4
package/scripts/statusline.py
CHANGED
|
@@ -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
|
-
|
|
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" | {
|
|
167
|
-
return f" | {
|
|
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
|
-
|
|
315
|
+
raw_value = value.strip()
|
|
316
|
+
value_lower = raw_value.lower()
|
|
216
317
|
if key == "autocompact":
|
|
217
|
-
config["autocompact"] =
|
|
318
|
+
config["autocompact"] = value_lower != "false"
|
|
218
319
|
elif key == "token_detail":
|
|
219
|
-
config["token_detail"] =
|
|
320
|
+
config["token_detail"] = value_lower != "false"
|
|
220
321
|
elif key == "show_delta":
|
|
221
|
-
config["show_delta"] =
|
|
322
|
+
config["show_delta"] = value_lower != "false"
|
|
222
323
|
elif key == "show_session":
|
|
223
|
-
config["show_session"] =
|
|
324
|
+
config["show_session"] = value_lower != "false"
|
|
224
325
|
elif key == "show_io_tokens":
|
|
225
|
-
config["show_io_tokens"] =
|
|
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 =
|
|
437
|
+
ctx_color = c_green
|
|
312
438
|
elif free_pct_int > 25:
|
|
313
|
-
ctx_color =
|
|
439
|
+
ctx_color = c_yellow
|
|
314
440
|
else:
|
|
315
|
-
ctx_color =
|
|
441
|
+
ctx_color = c_red
|
|
316
442
|
|
|
317
|
-
context_info = f" | {ctx_color}{free_display}
|
|
443
|
+
context_info = f" | {ctx_color}{free_display} ({free_pct:.1f}%){RESET}"
|
|
318
444
|
|
|
319
|
-
#
|
|
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
|
|
475
|
+
# Read last line to get previous state
|
|
347
476
|
with open(state_file) as f:
|
|
348
|
-
|
|
349
|
-
if
|
|
350
|
-
last_line =
|
|
477
|
+
file_lines = f.readlines()
|
|
478
|
+
if file_lines:
|
|
479
|
+
last_line = file_lines[-1].strip()
|
|
351
480
|
if "," in last_line:
|
|
352
|
-
|
|
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(
|
|
357
|
-
prev_cache_creation = int(
|
|
358
|
-
prev_cache_read = int(
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if
|
|
371
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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} {
|
|
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
|
|
|
@@ -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(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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,
|
|
357
|
-
|
|
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(
|
|
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)
|