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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Model Intelligence (MI) score computation.
|
|
2
|
+
|
|
3
|
+
Estimates answer quality based on context utilization, cache efficiency,
|
|
4
|
+
and output productivity. Inspired by the Michelangelo paper
|
|
5
|
+
(arXiv:2409.12640, Google DeepMind, Sep 2024).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from claude_statusline.core.state import StateEntry
|
|
13
|
+
|
|
14
|
+
# Hardcoded constants — not configurable, to minimize cross-implementation sync burden
|
|
15
|
+
MI_WEIGHT_CPS = 0.60
|
|
16
|
+
MI_WEIGHT_ES = 0.25
|
|
17
|
+
MI_WEIGHT_PS = 0.15
|
|
18
|
+
MI_GREEN_THRESHOLD = 0.65
|
|
19
|
+
MI_YELLOW_THRESHOLD = 0.35
|
|
20
|
+
MI_PRODUCTIVITY_TARGET = 0.2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class IntelligenceConfig:
|
|
25
|
+
"""Configuration for MI computation."""
|
|
26
|
+
|
|
27
|
+
beta: float = 1.5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class IntelligenceScore:
|
|
32
|
+
"""MI score with sub-components."""
|
|
33
|
+
|
|
34
|
+
cps: float
|
|
35
|
+
es: float
|
|
36
|
+
ps: float
|
|
37
|
+
mi: float
|
|
38
|
+
utilization: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calculate_context_pressure(utilization: float, beta: float = 1.5) -> float:
|
|
42
|
+
"""Calculate Context Pressure Score (CPS).
|
|
43
|
+
|
|
44
|
+
CPS = max(0, 1 - u^beta) where u is utilization ratio [0, 1+].
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
utilization: Context utilization ratio (current_used / context_window_size)
|
|
48
|
+
beta: Curve shape parameter (default 1.5)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
CPS value in [0, 1]
|
|
52
|
+
"""
|
|
53
|
+
if utilization <= 0:
|
|
54
|
+
return 1.0
|
|
55
|
+
return max(0.0, 1.0 - utilization**beta)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def calculate_efficiency(entry: StateEntry) -> float:
|
|
59
|
+
"""Calculate Efficiency Score (ES).
|
|
60
|
+
|
|
61
|
+
ES = 0.3 + 0.7 * cache_hit_ratio, where cache_hit_ratio = cache_read / total_context.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
entry: Current state entry
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
ES value in [0.3, 1.0]
|
|
68
|
+
"""
|
|
69
|
+
total_context = entry.current_used_tokens
|
|
70
|
+
if total_context == 0:
|
|
71
|
+
return 1.0
|
|
72
|
+
cache_hit_ratio = entry.cache_read / total_context
|
|
73
|
+
return 0.3 + 0.7 * cache_hit_ratio
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def calculate_productivity(
|
|
77
|
+
current: StateEntry, previous: StateEntry | None
|
|
78
|
+
) -> float:
|
|
79
|
+
"""Calculate Productivity Score (PS).
|
|
80
|
+
|
|
81
|
+
Uses consecutive entry diffs for delta_lines and delta_output_tokens.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
current: Current state entry
|
|
85
|
+
previous: Previous state entry, or None
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
PS value in [0.2, 1.0], or 0.5 if no previous entry
|
|
89
|
+
"""
|
|
90
|
+
if previous is None:
|
|
91
|
+
return 0.5
|
|
92
|
+
|
|
93
|
+
delta_lines_added = current.lines_added - previous.lines_added
|
|
94
|
+
delta_lines_removed = current.lines_removed - previous.lines_removed
|
|
95
|
+
delta_output_tokens = current.total_output_tokens - previous.total_output_tokens
|
|
96
|
+
|
|
97
|
+
if delta_output_tokens <= 0:
|
|
98
|
+
return 0.5
|
|
99
|
+
|
|
100
|
+
delta_lines = delta_lines_added + delta_lines_removed
|
|
101
|
+
ratio = delta_lines / delta_output_tokens
|
|
102
|
+
normalized = min(1.0, ratio / MI_PRODUCTIVITY_TARGET)
|
|
103
|
+
return 0.2 + 0.8 * normalized
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def calculate_intelligence(
|
|
107
|
+
current: StateEntry,
|
|
108
|
+
previous: StateEntry | None,
|
|
109
|
+
context_window_size: int,
|
|
110
|
+
beta: float = 1.5,
|
|
111
|
+
) -> IntelligenceScore:
|
|
112
|
+
"""Calculate composite Model Intelligence score.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
current: Current state entry
|
|
116
|
+
previous: Previous state entry (for productivity delta)
|
|
117
|
+
context_window_size: Total context window size in tokens
|
|
118
|
+
beta: CPS curve shape parameter
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
IntelligenceScore with all sub-scores and composite MI
|
|
122
|
+
"""
|
|
123
|
+
# Guard clause: unknown context window
|
|
124
|
+
if context_window_size == 0:
|
|
125
|
+
return IntelligenceScore(cps=1.0, es=1.0, ps=0.5, mi=1.0, utilization=0.0)
|
|
126
|
+
|
|
127
|
+
utilization = current.current_used_tokens / context_window_size
|
|
128
|
+
cps = calculate_context_pressure(utilization, beta)
|
|
129
|
+
es = calculate_efficiency(current)
|
|
130
|
+
ps = calculate_productivity(current, previous)
|
|
131
|
+
|
|
132
|
+
mi = MI_WEIGHT_CPS * cps + MI_WEIGHT_ES * es + MI_WEIGHT_PS * ps
|
|
133
|
+
|
|
134
|
+
return IntelligenceScore(cps=cps, es=es, ps=ps, mi=mi, utilization=utilization)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_mi_color(mi: float) -> str:
|
|
138
|
+
"""Get color name for MI score.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
mi: MI score value
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Color name: "green", "yellow", or "red"
|
|
145
|
+
"""
|
|
146
|
+
if mi > MI_GREEN_THRESHOLD:
|
|
147
|
+
return "green"
|
|
148
|
+
if mi > MI_YELLOW_THRESHOLD:
|
|
149
|
+
return "yellow"
|
|
150
|
+
return "red"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_mi_score(mi: float) -> str:
|
|
154
|
+
"""Format MI score for display.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
mi: MI score value
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Formatted string like "0.82"
|
|
161
|
+
"""
|
|
162
|
+
return f"{mi:.2f}"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import shutil
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
|
|
8
9
|
from claude_statusline.core.colors import ColorManager
|
|
@@ -94,6 +95,7 @@ class GraphRenderer:
|
|
|
94
95
|
timestamps: list[int],
|
|
95
96
|
title: str,
|
|
96
97
|
color: str,
|
|
98
|
+
label_fn: Callable[[int], str] | None = None,
|
|
97
99
|
) -> None:
|
|
98
100
|
"""Render a timeseries ASCII graph.
|
|
99
101
|
|
|
@@ -102,6 +104,7 @@ class GraphRenderer:
|
|
|
102
104
|
timestamps: Corresponding timestamps for x-axis labels
|
|
103
105
|
title: Graph title
|
|
104
106
|
color: ANSI color code for the graph
|
|
107
|
+
label_fn: Optional function to format Y-axis labels. If None, uses format_tokens().
|
|
105
108
|
"""
|
|
106
109
|
n = len(data)
|
|
107
110
|
if n == 0:
|
|
@@ -119,12 +122,14 @@ class GraphRenderer:
|
|
|
119
122
|
width = self.dimensions.graph_width
|
|
120
123
|
height = self.dimensions.graph_height
|
|
121
124
|
|
|
125
|
+
fmt = label_fn if label_fn else lambda v: format_tokens(v, self.token_detail)
|
|
126
|
+
|
|
122
127
|
# Print title and stats
|
|
123
128
|
self._emit()
|
|
124
129
|
self._emit(f"{self.colors.bold}{title}{self.colors.reset}")
|
|
125
130
|
self._emit(
|
|
126
|
-
f"{self.colors.dim}Max: {
|
|
127
|
-
f"Min: {
|
|
131
|
+
f"{self.colors.dim}Max: {fmt(max_val)} "
|
|
132
|
+
f"Min: {fmt(min_val)} "
|
|
128
133
|
f"Points: {n}{self.colors.reset}"
|
|
129
134
|
)
|
|
130
135
|
self._emit()
|
|
@@ -138,7 +143,7 @@ class GraphRenderer:
|
|
|
138
143
|
|
|
139
144
|
# Show labels at top, middle, and bottom
|
|
140
145
|
if r == 0 or r == height // 2 or r == height - 1:
|
|
141
|
-
label =
|
|
146
|
+
label = fmt(val)
|
|
142
147
|
else:
|
|
143
148
|
label = ""
|
|
144
149
|
|
|
@@ -259,12 +264,14 @@ class GraphRenderer:
|
|
|
259
264
|
self,
|
|
260
265
|
entries: list, # list[StateEntry]
|
|
261
266
|
deltas: list[int],
|
|
267
|
+
mi_score: object | None = None, # IntelligenceScore
|
|
262
268
|
) -> None:
|
|
263
269
|
"""Render summary statistics.
|
|
264
270
|
|
|
265
271
|
Args:
|
|
266
272
|
entries: List of StateEntry objects
|
|
267
273
|
deltas: List of token deltas
|
|
274
|
+
mi_score: Optional IntelligenceScore for MI display
|
|
268
275
|
"""
|
|
269
276
|
if not entries:
|
|
270
277
|
return
|
|
@@ -316,6 +323,34 @@ class GraphRenderer:
|
|
|
316
323
|
f" {status_color}{self.colors.bold}>>> {status_text.upper()} <<<{self.colors.reset} "
|
|
317
324
|
f"{self.colors.dim}({status_hint}){self.colors.reset}"
|
|
318
325
|
)
|
|
326
|
+
|
|
327
|
+
# Model Intelligence score
|
|
328
|
+
if mi_score is not None:
|
|
329
|
+
from claude_statusline.graphs.intelligence import (
|
|
330
|
+
format_mi_score,
|
|
331
|
+
get_mi_color,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
mi_color_name = get_mi_color(mi_score.mi)
|
|
335
|
+
mi_color = getattr(self.colors, mi_color_name)
|
|
336
|
+
if mi_score.mi > 0.65:
|
|
337
|
+
mi_hint = "Model is operating well"
|
|
338
|
+
elif mi_score.mi > 0.35:
|
|
339
|
+
mi_hint = "Context pressure is degrading answer quality"
|
|
340
|
+
else:
|
|
341
|
+
mi_hint = "Severely degraded, consider new session"
|
|
342
|
+
self._emit(
|
|
343
|
+
f" {mi_color}{'Model Intelligence:':<20}{self.colors.reset} "
|
|
344
|
+
f"{format_mi_score(mi_score.mi)} "
|
|
345
|
+
f"{self.colors.dim}({mi_hint}){self.colors.reset}"
|
|
346
|
+
)
|
|
347
|
+
self._emit(
|
|
348
|
+
f" {self.colors.dim} CPS: {mi_score.cps:.2f} "
|
|
349
|
+
f"ES: {mi_score.es:.2f} "
|
|
350
|
+
f"PS: {mi_score.ps:.2f}{self.colors.reset}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if last.context_window_size > 0:
|
|
319
354
|
self._emit()
|
|
320
355
|
|
|
321
356
|
# Session details (ordered: Last Growth, I/O, Lines, Cost, Model, Duration)
|
|
@@ -38,7 +38,7 @@ teardown() {
|
|
|
38
38
|
result=$(cat "$FIXTURES/valid_full.json" | "$SCRIPT")
|
|
39
39
|
[[ "$result" == *"Opus 4.5"* ]]
|
|
40
40
|
[[ "$result" == *"my-project"* ]]
|
|
41
|
-
[[ "$result" == *"
|
|
41
|
+
[[ "$result" == *"%"* ]]
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
@test "shows AC indicator when autocompact enabled" {
|
|
@@ -58,8 +58,8 @@ teardown() {
|
|
|
58
58
|
input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
|
|
59
59
|
result=$(echo "$input" | "$SCRIPT")
|
|
60
60
|
# Should NOT show 'k' suffix by default, should show comma-formatted number
|
|
61
|
-
[[ "$result" != *"k
|
|
62
|
-
[[ "$result" == *"
|
|
61
|
+
[[ "$result" != *"k ("* ]]
|
|
62
|
+
[[ "$result" == *"%"* ]]
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
@test "shows abbreviated tokens when token_detail=false" {
|
|
@@ -67,7 +67,7 @@ teardown() {
|
|
|
67
67
|
input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
|
|
68
68
|
result=$(echo "$input" | "$SCRIPT")
|
|
69
69
|
# Should show 'k' suffix for abbreviated format
|
|
70
|
-
[[ "$result" == *"k
|
|
70
|
+
[[ "$result" == *"k ("* ]]
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
@test "handles missing context window gracefully" {
|
|
@@ -79,7 +79,7 @@ teardown() {
|
|
|
79
79
|
@test "calculates free tokens percentage correctly" {
|
|
80
80
|
# Low usage fixture: 30k tokens used out of 200k = 85% free
|
|
81
81
|
result=$(cat "$FIXTURES/low_usage.json" | "$SCRIPT")
|
|
82
|
-
[[ "$result" == *"
|
|
82
|
+
[[ "$result" == *"%"* ]]
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
@test "uses fixture files correctly" {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"description": "Fresh session - low utilization, good cache, productive",
|
|
4
|
+
"input": {
|
|
5
|
+
"current_used": 20000,
|
|
6
|
+
"context_window": 200000,
|
|
7
|
+
"cache_read": 12000,
|
|
8
|
+
"current_input": 5000,
|
|
9
|
+
"cache_creation": 3000,
|
|
10
|
+
"prev_lines_added": 0,
|
|
11
|
+
"prev_lines_removed": 0,
|
|
12
|
+
"cur_lines_added": 150,
|
|
13
|
+
"cur_lines_removed": 10,
|
|
14
|
+
"prev_output": 0,
|
|
15
|
+
"cur_output": 1000,
|
|
16
|
+
"beta": 1.5
|
|
17
|
+
},
|
|
18
|
+
"expected": {
|
|
19
|
+
"cps": 0.968,
|
|
20
|
+
"es": 0.72,
|
|
21
|
+
"ps": 0.84,
|
|
22
|
+
"mi": 0.887
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"description": "Mid-session - moderate utilization, some cache, moderate productivity",
|
|
27
|
+
"input": {
|
|
28
|
+
"current_used": 100000,
|
|
29
|
+
"context_window": 200000,
|
|
30
|
+
"cache_read": 40000,
|
|
31
|
+
"current_input": 35000,
|
|
32
|
+
"cache_creation": 25000,
|
|
33
|
+
"prev_lines_added": 50,
|
|
34
|
+
"prev_lines_removed": 10,
|
|
35
|
+
"cur_lines_added": 150,
|
|
36
|
+
"cur_lines_removed": 20,
|
|
37
|
+
"prev_output": 500,
|
|
38
|
+
"cur_output": 1500,
|
|
39
|
+
"beta": 1.5
|
|
40
|
+
},
|
|
41
|
+
"expected": {
|
|
42
|
+
"cps": 0.646,
|
|
43
|
+
"es": 0.58,
|
|
44
|
+
"ps": 0.64,
|
|
45
|
+
"mi": 0.629
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"description": "Late session - high utilization, low cache, low productivity",
|
|
50
|
+
"input": {
|
|
51
|
+
"current_used": 170000,
|
|
52
|
+
"context_window": 200000,
|
|
53
|
+
"cache_read": 34000,
|
|
54
|
+
"current_input": 85000,
|
|
55
|
+
"cache_creation": 51000,
|
|
56
|
+
"prev_lines_added": 200,
|
|
57
|
+
"prev_lines_removed": 50,
|
|
58
|
+
"cur_lines_added": 250,
|
|
59
|
+
"cur_lines_removed": 55,
|
|
60
|
+
"prev_output": 2000,
|
|
61
|
+
"cur_output": 3000,
|
|
62
|
+
"beta": 1.5
|
|
63
|
+
},
|
|
64
|
+
"expected": {
|
|
65
|
+
"cps": 0.217,
|
|
66
|
+
"es": 0.44,
|
|
67
|
+
"ps": 0.42,
|
|
68
|
+
"mi": 0.303
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"description": "No previous entry - PS defaults to 0.5",
|
|
73
|
+
"input": {
|
|
74
|
+
"current_used": 50000,
|
|
75
|
+
"context_window": 200000,
|
|
76
|
+
"cache_read": 30000,
|
|
77
|
+
"current_input": 10000,
|
|
78
|
+
"cache_creation": 10000,
|
|
79
|
+
"prev_lines_added": null,
|
|
80
|
+
"prev_lines_removed": null,
|
|
81
|
+
"cur_lines_added": 100,
|
|
82
|
+
"cur_lines_removed": 5,
|
|
83
|
+
"prev_output": null,
|
|
84
|
+
"cur_output": 800,
|
|
85
|
+
"beta": 1.5
|
|
86
|
+
},
|
|
87
|
+
"expected": {
|
|
88
|
+
"cps": 0.875,
|
|
89
|
+
"es": 0.72,
|
|
90
|
+
"ps": 0.5,
|
|
91
|
+
"mi": 0.780
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"description": "Context window is zero - guard clause returns defaults",
|
|
96
|
+
"input": {
|
|
97
|
+
"current_used": 50000,
|
|
98
|
+
"context_window": 0,
|
|
99
|
+
"cache_read": 30000,
|
|
100
|
+
"current_input": 10000,
|
|
101
|
+
"cache_creation": 10000,
|
|
102
|
+
"prev_lines_added": 0,
|
|
103
|
+
"prev_lines_removed": 0,
|
|
104
|
+
"cur_lines_added": 100,
|
|
105
|
+
"cur_lines_removed": 5,
|
|
106
|
+
"prev_output": 0,
|
|
107
|
+
"cur_output": 800,
|
|
108
|
+
"beta": 1.5
|
|
109
|
+
},
|
|
110
|
+
"expected": {
|
|
111
|
+
"cps": 1.0,
|
|
112
|
+
"es": 1.0,
|
|
113
|
+
"ps": 0.5,
|
|
114
|
+
"mi": 1.0
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"description": "No cache at all - ES at minimum floor",
|
|
119
|
+
"input": {
|
|
120
|
+
"current_used": 80000,
|
|
121
|
+
"context_window": 200000,
|
|
122
|
+
"cache_read": 0,
|
|
123
|
+
"current_input": 80000,
|
|
124
|
+
"cache_creation": 0,
|
|
125
|
+
"prev_lines_added": 0,
|
|
126
|
+
"prev_lines_removed": 0,
|
|
127
|
+
"cur_lines_added": 50,
|
|
128
|
+
"cur_lines_removed": 10,
|
|
129
|
+
"prev_output": 0,
|
|
130
|
+
"cur_output": 500,
|
|
131
|
+
"beta": 1.5
|
|
132
|
+
},
|
|
133
|
+
"expected": {
|
|
134
|
+
"cps": 0.747,
|
|
135
|
+
"es": 0.3,
|
|
136
|
+
"ps": 0.68,
|
|
137
|
+
"mi": 0.625
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Model Intelligence (MI) score computation.
|
|
3
|
+
* Uses shared test vectors for cross-implementation parity.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const { computeMI } = require('../../scripts/statusline');
|
|
9
|
+
|
|
10
|
+
const VECTORS_PATH = path.join(__dirname, '..', 'fixtures', 'mi_test_vectors.json');
|
|
11
|
+
const vectors = JSON.parse(fs.readFileSync(VECTORS_PATH, 'utf8'));
|
|
12
|
+
|
|
13
|
+
describe('computeMI', () => {
|
|
14
|
+
test('guard clause: context_window=0 returns defaults', () => {
|
|
15
|
+
const result = computeMI(50000, 0, 30000, 50000, 0, null, 1.5);
|
|
16
|
+
expect(result.mi).toBe(1.0);
|
|
17
|
+
expect(result.cps).toBe(1.0);
|
|
18
|
+
expect(result.es).toBe(1.0);
|
|
19
|
+
expect(result.ps).toBe(0.5);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('empty context returns CPS=1', () => {
|
|
23
|
+
const result = computeMI(0, 200000, 0, 0, 0, null, 1.5);
|
|
24
|
+
expect(result.cps).toBe(1.0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('full context returns CPS=0', () => {
|
|
28
|
+
const result = computeMI(200000, 200000, 0, 200000, 0, 100, 1.5);
|
|
29
|
+
expect(result.cps).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('no cache returns ES=0.3', () => {
|
|
33
|
+
const result = computeMI(100000, 200000, 0, 100000, 0, null, 1.5);
|
|
34
|
+
expect(result.es).toBeCloseTo(0.3, 1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('all cache returns ES=1.0', () => {
|
|
38
|
+
const result = computeMI(100000, 200000, 100000, 100000, 0, null, 1.5);
|
|
39
|
+
expect(result.es).toBeCloseTo(1.0, 1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('no previous returns PS=0.5', () => {
|
|
43
|
+
const result = computeMI(100000, 200000, 50000, 100000, 0, null, 1.5);
|
|
44
|
+
expect(result.ps).toBe(0.5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('no output returns PS=0.5', () => {
|
|
48
|
+
const result = computeMI(100000, 200000, 50000, 100000, 100, 0, 1.5);
|
|
49
|
+
expect(result.ps).toBe(0.5);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('MI is always between 0 and 1', () => {
|
|
53
|
+
const utilizations = [0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0];
|
|
54
|
+
for (const u of utilizations) {
|
|
55
|
+
const used = Math.floor(u * 200000);
|
|
56
|
+
const result = computeMI(used, 200000, used / 2, used, 50, 500, 1.5);
|
|
57
|
+
expect(result.mi).toBeGreaterThanOrEqual(0);
|
|
58
|
+
expect(result.mi).toBeLessThanOrEqual(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('shared test vectors', () => {
|
|
64
|
+
vectors.forEach((vec) => {
|
|
65
|
+
test(vec.description, () => {
|
|
66
|
+
const inp = vec.input;
|
|
67
|
+
const exp = vec.expected;
|
|
68
|
+
|
|
69
|
+
const hasPrev = inp.prev_output !== null;
|
|
70
|
+
let deltaLines, deltaOutput;
|
|
71
|
+
|
|
72
|
+
if (hasPrev) {
|
|
73
|
+
const deltaLA = inp.cur_lines_added - inp.prev_lines_added;
|
|
74
|
+
const deltaLR = inp.cur_lines_removed - inp.prev_lines_removed;
|
|
75
|
+
deltaLines = deltaLA + deltaLR;
|
|
76
|
+
deltaOutput = inp.cur_output - inp.prev_output;
|
|
77
|
+
} else {
|
|
78
|
+
deltaLines = 0;
|
|
79
|
+
deltaOutput = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = computeMI(
|
|
83
|
+
inp.current_used,
|
|
84
|
+
inp.context_window,
|
|
85
|
+
inp.cache_read,
|
|
86
|
+
inp.current_used,
|
|
87
|
+
deltaLines,
|
|
88
|
+
deltaOutput,
|
|
89
|
+
inp.beta
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(result.cps).toBeCloseTo(exp.cps, 1);
|
|
93
|
+
expect(result.es).toBeCloseTo(exp.es, 1);
|
|
94
|
+
expect(result.ps).toBeCloseTo(exp.ps, 1);
|
|
95
|
+
expect(result.mi).toBeCloseTo(exp.mi, 1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -97,7 +97,7 @@ describe('statusline.js', () => {
|
|
|
97
97
|
|
|
98
98
|
test('shows free tokens indicator', async () => {
|
|
99
99
|
const result = await runScript(sampleInput);
|
|
100
|
-
expect(result.stdout).toContain('
|
|
100
|
+
expect(result.stdout).toContain('%');
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
test('shows AC indicator', async () => {
|
|
@@ -162,21 +162,21 @@ describe('statusline.js', () => {
|
|
|
162
162
|
const input = loadFixture('low_usage');
|
|
163
163
|
const result = await runScript(input);
|
|
164
164
|
expect(result.code).toBe(0);
|
|
165
|
-
expect(result.stdout).toContain('
|
|
165
|
+
expect(result.stdout).toContain('%');
|
|
166
166
|
});
|
|
167
167
|
|
|
168
168
|
test('handles medium_usage fixture', async () => {
|
|
169
169
|
const input = loadFixture('medium_usage');
|
|
170
170
|
const result = await runScript(input);
|
|
171
171
|
expect(result.code).toBe(0);
|
|
172
|
-
expect(result.stdout).toContain('
|
|
172
|
+
expect(result.stdout).toContain('%');
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
test('handles high_usage fixture', async () => {
|
|
176
176
|
const input = loadFixture('high_usage');
|
|
177
177
|
const result = await runScript(input);
|
|
178
178
|
expect(result.code).toBe(0);
|
|
179
|
-
expect(result.stdout).toContain('
|
|
179
|
+
expect(result.stdout).toContain('%');
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
test('all JSON fixtures succeed', async () => {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for configurable colors."""
|
|
2
|
+
|
|
3
|
+
from claude_statusline.core.colors import (
|
|
4
|
+
BLUE,
|
|
5
|
+
CYAN,
|
|
6
|
+
GREEN,
|
|
7
|
+
MAGENTA,
|
|
8
|
+
RED,
|
|
9
|
+
YELLOW,
|
|
10
|
+
ColorManager,
|
|
11
|
+
parse_color,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestParseColor:
|
|
16
|
+
"""Tests for parse_color()."""
|
|
17
|
+
|
|
18
|
+
def test_named_color_red(self):
|
|
19
|
+
assert parse_color("red") == "\033[0;31m"
|
|
20
|
+
|
|
21
|
+
def test_named_color_green(self):
|
|
22
|
+
assert parse_color("green") == "\033[0;32m"
|
|
23
|
+
|
|
24
|
+
def test_named_color_bright_cyan(self):
|
|
25
|
+
assert parse_color("bright_cyan") == "\033[0;96m"
|
|
26
|
+
|
|
27
|
+
def test_named_color_case_insensitive(self):
|
|
28
|
+
assert parse_color("RED") == "\033[0;31m"
|
|
29
|
+
assert parse_color("Green") == "\033[0;32m"
|
|
30
|
+
|
|
31
|
+
def test_hex_color(self):
|
|
32
|
+
result = parse_color("#ff5733")
|
|
33
|
+
assert result == "\033[38;2;255;87;51m"
|
|
34
|
+
|
|
35
|
+
def test_hex_color_uppercase(self):
|
|
36
|
+
result = parse_color("#FF5733")
|
|
37
|
+
assert result == "\033[38;2;255;87;51m"
|
|
38
|
+
|
|
39
|
+
def test_hex_color_black(self):
|
|
40
|
+
result = parse_color("#000000")
|
|
41
|
+
assert result == "\033[38;2;0;0;0m"
|
|
42
|
+
|
|
43
|
+
def test_hex_color_white(self):
|
|
44
|
+
result = parse_color("#ffffff")
|
|
45
|
+
assert result == "\033[38;2;255;255;255m"
|
|
46
|
+
|
|
47
|
+
def test_invalid_color_returns_none(self):
|
|
48
|
+
assert parse_color("nonexistent") is None
|
|
49
|
+
|
|
50
|
+
def test_empty_string_returns_none(self):
|
|
51
|
+
assert parse_color("") is None
|
|
52
|
+
|
|
53
|
+
def test_invalid_hex_returns_none(self):
|
|
54
|
+
assert parse_color("#xyz") is None
|
|
55
|
+
assert parse_color("#12345") is None
|
|
56
|
+
assert parse_color("#1234567") is None
|
|
57
|
+
|
|
58
|
+
def test_strips_whitespace(self):
|
|
59
|
+
assert parse_color(" red ") == "\033[0;31m"
|
|
60
|
+
assert parse_color(" #ff5733 ") == "\033[38;2;255;87;51m"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestColorManager:
|
|
64
|
+
"""Tests for ColorManager with overrides."""
|
|
65
|
+
|
|
66
|
+
def test_defaults_without_overrides(self):
|
|
67
|
+
cm = ColorManager(enabled=True)
|
|
68
|
+
assert cm.green == GREEN
|
|
69
|
+
assert cm.yellow == YELLOW
|
|
70
|
+
assert cm.red == RED
|
|
71
|
+
assert cm.blue == BLUE
|
|
72
|
+
assert cm.magenta == MAGENTA
|
|
73
|
+
assert cm.cyan == CYAN
|
|
74
|
+
|
|
75
|
+
def test_override_single_color(self):
|
|
76
|
+
custom = "\033[38;2;255;0;0m"
|
|
77
|
+
cm = ColorManager(enabled=True, overrides={"green": custom})
|
|
78
|
+
assert cm.green == custom
|
|
79
|
+
# Others unchanged
|
|
80
|
+
assert cm.yellow == YELLOW
|
|
81
|
+
assert cm.red == RED
|
|
82
|
+
|
|
83
|
+
def test_override_multiple_colors(self):
|
|
84
|
+
overrides = {
|
|
85
|
+
"green": "\033[38;2;0;255;0m",
|
|
86
|
+
"red": "\033[38;2;255;0;0m",
|
|
87
|
+
}
|
|
88
|
+
cm = ColorManager(enabled=True, overrides=overrides)
|
|
89
|
+
assert cm.green == overrides["green"]
|
|
90
|
+
assert cm.red == overrides["red"]
|
|
91
|
+
assert cm.yellow == YELLOW # not overridden
|
|
92
|
+
|
|
93
|
+
def test_disabled_returns_empty(self):
|
|
94
|
+
overrides = {"green": "\033[38;2;0;255;0m"}
|
|
95
|
+
cm = ColorManager(enabled=False, overrides=overrides)
|
|
96
|
+
assert cm.green == ""
|
|
97
|
+
assert cm.yellow == ""
|
|
98
|
+
assert cm.bold == ""
|
|
99
|
+
assert cm.reset == ""
|
|
100
|
+
|
|
101
|
+
def test_bold_dim_reset_not_overridable(self):
|
|
102
|
+
"""bold, dim, reset are always the standard ANSI codes."""
|
|
103
|
+
cm = ColorManager(enabled=True, overrides={"bold": "custom"})
|
|
104
|
+
# bold is not in the _get path, it uses the hardcoded value
|
|
105
|
+
assert cm.bold == "\033[1m"
|