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.
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Delta calculation parity tests: Python vs Node.js statusline scripts
4
+ # Verifies both implementations compute identical deltas from identical state.
5
+
6
+ strip_ansi() {
7
+ printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g'
8
+ }
9
+
10
+ setup() {
11
+ PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
12
+ PYTHON_SCRIPT="$PROJECT_ROOT/scripts/statusline.py"
13
+ NODE_SCRIPT="$PROJECT_ROOT/scripts/statusline.js"
14
+
15
+ # Create isolated temp HOME so state files don't pollute real ~/.claude/
16
+ TEST_HOME=$(mktemp -d)
17
+ export HOME="$TEST_HOME"
18
+
19
+ # Normalize terminal width for deterministic output
20
+ export COLUMNS=200
21
+
22
+ # Enable delta display
23
+ mkdir -p "$TEST_HOME/.claude"
24
+ echo "show_delta=true" > "$TEST_HOME/.claude/statusline.conf"
25
+
26
+ # Create a non-git temp working directory so both scripts skip git info
27
+ TEST_WORKDIR=$(mktemp -d)
28
+ cd "$TEST_WORKDIR"
29
+ }
30
+
31
+ teardown() {
32
+ rm -rf "$TEST_HOME"
33
+ rm -rf "$TEST_WORKDIR"
34
+ }
35
+
36
+ # Helper: create a JSON payload with specific token values and session_id
37
+ make_payload() {
38
+ local session="$1" input_tokens="$2" cache_creation="$3" cache_read="$4"
39
+ cat <<EOF
40
+ {
41
+ "model": {"display_name": "Opus 4.5"},
42
+ "workspace": {"current_dir": "$TEST_WORKDIR", "project_dir": "$TEST_WORKDIR"},
43
+ "session_id": "$session",
44
+ "context_window": {
45
+ "context_window_size": 200000,
46
+ "current_usage": {
47
+ "input_tokens": $input_tokens,
48
+ "cache_creation_input_tokens": $cache_creation,
49
+ "cache_read_input_tokens": $cache_read
50
+ }
51
+ }
52
+ }
53
+ EOF
54
+ }
55
+
56
+ # ============================================
57
+ # Delta Calculation Parity Tests
58
+ # ============================================
59
+
60
+ @test "delta parity: both scripts show identical delta after two sequential payloads" {
61
+ # First payload: 30k context usage (10k input + 10k cache_create + 10k cache_read)
62
+ local py_session="delta-parity-py"
63
+ local node_session="delta-parity-node"
64
+
65
+ local payload1_py=$(make_payload "$py_session" 10000 10000 10000)
66
+ local payload1_node=$(make_payload "$node_session" 10000 10000 10000)
67
+
68
+ # Run first payload (seeds state file, no delta shown)
69
+ echo "$payload1_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
70
+ echo "$payload1_node" | node "$NODE_SCRIPT" > /dev/null 2>&1
71
+
72
+ # Second payload: 80k context usage (40k input + 20k cache_create + 20k cache_read)
73
+ # Expected delta = 80k - 30k = 50k
74
+ local payload2_py=$(make_payload "$py_session" 40000 20000 20000)
75
+ local payload2_node=$(make_payload "$node_session" 40000 20000 20000)
76
+
77
+ local py_output=$(echo "$payload2_py" | python3 "$PYTHON_SCRIPT" 2>/dev/null)
78
+ local node_output=$(echo "$payload2_node" | node "$NODE_SCRIPT" 2>/dev/null)
79
+
80
+ local py_clean=$(strip_ansi "$py_output")
81
+ local node_clean=$(strip_ansi "$node_output")
82
+
83
+ # Both should contain [+50,000] delta
84
+ if [[ "$py_clean" != *"[+50,000]"* ]]; then
85
+ echo "Python output missing expected delta [+50,000]"
86
+ echo "Python output: $py_clean"
87
+ return 1
88
+ fi
89
+ if [[ "$node_clean" != *"[+50,000]"* ]]; then
90
+ echo "Node.js output missing expected delta [+50,000]"
91
+ echo "Node.js output: $node_clean"
92
+ return 1
93
+ fi
94
+
95
+ # Compare outputs ignoring session_id suffix (which intentionally differs)
96
+ # Strip the trailing session ID from both outputs for comparison
97
+ local py_no_session=$(echo "$py_clean" | sed 's/ delta-parity-py$//')
98
+ local node_no_session=$(echo "$node_clean" | sed 's/ delta-parity-node$//')
99
+
100
+ if [ "$py_no_session" != "$node_no_session" ]; then
101
+ echo "DELTA PARITY MISMATCH (ignoring session_id)"
102
+ echo "Python: $py_no_session"
103
+ echo "Node.js: $node_no_session"
104
+ return 1
105
+ fi
106
+ }
107
+
108
+ @test "delta parity: no delta shown on first run (no previous state)" {
109
+ local py_session="delta-first-py"
110
+ local node_session="delta-first-node"
111
+
112
+ local payload_py=$(make_payload "$py_session" 50000 10000 5000)
113
+ local payload_node=$(make_payload "$node_session" 50000 10000 5000)
114
+
115
+ local py_output=$(echo "$payload_py" | python3 "$PYTHON_SCRIPT" 2>/dev/null)
116
+ local node_output=$(echo "$payload_node" | node "$NODE_SCRIPT" 2>/dev/null)
117
+
118
+ local py_clean=$(strip_ansi "$py_output")
119
+ local node_clean=$(strip_ansi "$node_output")
120
+
121
+ # Neither should show a delta on first run
122
+ if [[ "$py_clean" == *"[+"* ]]; then
123
+ echo "Python should not show delta on first run"
124
+ echo "Python output: $py_clean"
125
+ return 1
126
+ fi
127
+ if [[ "$node_clean" == *"[+"* ]]; then
128
+ echo "Node.js should not show delta on first run"
129
+ echo "Node.js output: $node_clean"
130
+ return 1
131
+ fi
132
+ }
133
+
134
+ @test "delta parity: no delta shown when tokens decrease (context reset)" {
135
+ local py_session="delta-decrease-py"
136
+ local node_session="delta-decrease-node"
137
+
138
+ # First payload: high usage
139
+ local payload1_py=$(make_payload "$py_session" 80000 20000 10000)
140
+ local payload1_node=$(make_payload "$node_session" 80000 20000 10000)
141
+
142
+ echo "$payload1_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
143
+ echo "$payload1_node" | node "$NODE_SCRIPT" > /dev/null 2>&1
144
+
145
+ # Second payload: lower usage (context was reset/compacted)
146
+ local payload2_py=$(make_payload "$py_session" 20000 5000 5000)
147
+ local payload2_node=$(make_payload "$node_session" 20000 5000 5000)
148
+
149
+ local py_output=$(echo "$payload2_py" | python3 "$PYTHON_SCRIPT" 2>/dev/null)
150
+ local node_output=$(echo "$payload2_node" | node "$NODE_SCRIPT" 2>/dev/null)
151
+
152
+ local py_clean=$(strip_ansi "$py_output")
153
+ local node_clean=$(strip_ansi "$node_output")
154
+
155
+ # Neither should show delta when tokens decrease
156
+ if [[ "$py_clean" == *"[+"* ]]; then
157
+ echo "Python should not show delta when tokens decrease"
158
+ echo "Python output: $py_clean"
159
+ return 1
160
+ fi
161
+ if [[ "$node_clean" == *"[+"* ]]; then
162
+ echo "Node.js should not show delta when tokens decrease"
163
+ echo "Node.js output: $node_clean"
164
+ return 1
165
+ fi
166
+ }
167
+
168
+ @test "delta parity: duplicate guard prevents writing when tokens unchanged" {
169
+ local py_session="delta-dedup-py"
170
+ local node_session="delta-dedup-node"
171
+
172
+ local payload_py=$(make_payload "$py_session" 50000 10000 5000)
173
+ local payload_node=$(make_payload "$node_session" 50000 10000 5000)
174
+
175
+ # Run same payload three times
176
+ echo "$payload_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
177
+ echo "$payload_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
178
+ echo "$payload_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
179
+
180
+ echo "$payload_node" | node "$NODE_SCRIPT" > /dev/null 2>&1
181
+ echo "$payload_node" | node "$NODE_SCRIPT" > /dev/null 2>&1
182
+ echo "$payload_node" | node "$NODE_SCRIPT" > /dev/null 2>&1
183
+
184
+ local py_state="$TEST_HOME/.claude/statusline/statusline.${py_session}.state"
185
+ local node_state="$TEST_HOME/.claude/statusline/statusline.${node_session}.state"
186
+
187
+ # Both should have written only 1 line (duplicate guard)
188
+ local py_lines=$(wc -l < "$py_state" | tr -d ' ')
189
+ local node_lines=$(wc -l < "$node_state" | tr -d ' ')
190
+
191
+ if [ "$py_lines" -ne 1 ]; then
192
+ echo "Python wrote $py_lines lines (expected 1 — duplicate guard failed)"
193
+ return 1
194
+ fi
195
+ if [ "$node_lines" -ne 1 ]; then
196
+ echo "Node.js wrote $node_lines lines (expected 1 — duplicate guard failed)"
197
+ return 1
198
+ fi
199
+ }
@@ -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"
@@ -0,0 +1,78 @@
1
+ """Tests for color configuration in Config."""
2
+
3
+ from claude_statusline.core.config import Config
4
+
5
+
6
+ class TestConfigColorOverrides:
7
+ """Tests for loading color overrides from config file."""
8
+
9
+ def test_no_color_overrides_by_default(self, tmp_path):
10
+ config_file = tmp_path / "statusline.conf"
11
+ config_file.write_text("autocompact=true\n")
12
+ config = Config.load(config_path=config_file)
13
+ assert config.color_overrides == {}
14
+
15
+ def test_named_color_override(self, tmp_path):
16
+ config_file = tmp_path / "statusline.conf"
17
+ config_file.write_text("color_green=bright_cyan\n")
18
+ config = Config.load(config_path=config_file)
19
+ assert "green" in config.color_overrides
20
+ assert config.color_overrides["green"] == "\033[0;96m"
21
+
22
+ def test_hex_color_override(self, tmp_path):
23
+ config_file = tmp_path / "statusline.conf"
24
+ config_file.write_text("color_red=#f7768e\n")
25
+ config = Config.load(config_path=config_file)
26
+ assert "red" in config.color_overrides
27
+ assert config.color_overrides["red"] == "\033[38;2;247;118;142m"
28
+
29
+ def test_multiple_color_overrides(self, tmp_path):
30
+ config_file = tmp_path / "statusline.conf"
31
+ config_file.write_text("color_green=#7dcfff\ncolor_red=#f7768e\ncolor_blue=bright_blue\n")
32
+ config = Config.load(config_path=config_file)
33
+ assert len(config.color_overrides) == 3
34
+ assert "green" in config.color_overrides
35
+ assert "red" in config.color_overrides
36
+ assert "blue" in config.color_overrides
37
+
38
+ def test_invalid_color_ignored(self, tmp_path, capsys):
39
+ config_file = tmp_path / "statusline.conf"
40
+ config_file.write_text("color_green=nonexistent_color\n")
41
+ config = Config.load(config_path=config_file)
42
+ assert config.color_overrides == {}
43
+
44
+ def test_color_overrides_mixed_with_booleans(self, tmp_path):
45
+ config_file = tmp_path / "statusline.conf"
46
+ config_file.write_text("autocompact=false\ntoken_detail=true\ncolor_yellow=#e0af68\n")
47
+ config = Config.load(config_path=config_file)
48
+ assert config.autocompact is False
49
+ assert config.token_detail is True
50
+ assert "yellow" in config.color_overrides
51
+ assert config.color_overrides["yellow"] == "\033[38;2;224;175;104m"
52
+
53
+ def test_color_overrides_in_to_dict(self, tmp_path):
54
+ config_file = tmp_path / "statusline.conf"
55
+ config_file.write_text("color_cyan=#00ffff\n")
56
+ config = Config.load(config_path=config_file)
57
+ d = config.to_dict()
58
+ assert "color_overrides" in d
59
+ assert "cyan" in d["color_overrides"]
60
+
61
+ def test_unknown_color_key_ignored(self, tmp_path):
62
+ config_file = tmp_path / "statusline.conf"
63
+ config_file.write_text("color_purple=magenta\n")
64
+ config = Config.load(config_path=config_file)
65
+ assert config.color_overrides == {}
66
+
67
+ def test_all_six_color_slots(self, tmp_path):
68
+ config_file = tmp_path / "statusline.conf"
69
+ config_file.write_text(
70
+ "color_green=green\n"
71
+ "color_yellow=yellow\n"
72
+ "color_red=red\n"
73
+ "color_blue=blue\n"
74
+ "color_magenta=magenta\n"
75
+ "color_cyan=cyan\n"
76
+ )
77
+ config = Config.load(config_path=config_file)
78
+ assert len(config.color_overrides) == 6
@@ -0,0 +1,177 @@
1
+ """Tests for the context-stats explain command."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
9
+ FIXTURES_DIR = PROJECT_ROOT / "tests" / "fixtures" / "json"
10
+
11
+
12
+ class TestExplainCommand:
13
+ """Tests for `context-stats explain`."""
14
+
15
+ def _run_explain(self, input_data, extra_args=None):
16
+ """Run context-stats explain with JSON input and return stdout."""
17
+ cmd = [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain"]
18
+ if extra_args:
19
+ cmd.extend(extra_args)
20
+ result = subprocess.run(
21
+ cmd,
22
+ input=json.dumps(input_data),
23
+ capture_output=True,
24
+ text=True,
25
+ timeout=10,
26
+ )
27
+ return result
28
+
29
+ def test_explain_shows_model(self):
30
+ data = {"model": {"display_name": "Opus 4.5", "id": "claude-opus-4-5"}}
31
+ result = self._run_explain(data)
32
+ assert result.returncode == 0
33
+ assert "Opus 4.5" in result.stdout
34
+ assert "claude-opus-4-5" in result.stdout
35
+
36
+ def test_explain_shows_workspace(self):
37
+ data = {
38
+ "workspace": {
39
+ "current_dir": "/home/user/project",
40
+ "project_dir": "/home/user/project",
41
+ }
42
+ }
43
+ result = self._run_explain(data)
44
+ assert result.returncode == 0
45
+ assert "/home/user/project" in result.stdout
46
+
47
+ def test_explain_shows_context_window(self):
48
+ data = {
49
+ "context_window": {
50
+ "context_window_size": 200000,
51
+ "current_usage": {
52
+ "input_tokens": 50000,
53
+ "cache_creation_input_tokens": 10000,
54
+ "cache_read_input_tokens": 20000,
55
+ },
56
+ }
57
+ }
58
+ result = self._run_explain(data)
59
+ assert result.returncode == 0
60
+ assert "200,000" in result.stdout
61
+ assert "50,000" in result.stdout
62
+ assert "context_used" in result.stdout
63
+
64
+ def test_explain_shows_cost(self):
65
+ data = {
66
+ "cost": {
67
+ "total_cost_usd": 0.1234,
68
+ "total_lines_added": 100,
69
+ "total_lines_removed": 50,
70
+ }
71
+ }
72
+ result = self._run_explain(data)
73
+ assert result.returncode == 0
74
+ assert "$0.1234" in result.stdout
75
+
76
+ def test_explain_shows_session(self):
77
+ data = {"session_id": "abc-123", "version": "2.0.0"}
78
+ result = self._run_explain(data)
79
+ assert result.returncode == 0
80
+ assert "abc-123" in result.stdout
81
+ assert "2.0.0" in result.stdout
82
+
83
+ def test_explain_shows_absent_fields(self):
84
+ data = {}
85
+ result = self._run_explain(data)
86
+ assert result.returncode == 0
87
+ assert "(absent)" in result.stdout
88
+
89
+ def test_explain_shows_raw_json(self):
90
+ data = {"model": {"display_name": "Test"}}
91
+ result = self._run_explain(data)
92
+ assert result.returncode == 0
93
+ assert "Raw JSON" in result.stdout
94
+ assert '"display_name": "Test"' in result.stdout
95
+
96
+ def test_explain_shows_config(self):
97
+ data = {}
98
+ result = self._run_explain(data)
99
+ assert result.returncode == 0
100
+ assert "Active Config" in result.stdout
101
+
102
+ def test_explain_with_full_fixture(self):
103
+ with open(FIXTURES_DIR / "valid_full.json") as f:
104
+ data = json.load(f)
105
+ result = self._run_explain(data)
106
+ assert result.returncode == 0
107
+ assert "Opus 4.5" in result.stdout
108
+ assert "test-session-123" in result.stdout
109
+
110
+ def test_explain_invalid_json_fails(self):
111
+ result = subprocess.run(
112
+ [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain"],
113
+ input="not valid json",
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=10,
117
+ )
118
+ assert result.returncode == 1
119
+ assert "invalid JSON" in result.stderr
120
+
121
+ def test_explain_shows_derived_free_tokens(self):
122
+ data = {
123
+ "context_window": {
124
+ "context_window_size": 200000,
125
+ "current_usage": {
126
+ "input_tokens": 50000,
127
+ "cache_creation_input_tokens": 10000,
128
+ "cache_read_input_tokens": 20000,
129
+ },
130
+ }
131
+ }
132
+ result = self._run_explain(data)
133
+ assert result.returncode == 0
134
+ # 200000 - (50000+10000+20000) = 120000
135
+ assert "120,000" in result.stdout
136
+ assert "60.0%" in result.stdout
137
+
138
+ def test_explain_no_color_flag(self):
139
+ data = {"model": {"display_name": "Test"}}
140
+ result = subprocess.run(
141
+ [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain", "--no-color"],
142
+ input=json.dumps(data),
143
+ capture_output=True,
144
+ text=True,
145
+ timeout=10,
146
+ )
147
+ assert result.returncode == 0
148
+ assert "Test" in result.stdout
149
+ # No ANSI escape codes when --no-color is passed
150
+ assert "\x1b[" not in result.stdout
151
+
152
+ def test_explain_shows_vim_mode(self):
153
+ data = {"vim": {"mode": "NORMAL"}}
154
+ result = self._run_explain(data)
155
+ assert result.returncode == 0
156
+ assert "NORMAL" in result.stdout
157
+ assert "Extensions" in result.stdout
158
+
159
+ def test_explain_shows_agent(self):
160
+ data = {"agent": {"name": "my-agent"}}
161
+ result = self._run_explain(data)
162
+ assert result.returncode == 0
163
+ assert "my-agent" in result.stdout
164
+ assert "Extensions" in result.stdout
165
+
166
+ def test_explain_shows_output_style(self):
167
+ data = {"output_style": {"name": "concise"}}
168
+ result = self._run_explain(data)
169
+ assert result.returncode == 0
170
+ assert "concise" in result.stdout
171
+ assert "Extensions" in result.stdout
172
+
173
+ def test_explain_no_extensions_section_when_absent(self):
174
+ data = {"model": {"display_name": "Test"}}
175
+ result = self._run_explain(data)
176
+ assert result.returncode == 0
177
+ assert "Extensions" not in result.stdout