cc-context-stats 1.7.0 → 1.8.1
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/package.json +9 -1
- package/scripts/context-stats.sh +1 -1
- package/scripts/statusline.js +128 -18
- package/.editorconfig +0 -60
- package/.eslintrc.json +0 -35
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
- package/.github/dependabot.yml +0 -44
- package/.github/workflows/ci.yml +0 -294
- package/.github/workflows/release.yml +0 -151
- package/.pre-commit-config.yaml +0 -74
- package/.prettierrc +0 -33
- package/.shellcheckrc +0 -10
- package/CHANGELOG.md +0 -163
- package/CLAUDE.md +0 -66
- package/CODE_OF_CONDUCT.md +0 -59
- package/CONTRIBUTING.md +0 -240
- package/RELEASE_NOTES.md +0 -19
- package/SECURITY.md +0 -44
- package/TODOS.md +0 -72
- package/assets/logo/favicon.svg +0 -19
- package/assets/logo/logo-black.svg +0 -24
- package/assets/logo/logo-full.svg +0 -40
- package/assets/logo/logo-icon.svg +0 -27
- package/assets/logo/logo-mark.svg +0 -28
- package/assets/logo/logo-white.svg +0 -24
- package/assets/logo/logo-wordmark.svg +0 -6
- package/config/settings-example.json +0 -7
- package/config/settings-node.json +0 -7
- package/config/settings-python.json +0 -7
- package/docs/ARCHITECTURE.md +0 -128
- package/docs/CSV_FORMAT.md +0 -42
- package/docs/DEPLOYMENT.md +0 -71
- package/docs/DEVELOPMENT.md +0 -161
- package/docs/configuration.md +0 -118
- package/docs/context-stats.md +0 -143
- package/docs/installation.md +0 -255
- package/docs/scripts.md +0 -140
- package/docs/troubleshooting.md +0 -278
- package/images/claude-statusline-token-graph.gif +0 -0
- package/images/claude-statusline.png +0 -0
- package/images/context-status-dumbzone.png +0 -0
- package/images/context-status.png +0 -0
- package/images/statusline-detail.png +0 -0
- package/images/token-graph.jpeg +0 -0
- package/images/token-graph.png +0 -0
- package/images/v1.6.1.png +0 -0
- package/install +0 -351
- package/install.sh +0 -298
- package/jest.config.js +0 -11
- package/pyproject.toml +0 -115
- package/requirements-dev.txt +0 -12
- package/scripts/statusline-full.sh +0 -304
- package/scripts/statusline-git.sh +0 -88
- package/scripts/statusline-minimal.sh +0 -67
- package/scripts/statusline.py +0 -485
- package/src/claude_statusline/__init__.py +0 -11
- package/src/claude_statusline/__main__.py +0 -6
- package/src/claude_statusline/cli/__init__.py +0 -1
- package/src/claude_statusline/cli/context_stats.py +0 -512
- package/src/claude_statusline/cli/explain.py +0 -228
- package/src/claude_statusline/cli/statusline.py +0 -169
- package/src/claude_statusline/core/__init__.py +0 -1
- package/src/claude_statusline/core/colors.py +0 -124
- package/src/claude_statusline/core/config.py +0 -148
- package/src/claude_statusline/core/git.py +0 -78
- package/src/claude_statusline/core/state.py +0 -323
- package/src/claude_statusline/formatters/__init__.py +0 -1
- package/src/claude_statusline/formatters/layout.py +0 -67
- package/src/claude_statusline/formatters/time.py +0 -50
- package/src/claude_statusline/formatters/tokens.py +0 -70
- package/src/claude_statusline/graphs/__init__.py +0 -1
- package/src/claude_statusline/graphs/renderer.py +0 -366
- package/src/claude_statusline/graphs/statistics.py +0 -92
- package/src/claude_statusline/ui/__init__.py +0 -1
- package/src/claude_statusline/ui/icons.py +0 -93
- package/src/claude_statusline/ui/waiting.py +0 -62
- package/tests/bash/test_delta_parity.bats +0 -199
- package/tests/bash/test_install.bats +0 -29
- package/tests/bash/test_parity.bats +0 -315
- package/tests/bash/test_statusline_full.bats +0 -139
- package/tests/bash/test_statusline_git.bats +0 -42
- package/tests/bash/test_statusline_minimal.bats +0 -37
- package/tests/fixtures/json/comma_in_path.json +0 -31
- package/tests/fixtures/json/high_usage.json +0 -17
- package/tests/fixtures/json/low_usage.json +0 -17
- package/tests/fixtures/json/medium_usage.json +0 -17
- package/tests/fixtures/json/valid_full.json +0 -30
- package/tests/fixtures/json/valid_minimal.json +0 -9
- package/tests/node/rotation.test.js +0 -89
- package/tests/node/statusline.test.js +0 -240
- package/tests/python/conftest.py +0 -84
- package/tests/python/test_colors.py +0 -105
- package/tests/python/test_config_colors.py +0 -78
- package/tests/python/test_data_pipeline.py +0 -446
- package/tests/python/test_explain.py +0 -177
- package/tests/python/test_icons.py +0 -152
- package/tests/python/test_layout.py +0 -127
- package/tests/python/test_state_rotation_validation.py +0 -232
- package/tests/python/test_statusline.py +0 -215
- package/tests/python/test_waiting.py +0 -127
|
@@ -1,177 +0,0 @@
|
|
|
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
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
"""Tests for activity tier detection."""
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
from claude_statusline.core.state import StateEntry
|
|
6
|
-
from claude_statusline.graphs.statistics import detect_spike
|
|
7
|
-
from claude_statusline.ui.icons import (
|
|
8
|
-
ActivityTier,
|
|
9
|
-
get_activity_tier,
|
|
10
|
-
get_tier_label,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _make_entry(
|
|
15
|
-
timestamp: int = 0,
|
|
16
|
-
current_input: int = 1000,
|
|
17
|
-
cache_creation: int = 0,
|
|
18
|
-
cache_read: int = 0,
|
|
19
|
-
context_window_size: int = 200_000,
|
|
20
|
-
) -> StateEntry:
|
|
21
|
-
"""Helper to create a StateEntry for testing."""
|
|
22
|
-
return StateEntry(
|
|
23
|
-
timestamp=timestamp,
|
|
24
|
-
total_input_tokens=current_input,
|
|
25
|
-
total_output_tokens=0,
|
|
26
|
-
current_input_tokens=current_input,
|
|
27
|
-
current_output_tokens=0,
|
|
28
|
-
cache_creation=cache_creation,
|
|
29
|
-
cache_read=cache_read,
|
|
30
|
-
cost_usd=0.0,
|
|
31
|
-
lines_added=0,
|
|
32
|
-
lines_removed=0,
|
|
33
|
-
session_id="test",
|
|
34
|
-
model_id="test-model",
|
|
35
|
-
workspace_project_dir="/tmp/test",
|
|
36
|
-
context_window_size=context_window_size,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class TestDetectSpike:
|
|
41
|
-
"""Tests for spike detection logic."""
|
|
42
|
-
|
|
43
|
-
def test_empty_deltas(self):
|
|
44
|
-
assert detect_spike([], 200_000) is False
|
|
45
|
-
|
|
46
|
-
def test_no_spike_small_delta(self):
|
|
47
|
-
deltas = [1000, 1200, 1100, 900, 1000]
|
|
48
|
-
assert detect_spike(deltas, 200_000) is False
|
|
49
|
-
|
|
50
|
-
def test_spike_absolute_threshold(self):
|
|
51
|
-
"""Delta > 15% of context window is a spike."""
|
|
52
|
-
deltas = [1000, 1200, 1100, 900, 35_000]
|
|
53
|
-
assert detect_spike(deltas, 200_000) is True
|
|
54
|
-
|
|
55
|
-
def test_spike_relative_threshold(self):
|
|
56
|
-
"""Delta > 3x rolling average is a spike."""
|
|
57
|
-
deltas = [100, 100, 100, 100, 500]
|
|
58
|
-
assert detect_spike(deltas, 200_000) is True
|
|
59
|
-
|
|
60
|
-
def test_no_spike_when_all_similar(self):
|
|
61
|
-
deltas = [1000, 1000, 1000, 1000, 1000]
|
|
62
|
-
assert detect_spike(deltas, 200_000) is False
|
|
63
|
-
|
|
64
|
-
def test_single_delta_no_spike(self):
|
|
65
|
-
"""Single delta can't be a relative spike (no average to compare)."""
|
|
66
|
-
deltas = [1000]
|
|
67
|
-
assert detect_spike(deltas, 200_000) is False
|
|
68
|
-
|
|
69
|
-
def test_spike_with_zero_context_window(self):
|
|
70
|
-
"""Only relative threshold applies when context_window is 0."""
|
|
71
|
-
deltas = [100, 100, 100, 100, 500]
|
|
72
|
-
assert detect_spike(deltas, 0) is True
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class TestActivityTier:
|
|
76
|
-
"""Tests for activity tier determination."""
|
|
77
|
-
|
|
78
|
-
def test_idle_with_no_entries(self):
|
|
79
|
-
assert get_activity_tier([], 200_000) == ActivityTier.IDLE
|
|
80
|
-
|
|
81
|
-
def test_idle_with_single_entry(self):
|
|
82
|
-
entry = _make_entry(timestamp=int(time.time()))
|
|
83
|
-
assert get_activity_tier([entry], 200_000) == ActivityTier.IDLE
|
|
84
|
-
|
|
85
|
-
def test_idle_when_stale(self):
|
|
86
|
-
"""Entries older than 30s should be idle."""
|
|
87
|
-
old_time = int(time.time()) - 60
|
|
88
|
-
entries = [
|
|
89
|
-
_make_entry(timestamp=old_time, current_input=1000),
|
|
90
|
-
_make_entry(timestamp=old_time + 5, current_input=2000),
|
|
91
|
-
]
|
|
92
|
-
assert get_activity_tier(entries, 200_000) == ActivityTier.IDLE
|
|
93
|
-
|
|
94
|
-
def test_low_activity(self):
|
|
95
|
-
"""Small delta (<2% of window) = low."""
|
|
96
|
-
now = int(time.time())
|
|
97
|
-
entries = [
|
|
98
|
-
_make_entry(timestamp=now - 5, current_input=1000),
|
|
99
|
-
_make_entry(timestamp=now, current_input=2000), # delta=1000, 0.5% of 200k
|
|
100
|
-
]
|
|
101
|
-
assert get_activity_tier(entries, 200_000) == ActivityTier.LOW
|
|
102
|
-
|
|
103
|
-
def test_medium_activity(self):
|
|
104
|
-
"""Delta 2-5% of window = medium."""
|
|
105
|
-
now = int(time.time())
|
|
106
|
-
entries = [
|
|
107
|
-
_make_entry(timestamp=now - 5, current_input=1000),
|
|
108
|
-
_make_entry(timestamp=now, current_input=7000), # delta=6000, 3% of 200k
|
|
109
|
-
]
|
|
110
|
-
assert get_activity_tier(entries, 200_000) == ActivityTier.MEDIUM
|
|
111
|
-
|
|
112
|
-
def test_high_activity(self):
|
|
113
|
-
"""Delta 5-15% of window = high."""
|
|
114
|
-
now = int(time.time())
|
|
115
|
-
entries = [
|
|
116
|
-
_make_entry(timestamp=now - 5, current_input=1000),
|
|
117
|
-
_make_entry(timestamp=now, current_input=21000), # delta=20000, 10% of 200k
|
|
118
|
-
]
|
|
119
|
-
assert get_activity_tier(entries, 200_000) == ActivityTier.HIGH
|
|
120
|
-
|
|
121
|
-
def test_spike_activity(self):
|
|
122
|
-
"""Delta > 15% of window = spike."""
|
|
123
|
-
now = int(time.time())
|
|
124
|
-
entries = [
|
|
125
|
-
_make_entry(timestamp=now - 5, current_input=1000),
|
|
126
|
-
_make_entry(timestamp=now, current_input=41000), # delta=40000, 20% of 200k
|
|
127
|
-
]
|
|
128
|
-
assert get_activity_tier(entries, 200_000) == ActivityTier.SPIKE
|
|
129
|
-
|
|
130
|
-
def test_zero_delta_is_idle(self):
|
|
131
|
-
"""No token change = idle (even if recent)."""
|
|
132
|
-
now = int(time.time())
|
|
133
|
-
entries = [
|
|
134
|
-
_make_entry(timestamp=now - 5, current_input=1000),
|
|
135
|
-
_make_entry(timestamp=now, current_input=1000), # delta=0
|
|
136
|
-
]
|
|
137
|
-
assert get_activity_tier(entries, 200_000) == ActivityTier.IDLE
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class TestGetTierLabel:
|
|
141
|
-
"""Tests for tier labels."""
|
|
142
|
-
|
|
143
|
-
def test_all_tiers_have_labels(self):
|
|
144
|
-
for tier in ActivityTier:
|
|
145
|
-
label = get_tier_label(tier)
|
|
146
|
-
assert len(label) > 0
|
|
147
|
-
|
|
148
|
-
def test_idle_label(self):
|
|
149
|
-
assert get_tier_label(ActivityTier.IDLE) == "Idle"
|
|
150
|
-
|
|
151
|
-
def test_spike_label(self):
|
|
152
|
-
assert get_tier_label(ActivityTier.SPIKE) == "Spike!"
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
"""Tests for layout helpers (visible_width, get_terminal_width, fit_to_width)."""
|
|
2
|
-
|
|
3
|
-
from unittest.mock import patch
|
|
4
|
-
|
|
5
|
-
from claude_statusline.formatters.layout import (
|
|
6
|
-
fit_to_width,
|
|
7
|
-
get_terminal_width,
|
|
8
|
-
visible_width,
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestVisibleWidth:
|
|
13
|
-
"""Tests for visible_width()."""
|
|
14
|
-
|
|
15
|
-
def test_plain_text(self):
|
|
16
|
-
assert visible_width("hello") == 5
|
|
17
|
-
|
|
18
|
-
def test_ansi_colored_string(self):
|
|
19
|
-
s = "\033[0;32mgreen\033[0m"
|
|
20
|
-
assert visible_width(s) == 5
|
|
21
|
-
|
|
22
|
-
def test_multiple_ansi_codes(self):
|
|
23
|
-
s = "\033[2m[\033[0m\033[0;34mdir\033[0m"
|
|
24
|
-
assert visible_width(s) == 4 # [dir
|
|
25
|
-
|
|
26
|
-
def test_empty_string(self):
|
|
27
|
-
assert visible_width("") == 0
|
|
28
|
-
|
|
29
|
-
def test_unicode_icons(self):
|
|
30
|
-
assert visible_width("\u25cb") == 1 # ○
|
|
31
|
-
assert visible_width("\u26a1") == 1 # ⚡
|
|
32
|
-
|
|
33
|
-
def test_ansi_with_semicolons(self):
|
|
34
|
-
s = "\033[1;31;42mtext\033[0m"
|
|
35
|
-
assert visible_width(s) == 4
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class TestGetTerminalWidth:
|
|
39
|
-
"""Tests for get_terminal_width()."""
|
|
40
|
-
|
|
41
|
-
def test_fallback_to_200_when_no_columns_env(self):
|
|
42
|
-
"""When COLUMNS is not set and shutil returns 80, use 200 (Claude Code subprocess)."""
|
|
43
|
-
with (
|
|
44
|
-
patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
|
|
45
|
-
patch.dict("os.environ", {}, clear=False),
|
|
46
|
-
):
|
|
47
|
-
# Remove COLUMNS if present
|
|
48
|
-
import os
|
|
49
|
-
|
|
50
|
-
os.environ.pop("COLUMNS", None)
|
|
51
|
-
mock.return_value = type("Size", (), {"columns": 80})()
|
|
52
|
-
assert get_terminal_width() == 200
|
|
53
|
-
|
|
54
|
-
def test_respects_columns_env_80(self):
|
|
55
|
-
"""When COLUMNS=80 is explicitly set, trust it."""
|
|
56
|
-
with (
|
|
57
|
-
patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
|
|
58
|
-
patch.dict("os.environ", {"COLUMNS": "80"}),
|
|
59
|
-
):
|
|
60
|
-
mock.return_value = type("Size", (), {"columns": 80})()
|
|
61
|
-
assert get_terminal_width() == 80
|
|
62
|
-
|
|
63
|
-
def test_custom_width(self):
|
|
64
|
-
with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
|
|
65
|
-
mock.return_value = type("Size", (), {"columns": 120})()
|
|
66
|
-
assert get_terminal_width() == 120
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class TestFitToWidth:
|
|
70
|
-
"""Tests for fit_to_width()."""
|
|
71
|
-
|
|
72
|
-
def test_all_parts_fit(self):
|
|
73
|
-
parts = ["base", " | git", " | ctx"]
|
|
74
|
-
result = fit_to_width(parts, 80)
|
|
75
|
-
assert result == "base | git | ctx"
|
|
76
|
-
|
|
77
|
-
def test_drops_lowest_priority(self):
|
|
78
|
-
parts = ["base", " | git", " | ctx", " session-uuid-here"]
|
|
79
|
-
result = fit_to_width(parts, 25)
|
|
80
|
-
assert "base" in result
|
|
81
|
-
assert "session-uuid-here" not in result
|
|
82
|
-
|
|
83
|
-
def test_base_always_included(self):
|
|
84
|
-
parts = ["very-long-base-string-that-exceeds-width"]
|
|
85
|
-
result = fit_to_width(parts, 10)
|
|
86
|
-
assert result == "very-long-base-string-that-exceeds-width"
|
|
87
|
-
|
|
88
|
-
def test_empty_parts_skipped(self):
|
|
89
|
-
parts = ["base", "", " | ctx", "", " session"]
|
|
90
|
-
result = fit_to_width(parts, 30)
|
|
91
|
-
assert result == "base | ctx session"
|
|
92
|
-
|
|
93
|
-
def test_exact_boundary(self):
|
|
94
|
-
parts = ["12345", "67890"]
|
|
95
|
-
result = fit_to_width(parts, 10)
|
|
96
|
-
assert result == "1234567890"
|
|
97
|
-
|
|
98
|
-
def test_one_char_over_boundary(self):
|
|
99
|
-
parts = ["12345", "678901"]
|
|
100
|
-
result = fit_to_width(parts, 10)
|
|
101
|
-
assert result == "12345"
|
|
102
|
-
|
|
103
|
-
def test_empty_parts_list(self):
|
|
104
|
-
assert fit_to_width([], 80) == ""
|
|
105
|
-
|
|
106
|
-
def test_realistic_ansi_strings(self):
|
|
107
|
-
base = "\033[2m[Claude]\033[0m \033[0;34mdir\033[0m"
|
|
108
|
-
git = " | \033[0;35mmain\033[0m"
|
|
109
|
-
ctx = " | \033[0;32m150.0k free (75.0%)\033[0m"
|
|
110
|
-
session = " \033[2mtest-session-uuid-1234\033[0m"
|
|
111
|
-
|
|
112
|
-
# base=[Claude] dir = 12, git= | main = 7, ctx= | 150.0k free (75.0%) = 22,
|
|
113
|
-
# session= test-session-uuid-1234 = 23 => total = 64
|
|
114
|
-
result = fit_to_width([base, git, ctx, session], 80)
|
|
115
|
-
assert visible_width(result) == 64
|
|
116
|
-
|
|
117
|
-
# With tight width, session should be dropped
|
|
118
|
-
result = fit_to_width([base, git, ctx, session], 50)
|
|
119
|
-
assert "test-session-uuid-1234" not in result
|
|
120
|
-
assert visible_width(result) <= 50
|
|
121
|
-
|
|
122
|
-
def test_priority_order_preserved(self):
|
|
123
|
-
parts = ["base", " A", " B", " C", " D"]
|
|
124
|
-
# base=4, A=2, B=2, C=2, D=2 => total 12
|
|
125
|
-
# max_width=8 => base + A + B fits (8), C dropped, D dropped
|
|
126
|
-
result = fit_to_width(parts, 8)
|
|
127
|
-
assert result == "base A B"
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
"""Tests for state file rotation and session ID validation."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from claude_statusline.core.state import StateFile, _validate_session_id
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# ---------------------------------------------------------------------------
|
|
13
|
-
# Helpers
|
|
14
|
-
# ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _make_csv_line(index: int) -> str:
|
|
18
|
-
"""Generate a deterministic CSV state line for a given index."""
|
|
19
|
-
return (
|
|
20
|
-
f"{1710288000 + index},100,200,300,400,500,600,0.01,"
|
|
21
|
-
f"10,5,sess-{index},model,/tmp/proj,200000"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# ---------------------------------------------------------------------------
|
|
26
|
-
# State File Rotation
|
|
27
|
-
# ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class TestStateFileRotation:
|
|
31
|
-
"""Tests for _maybe_rotate() in StateFile."""
|
|
32
|
-
|
|
33
|
-
def test_below_threshold_no_rotation(self, tmp_path, monkeypatch):
|
|
34
|
-
"""File with fewer than 10,000 lines is not rotated."""
|
|
35
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
36
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
37
|
-
(tmp_path / "old").mkdir()
|
|
38
|
-
|
|
39
|
-
sf = StateFile("test-session")
|
|
40
|
-
# Write 9,999 lines
|
|
41
|
-
lines = [_make_csv_line(i) + "\n" for i in range(9_999)]
|
|
42
|
-
sf.file_path.write_text("".join(lines))
|
|
43
|
-
|
|
44
|
-
sf._maybe_rotate()
|
|
45
|
-
|
|
46
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
47
|
-
assert len(result_lines) == 9_999
|
|
48
|
-
|
|
49
|
-
def test_at_threshold_no_rotation(self, tmp_path, monkeypatch):
|
|
50
|
-
"""File with exactly 10,000 lines is not rotated."""
|
|
51
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
52
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
53
|
-
(tmp_path / "old").mkdir()
|
|
54
|
-
|
|
55
|
-
sf = StateFile("test-session")
|
|
56
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
|
|
57
|
-
sf.file_path.write_text("".join(lines))
|
|
58
|
-
|
|
59
|
-
sf._maybe_rotate()
|
|
60
|
-
|
|
61
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
62
|
-
assert len(result_lines) == 10_000
|
|
63
|
-
|
|
64
|
-
def test_exceeds_threshold_truncates_to_5000(self, tmp_path, monkeypatch):
|
|
65
|
-
"""File with 10,001 lines is truncated to 5,000."""
|
|
66
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
67
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
68
|
-
(tmp_path / "old").mkdir()
|
|
69
|
-
|
|
70
|
-
sf = StateFile("test-session")
|
|
71
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
|
|
72
|
-
sf.file_path.write_text("".join(lines))
|
|
73
|
-
|
|
74
|
-
sf._maybe_rotate()
|
|
75
|
-
|
|
76
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
77
|
-
assert len(result_lines) == 5_000
|
|
78
|
-
|
|
79
|
-
def test_retained_lines_are_most_recent(self, tmp_path, monkeypatch):
|
|
80
|
-
"""After rotation, the retained lines are the last 5,000 of the original."""
|
|
81
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
82
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
83
|
-
(tmp_path / "old").mkdir()
|
|
84
|
-
|
|
85
|
-
sf = StateFile("test-session")
|
|
86
|
-
total = 10_001
|
|
87
|
-
lines = [_make_csv_line(i) + "\n" for i in range(total)]
|
|
88
|
-
sf.file_path.write_text("".join(lines))
|
|
89
|
-
|
|
90
|
-
sf._maybe_rotate()
|
|
91
|
-
|
|
92
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
93
|
-
# First retained line should be the one at index (total - 5000) = 5001
|
|
94
|
-
assert f"sess-{total - 5000}" in result_lines[0]
|
|
95
|
-
# Last retained line should be the last original line
|
|
96
|
-
assert f"sess-{total - 1}" in result_lines[-1]
|
|
97
|
-
|
|
98
|
-
def test_rotation_via_append_entry(self, tmp_path, monkeypatch):
|
|
99
|
-
"""append_entry triggers rotation when threshold is exceeded."""
|
|
100
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
101
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
102
|
-
(tmp_path / "old").mkdir()
|
|
103
|
-
|
|
104
|
-
sf = StateFile("test-session")
|
|
105
|
-
# Write exactly 10,000 lines (at threshold, no rotation yet)
|
|
106
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
|
|
107
|
-
sf.file_path.write_text("".join(lines))
|
|
108
|
-
|
|
109
|
-
# Import StateEntry to create a valid entry
|
|
110
|
-
from claude_statusline.core.state import StateEntry
|
|
111
|
-
|
|
112
|
-
entry = StateEntry(
|
|
113
|
-
timestamp=1710298000,
|
|
114
|
-
total_input_tokens=100,
|
|
115
|
-
total_output_tokens=200,
|
|
116
|
-
current_input_tokens=300,
|
|
117
|
-
current_output_tokens=400,
|
|
118
|
-
cache_creation=500,
|
|
119
|
-
cache_read=600,
|
|
120
|
-
cost_usd=0.01,
|
|
121
|
-
lines_added=10,
|
|
122
|
-
lines_removed=5,
|
|
123
|
-
session_id="test-session",
|
|
124
|
-
model_id="model",
|
|
125
|
-
workspace_project_dir="/tmp/proj",
|
|
126
|
-
context_window_size=200000,
|
|
127
|
-
)
|
|
128
|
-
sf.append_entry(entry)
|
|
129
|
-
|
|
130
|
-
# Now file had 10,001 lines -> should have been rotated to 5,000
|
|
131
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
132
|
-
assert len(result_lines) == 5_000
|
|
133
|
-
|
|
134
|
-
def test_no_temp_files_left_after_rotation(self, tmp_path, monkeypatch):
|
|
135
|
-
"""No .tmp files remain after successful rotation."""
|
|
136
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
137
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
138
|
-
(tmp_path / "old").mkdir()
|
|
139
|
-
|
|
140
|
-
sf = StateFile("test-session")
|
|
141
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
|
|
142
|
-
sf.file_path.write_text("".join(lines))
|
|
143
|
-
|
|
144
|
-
sf._maybe_rotate()
|
|
145
|
-
|
|
146
|
-
tmp_files = list(tmp_path.glob("*.tmp"))
|
|
147
|
-
assert tmp_files == []
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
# ---------------------------------------------------------------------------
|
|
151
|
-
# Session ID Validation
|
|
152
|
-
# ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class TestSessionIdValidation:
|
|
156
|
-
"""Tests for _validate_session_id and StateFile constructor validation."""
|
|
157
|
-
|
|
158
|
-
def test_reject_forward_slash(self):
|
|
159
|
-
with pytest.raises(ValueError, match="/"):
|
|
160
|
-
_validate_session_id("../../etc/passwd")
|
|
161
|
-
|
|
162
|
-
def test_reject_backslash(self):
|
|
163
|
-
with pytest.raises(ValueError, match=re.escape("\\")):
|
|
164
|
-
_validate_session_id("..\\..\\etc\\passwd")
|
|
165
|
-
|
|
166
|
-
def test_reject_dot_dot(self):
|
|
167
|
-
with pytest.raises(ValueError, match=r"\.\."):
|
|
168
|
-
_validate_session_id("..hidden")
|
|
169
|
-
|
|
170
|
-
def test_reject_null_byte(self):
|
|
171
|
-
with pytest.raises(ValueError):
|
|
172
|
-
_validate_session_id("session\0id")
|
|
173
|
-
|
|
174
|
-
def test_accept_valid_uuid(self):
|
|
175
|
-
_validate_session_id("abc-123-def-456") # Should not raise
|
|
176
|
-
|
|
177
|
-
def test_accept_hyphens_underscores(self):
|
|
178
|
-
_validate_session_id("my_session-id_123") # Should not raise
|
|
179
|
-
|
|
180
|
-
def test_accept_alphanumeric(self):
|
|
181
|
-
_validate_session_id("abcdef1234567890") # Should not raise
|
|
182
|
-
|
|
183
|
-
def test_statefile_rejects_invalid_session(self, tmp_path, monkeypatch):
|
|
184
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
185
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
186
|
-
(tmp_path / "old").mkdir()
|
|
187
|
-
|
|
188
|
-
with pytest.raises(ValueError):
|
|
189
|
-
StateFile("../../etc/passwd")
|
|
190
|
-
|
|
191
|
-
def test_statefile_accepts_none_session(self, tmp_path, monkeypatch):
|
|
192
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
193
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
194
|
-
(tmp_path / "old").mkdir()
|
|
195
|
-
|
|
196
|
-
sf = StateFile(None) # Should not raise
|
|
197
|
-
assert sf.session_id is None
|
|
198
|
-
|
|
199
|
-
def test_statefile_accepts_valid_session(self, tmp_path, monkeypatch):
|
|
200
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
201
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
202
|
-
(tmp_path / "old").mkdir()
|
|
203
|
-
|
|
204
|
-
sf = StateFile("valid-session-123") # Should not raise
|
|
205
|
-
assert sf.session_id == "valid-session-123"
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# ---------------------------------------------------------------------------
|
|
209
|
-
# CLI Session ID Rejection (subprocess test)
|
|
210
|
-
# ---------------------------------------------------------------------------
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
class TestCliSessionIdRejection:
|
|
214
|
-
"""Test that context-stats CLI rejects invalid session IDs."""
|
|
215
|
-
|
|
216
|
-
def test_cli_rejects_path_traversal(self):
|
|
217
|
-
result = subprocess.run(
|
|
218
|
-
[sys.executable, "-m", "claude_statusline.cli.context_stats", "../../etc/passwd"],
|
|
219
|
-
capture_output=True,
|
|
220
|
-
text=True,
|
|
221
|
-
)
|
|
222
|
-
assert result.returncode != 0
|
|
223
|
-
assert "Invalid session_id" in result.stderr
|
|
224
|
-
|
|
225
|
-
def test_cli_rejects_backslash(self):
|
|
226
|
-
result = subprocess.run(
|
|
227
|
-
[sys.executable, "-m", "claude_statusline.cli.context_stats", "test\\bad"],
|
|
228
|
-
capture_output=True,
|
|
229
|
-
text=True,
|
|
230
|
-
)
|
|
231
|
-
assert result.returncode != 0
|
|
232
|
-
assert "Invalid session_id" in result.stderr
|