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,215 +0,0 @@
|
|
|
1
|
-
"""Tests for statusline.py script."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
SCRIPT_PATH = Path(__file__).parent.parent.parent / "scripts" / "statusline.py"
|
|
13
|
-
|
|
14
|
-
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def strip_ansi(s: str) -> str:
|
|
18
|
-
"""Strip ANSI escape sequences from a string."""
|
|
19
|
-
return _ANSI_RE.sub("", s)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def run_script(input_data: dict, env_overrides: dict | None = None) -> tuple[str, int]:
|
|
23
|
-
"""Run the statusline.py script with the given input.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
input_data: JSON-serializable dict to pass as stdin.
|
|
27
|
-
env_overrides: Optional dict of environment variable overrides.
|
|
28
|
-
|
|
29
|
-
Returns:
|
|
30
|
-
Tuple of (stdout, return_code)
|
|
31
|
-
"""
|
|
32
|
-
env = os.environ.copy()
|
|
33
|
-
env["PYTHONUTF8"] = "1"
|
|
34
|
-
if env_overrides:
|
|
35
|
-
env.update(env_overrides)
|
|
36
|
-
result = subprocess.run(
|
|
37
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
38
|
-
input=json.dumps(input_data),
|
|
39
|
-
capture_output=True,
|
|
40
|
-
text=True,
|
|
41
|
-
env=env,
|
|
42
|
-
)
|
|
43
|
-
return result.stdout.strip(), result.returncode
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class TestStatuslineScript:
|
|
47
|
-
"""Tests for the statusline.py script execution."""
|
|
48
|
-
|
|
49
|
-
def test_script_exists(self):
|
|
50
|
-
"""Script file should exist."""
|
|
51
|
-
assert SCRIPT_PATH.exists()
|
|
52
|
-
|
|
53
|
-
def test_script_is_python(self):
|
|
54
|
-
"""Script should have Python shebang."""
|
|
55
|
-
content = SCRIPT_PATH.read_text(encoding="utf-8")
|
|
56
|
-
assert content.startswith("#!/usr/bin/env python3")
|
|
57
|
-
|
|
58
|
-
def test_outputs_model_name(self, sample_input):
|
|
59
|
-
"""Should output the model name."""
|
|
60
|
-
output, code = run_script(sample_input)
|
|
61
|
-
assert code == 0
|
|
62
|
-
assert "Claude 3.5 Sonnet" in output
|
|
63
|
-
|
|
64
|
-
def test_outputs_directory_name(self, sample_input):
|
|
65
|
-
"""Should output the directory name."""
|
|
66
|
-
output, code = run_script(sample_input)
|
|
67
|
-
assert code == 0
|
|
68
|
-
assert "myproject" in output
|
|
69
|
-
|
|
70
|
-
def test_shows_free_tokens(self, sample_input):
|
|
71
|
-
"""Should show free tokens indicator."""
|
|
72
|
-
output, code = run_script(sample_input)
|
|
73
|
-
assert code == 0
|
|
74
|
-
assert "free" in output
|
|
75
|
-
|
|
76
|
-
def test_shows_ac_indicator(self, sample_input):
|
|
77
|
-
"""Should show autocompact indicator."""
|
|
78
|
-
output, code = run_script(sample_input)
|
|
79
|
-
assert code == 0
|
|
80
|
-
assert "[AC:" in output
|
|
81
|
-
|
|
82
|
-
def test_handles_missing_model(self):
|
|
83
|
-
"""Should handle missing model gracefully."""
|
|
84
|
-
input_data = {"workspace": {"current_dir": "/tmp/test", "project_dir": "/tmp/test"}}
|
|
85
|
-
output, code = run_script(input_data)
|
|
86
|
-
assert code == 0
|
|
87
|
-
assert "Claude" in output # Default fallback
|
|
88
|
-
|
|
89
|
-
def test_handles_invalid_json(self):
|
|
90
|
-
"""Should handle invalid JSON gracefully."""
|
|
91
|
-
env = os.environ.copy()
|
|
92
|
-
env["PYTHONUTF8"] = "1"
|
|
93
|
-
result = subprocess.run(
|
|
94
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
95
|
-
input="invalid json",
|
|
96
|
-
capture_output=True,
|
|
97
|
-
text=True,
|
|
98
|
-
env=env,
|
|
99
|
-
)
|
|
100
|
-
assert result.returncode == 0
|
|
101
|
-
assert "Claude" in result.stdout
|
|
102
|
-
|
|
103
|
-
def test_handles_empty_input(self):
|
|
104
|
-
"""Should handle empty input gracefully."""
|
|
105
|
-
env = os.environ.copy()
|
|
106
|
-
env["PYTHONUTF8"] = "1"
|
|
107
|
-
result = subprocess.run(
|
|
108
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
109
|
-
input="",
|
|
110
|
-
capture_output=True,
|
|
111
|
-
text=True,
|
|
112
|
-
env=env,
|
|
113
|
-
)
|
|
114
|
-
assert result.returncode == 0
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class TestContextWindowColors:
|
|
118
|
-
"""Tests for context window color coding."""
|
|
119
|
-
|
|
120
|
-
def test_low_usage_has_output(self, low_usage_input):
|
|
121
|
-
"""Low usage (>50% free) should produce output with 'free'."""
|
|
122
|
-
output, code = run_script(low_usage_input)
|
|
123
|
-
assert code == 0
|
|
124
|
-
assert "free" in output
|
|
125
|
-
|
|
126
|
-
def test_medium_usage_has_output(self, medium_usage_input):
|
|
127
|
-
"""Medium usage (25-50% free) should produce output with 'free'."""
|
|
128
|
-
output, code = run_script(medium_usage_input)
|
|
129
|
-
assert code == 0
|
|
130
|
-
assert "free" in output
|
|
131
|
-
|
|
132
|
-
def test_high_usage_has_output(self, high_usage_input):
|
|
133
|
-
"""High usage (<25% free) should produce output with 'free'."""
|
|
134
|
-
output, code = run_script(high_usage_input)
|
|
135
|
-
assert code == 0
|
|
136
|
-
assert "free" in output
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
class TestFixtures:
|
|
140
|
-
"""Tests using fixture files."""
|
|
141
|
-
|
|
142
|
-
def test_valid_full_fixture(self, valid_full_input):
|
|
143
|
-
"""Should handle valid_full.json fixture."""
|
|
144
|
-
output, code = run_script(valid_full_input)
|
|
145
|
-
assert code == 0
|
|
146
|
-
assert "Opus 4.5" in output
|
|
147
|
-
assert "my-project" in output
|
|
148
|
-
|
|
149
|
-
def test_valid_minimal_fixture(self, valid_minimal_input):
|
|
150
|
-
"""Should handle valid_minimal.json fixture."""
|
|
151
|
-
output, code = run_script(valid_minimal_input)
|
|
152
|
-
assert code == 0
|
|
153
|
-
assert "Claude" in output
|
|
154
|
-
|
|
155
|
-
def test_all_fixtures_succeed(self, fixtures_dir):
|
|
156
|
-
"""All JSON fixtures should be processed without errors."""
|
|
157
|
-
for fixture_file in fixtures_dir.glob("*.json"):
|
|
158
|
-
with open(fixture_file) as f:
|
|
159
|
-
input_data = json.load(f)
|
|
160
|
-
output, code = run_script(input_data)
|
|
161
|
-
assert code == 0, f"Failed on fixture: {fixture_file.name}"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
class TestSessionDisplay:
|
|
165
|
-
"""Tests for session_id display feature."""
|
|
166
|
-
|
|
167
|
-
def test_shows_session_id_by_default(self, sample_input):
|
|
168
|
-
"""Should show session_id by default (show_session=true)."""
|
|
169
|
-
sample_input["session_id"] = "test-session-12345"
|
|
170
|
-
output, code = run_script(sample_input, {"COLUMNS": "200"})
|
|
171
|
-
assert code == 0
|
|
172
|
-
assert "test-session-12345" in output
|
|
173
|
-
|
|
174
|
-
def test_handles_missing_session_id(self, sample_input):
|
|
175
|
-
"""Should handle missing session_id gracefully."""
|
|
176
|
-
# Ensure no session_id in input
|
|
177
|
-
if "session_id" in sample_input:
|
|
178
|
-
del sample_input["session_id"]
|
|
179
|
-
output, code = run_script(sample_input)
|
|
180
|
-
assert code == 0
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
class TestWidthTruncation:
|
|
184
|
-
"""Tests for width truncation to fit terminal width."""
|
|
185
|
-
|
|
186
|
-
def test_output_fits_80_columns(self, sample_input):
|
|
187
|
-
"""Output should fit within 80 columns."""
|
|
188
|
-
sample_input["session_id"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
189
|
-
output, code = run_script(sample_input, {"COLUMNS": "80"})
|
|
190
|
-
assert code == 0
|
|
191
|
-
visible = strip_ansi(output)
|
|
192
|
-
assert len(visible) <= 80
|
|
193
|
-
|
|
194
|
-
def test_output_fits_narrow_terminal(self, sample_input):
|
|
195
|
-
"""Output should fit within 40 columns and still show model+dir."""
|
|
196
|
-
output, code = run_script(sample_input, {"COLUMNS": "40"})
|
|
197
|
-
assert code == 0
|
|
198
|
-
visible = strip_ansi(output)
|
|
199
|
-
assert len(visible) <= 40
|
|
200
|
-
assert "Claude 3.5 Sonnet" in visible
|
|
201
|
-
assert "myproject" in visible
|
|
202
|
-
|
|
203
|
-
def test_wide_terminal_shows_all(self, sample_input):
|
|
204
|
-
"""Wide terminal should show session_id."""
|
|
205
|
-
sample_input["session_id"] = "test-wide-session-uuid"
|
|
206
|
-
output, code = run_script(sample_input, {"COLUMNS": "200"})
|
|
207
|
-
assert code == 0
|
|
208
|
-
assert "test-wide-session-uuid" in output
|
|
209
|
-
|
|
210
|
-
def test_full_input_truncated(self, valid_full_input):
|
|
211
|
-
"""Full input with all features should fit within 80 columns."""
|
|
212
|
-
output, code = run_script(valid_full_input, {"COLUMNS": "80"})
|
|
213
|
-
assert code == 0
|
|
214
|
-
visible = strip_ansi(output)
|
|
215
|
-
assert len(visible) <= 80
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
"""Tests for rotating waiting text and activity detection."""
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
from claude_statusline.core.state import StateEntry
|
|
6
|
-
from claude_statusline.ui.waiting import (
|
|
7
|
-
STATIC_MESSAGE,
|
|
8
|
-
WAITING_MESSAGES,
|
|
9
|
-
get_waiting_text,
|
|
10
|
-
is_active,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _make_entry(timestamp: int = 0) -> StateEntry:
|
|
15
|
-
"""Helper to create a StateEntry for testing."""
|
|
16
|
-
return StateEntry(
|
|
17
|
-
timestamp=timestamp,
|
|
18
|
-
total_input_tokens=1000,
|
|
19
|
-
total_output_tokens=0,
|
|
20
|
-
current_input_tokens=1000,
|
|
21
|
-
current_output_tokens=0,
|
|
22
|
-
cache_creation=0,
|
|
23
|
-
cache_read=0,
|
|
24
|
-
cost_usd=0.0,
|
|
25
|
-
lines_added=0,
|
|
26
|
-
lines_removed=0,
|
|
27
|
-
session_id="test",
|
|
28
|
-
model_id="test-model",
|
|
29
|
-
workspace_project_dir="/tmp/test",
|
|
30
|
-
context_window_size=200_000,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TestGetWaitingText:
|
|
35
|
-
"""Tests for rotating waiting text."""
|
|
36
|
-
|
|
37
|
-
def test_returns_string(self):
|
|
38
|
-
text = get_waiting_text(0)
|
|
39
|
-
assert isinstance(text, str)
|
|
40
|
-
assert len(text) > 0
|
|
41
|
-
|
|
42
|
-
def test_rotates_every_two_cycles(self):
|
|
43
|
-
"""Messages should change every 2 cycles."""
|
|
44
|
-
text_0 = get_waiting_text(0)
|
|
45
|
-
text_1 = get_waiting_text(1)
|
|
46
|
-
text_2 = get_waiting_text(2)
|
|
47
|
-
|
|
48
|
-
# Cycle 0 and 1 should be the same (same message index)
|
|
49
|
-
assert text_0 == text_1
|
|
50
|
-
# Cycle 2 should be different (next message)
|
|
51
|
-
assert text_0 != text_2
|
|
52
|
-
|
|
53
|
-
def test_wraps_around(self):
|
|
54
|
-
"""Should cycle back to the first message."""
|
|
55
|
-
total_messages = len(WAITING_MESSAGES)
|
|
56
|
-
# After going through all messages (2 cycles each), should wrap
|
|
57
|
-
first = get_waiting_text(0)
|
|
58
|
-
wrapped = get_waiting_text(total_messages * 2)
|
|
59
|
-
assert first == wrapped
|
|
60
|
-
|
|
61
|
-
def test_all_messages_reachable(self):
|
|
62
|
-
"""Every message should appear at some cycle."""
|
|
63
|
-
seen = set()
|
|
64
|
-
for i in range(len(WAITING_MESSAGES) * 2):
|
|
65
|
-
seen.add(get_waiting_text(i))
|
|
66
|
-
assert seen == set(WAITING_MESSAGES)
|
|
67
|
-
|
|
68
|
-
def test_reduced_motion_returns_static(self):
|
|
69
|
-
"""With reduced_motion=True, always return static message."""
|
|
70
|
-
for i in range(10):
|
|
71
|
-
text = get_waiting_text(i, reduced_motion=True)
|
|
72
|
-
assert text == STATIC_MESSAGE
|
|
73
|
-
|
|
74
|
-
def test_reduced_motion_consistent(self):
|
|
75
|
-
"""Static message should be the same regardless of cycle."""
|
|
76
|
-
texts = {get_waiting_text(i, reduced_motion=True) for i in range(20)}
|
|
77
|
-
assert len(texts) == 1
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class TestIsActive:
|
|
81
|
-
"""Tests for session activity detection."""
|
|
82
|
-
|
|
83
|
-
def test_empty_entries(self):
|
|
84
|
-
assert is_active([]) is False
|
|
85
|
-
|
|
86
|
-
def test_recent_entry_is_active(self):
|
|
87
|
-
"""Entry within timeout is active."""
|
|
88
|
-
entry = _make_entry(timestamp=int(time.time()) - 5)
|
|
89
|
-
assert is_active([entry]) is True
|
|
90
|
-
|
|
91
|
-
def test_old_entry_is_not_active(self):
|
|
92
|
-
"""Entry older than timeout is not active."""
|
|
93
|
-
entry = _make_entry(timestamp=int(time.time()) - 60)
|
|
94
|
-
assert is_active([entry]) is False
|
|
95
|
-
|
|
96
|
-
def test_exactly_at_timeout_is_active(self):
|
|
97
|
-
"""Entry exactly at timeout boundary is still active."""
|
|
98
|
-
entry = _make_entry(timestamp=int(time.time()) - 30)
|
|
99
|
-
assert is_active([entry], timeout=30) is True
|
|
100
|
-
|
|
101
|
-
def test_just_past_timeout_is_not_active(self):
|
|
102
|
-
"""Entry just past timeout boundary is not active."""
|
|
103
|
-
entry = _make_entry(timestamp=int(time.time()) - 31)
|
|
104
|
-
assert is_active([entry], timeout=30) is False
|
|
105
|
-
|
|
106
|
-
def test_custom_timeout(self):
|
|
107
|
-
"""Custom timeout should be respected."""
|
|
108
|
-
entry = _make_entry(timestamp=int(time.time()) - 10)
|
|
109
|
-
assert is_active([entry], timeout=5) is False
|
|
110
|
-
assert is_active([entry], timeout=15) is True
|
|
111
|
-
|
|
112
|
-
def test_uses_last_entry(self):
|
|
113
|
-
"""Should check the most recent (last) entry, not the first."""
|
|
114
|
-
old_entry = _make_entry(timestamp=int(time.time()) - 120)
|
|
115
|
-
recent_entry = _make_entry(timestamp=int(time.time()) - 5)
|
|
116
|
-
assert is_active([old_entry, recent_entry]) is True
|
|
117
|
-
|
|
118
|
-
def test_old_entries_with_recent_last(self):
|
|
119
|
-
"""Multiple old entries don't matter if last one is recent."""
|
|
120
|
-
now = int(time.time())
|
|
121
|
-
entries = [
|
|
122
|
-
_make_entry(timestamp=now - 300),
|
|
123
|
-
_make_entry(timestamp=now - 200),
|
|
124
|
-
_make_entry(timestamp=now - 100),
|
|
125
|
-
_make_entry(timestamp=now - 2),
|
|
126
|
-
]
|
|
127
|
-
assert is_active(entries) is True
|