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.
- package/CHANGELOG.md +30 -0
- package/CLAUDE.md +12 -0
- package/README.md +35 -25
- 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/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/images/v1.6.1.png +0 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/statusline.js +87 -15
- package/scripts/statusline.py +169 -51
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +52 -10
- package/src/claude_statusline/cli/explain.py +228 -0
- package/src/claude_statusline/cli/statusline.py +16 -20
- package/src/claude_statusline/core/colors.py +78 -9
- package/src/claude_statusline/core/config.py +51 -9
- package/src/claude_statusline/core/git.py +16 -5
- package/tests/bash/test_delta_parity.bats +199 -0
- 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
|
@@ -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
|