cc-context-stats 1.8.0 → 1.8.2

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.
Files changed (106) hide show
  1. package/package.json +8 -1
  2. package/scripts/context-stats.sh +1 -1
  3. package/.editorconfig +0 -60
  4. package/.eslintrc.json +0 -35
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  7. package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
  8. package/.github/dependabot.yml +0 -44
  9. package/.github/workflows/ci.yml +0 -294
  10. package/.github/workflows/release.yml +0 -151
  11. package/.pre-commit-config.yaml +0 -74
  12. package/.prettierrc +0 -33
  13. package/.shellcheckrc +0 -10
  14. package/CHANGELOG.md +0 -187
  15. package/CLAUDE.md +0 -66
  16. package/CODE_OF_CONDUCT.md +0 -59
  17. package/CONTRIBUTING.md +0 -240
  18. package/RELEASE_NOTES.md +0 -19
  19. package/SECURITY.md +0 -44
  20. package/TODOS.md +0 -72
  21. package/assets/logo/favicon.svg +0 -19
  22. package/assets/logo/logo-black.svg +0 -24
  23. package/assets/logo/logo-full.svg +0 -40
  24. package/assets/logo/logo-icon.svg +0 -27
  25. package/assets/logo/logo-mark.svg +0 -28
  26. package/assets/logo/logo-white.svg +0 -24
  27. package/assets/logo/logo-wordmark.svg +0 -6
  28. package/config/settings-example.json +0 -7
  29. package/config/settings-node.json +0 -7
  30. package/config/settings-python.json +0 -7
  31. package/docs/ARCHITECTURE.md +0 -128
  32. package/docs/CSV_FORMAT.md +0 -42
  33. package/docs/DEPLOYMENT.md +0 -71
  34. package/docs/DEVELOPMENT.md +0 -161
  35. package/docs/MODEL_INTELLIGENCE.md +0 -396
  36. package/docs/configuration.md +0 -118
  37. package/docs/context-stats.md +0 -143
  38. package/docs/installation.md +0 -255
  39. package/docs/scripts.md +0 -140
  40. package/docs/troubleshooting.md +0 -278
  41. package/images/claude-statusline-token-graph.gif +0 -0
  42. package/images/claude-statusline.png +0 -0
  43. package/images/context-status-dumbzone.png +0 -0
  44. package/images/context-status.png +0 -0
  45. package/images/statusline-detail.png +0 -0
  46. package/images/token-graph.jpeg +0 -0
  47. package/images/token-graph.png +0 -0
  48. package/images/v1.6.1.png +0 -0
  49. package/install +0 -351
  50. package/install.sh +0 -298
  51. package/jest.config.js +0 -11
  52. package/pyproject.toml +0 -115
  53. package/requirements-dev.txt +0 -12
  54. package/scripts/statusline-full.sh +0 -438
  55. package/scripts/statusline-git.sh +0 -88
  56. package/scripts/statusline-minimal.sh +0 -67
  57. package/scripts/statusline.py +0 -569
  58. package/src/claude_statusline/__init__.py +0 -11
  59. package/src/claude_statusline/__main__.py +0 -6
  60. package/src/claude_statusline/cli/__init__.py +0 -1
  61. package/src/claude_statusline/cli/context_stats.py +0 -542
  62. package/src/claude_statusline/cli/explain.py +0 -228
  63. package/src/claude_statusline/cli/statusline.py +0 -184
  64. package/src/claude_statusline/core/__init__.py +0 -1
  65. package/src/claude_statusline/core/colors.py +0 -124
  66. package/src/claude_statusline/core/config.py +0 -165
  67. package/src/claude_statusline/core/git.py +0 -78
  68. package/src/claude_statusline/core/state.py +0 -323
  69. package/src/claude_statusline/formatters/__init__.py +0 -1
  70. package/src/claude_statusline/formatters/layout.py +0 -67
  71. package/src/claude_statusline/formatters/time.py +0 -50
  72. package/src/claude_statusline/formatters/tokens.py +0 -70
  73. package/src/claude_statusline/graphs/__init__.py +0 -1
  74. package/src/claude_statusline/graphs/intelligence.py +0 -162
  75. package/src/claude_statusline/graphs/renderer.py +0 -401
  76. package/src/claude_statusline/graphs/statistics.py +0 -92
  77. package/src/claude_statusline/ui/__init__.py +0 -1
  78. package/src/claude_statusline/ui/icons.py +0 -93
  79. package/src/claude_statusline/ui/waiting.py +0 -62
  80. package/tests/bash/test_delta_parity.bats +0 -199
  81. package/tests/bash/test_install.bats +0 -29
  82. package/tests/bash/test_parity.bats +0 -315
  83. package/tests/bash/test_statusline_full.bats +0 -139
  84. package/tests/bash/test_statusline_git.bats +0 -42
  85. package/tests/bash/test_statusline_minimal.bats +0 -37
  86. package/tests/fixtures/json/comma_in_path.json +0 -31
  87. package/tests/fixtures/json/high_usage.json +0 -17
  88. package/tests/fixtures/json/low_usage.json +0 -17
  89. package/tests/fixtures/json/medium_usage.json +0 -17
  90. package/tests/fixtures/json/valid_full.json +0 -30
  91. package/tests/fixtures/json/valid_minimal.json +0 -9
  92. package/tests/fixtures/mi_test_vectors.json +0 -140
  93. package/tests/node/intelligence.test.js +0 -98
  94. package/tests/node/rotation.test.js +0 -89
  95. package/tests/node/statusline.test.js +0 -240
  96. package/tests/python/conftest.py +0 -84
  97. package/tests/python/test_colors.py +0 -105
  98. package/tests/python/test_config_colors.py +0 -78
  99. package/tests/python/test_data_pipeline.py +0 -446
  100. package/tests/python/test_explain.py +0 -177
  101. package/tests/python/test_icons.py +0 -152
  102. package/tests/python/test_intelligence.py +0 -314
  103. package/tests/python/test_layout.py +0 -127
  104. package/tests/python/test_state_rotation_validation.py +0 -232
  105. package/tests/python/test_statusline.py +0 -215
  106. package/tests/python/test_waiting.py +0 -127
@@ -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 (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 (75.0%) = 17,
113
- # session= test-session-uuid-1234 = 23 => total = 59
114
- result = fit_to_width([base, git, ctx, session], 80)
115
- assert visible_width(result) == 59
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
@@ -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 "%" 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 "%" 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 "%" 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 "%" 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