cc-context-stats 1.3.0 → 1.5.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.
Files changed (41) hide show
  1. package/.claude/settings.local.json +33 -1
  2. package/.github/workflows/ci.yml +4 -2
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +33 -11
  5. package/RELEASE_NOTES.md +9 -0
  6. package/assets/logo/favicon.svg +16 -0
  7. package/assets/logo/logo-black.svg +23 -0
  8. package/assets/logo/logo-full.svg +30 -0
  9. package/assets/logo/logo-icon.svg +26 -0
  10. package/assets/logo/logo-mark.svg +26 -0
  11. package/assets/logo/logo-white.svg +23 -0
  12. package/assets/logo/logo-wordmark.svg +7 -0
  13. package/install.sh +21 -2
  14. package/package.json +7 -3
  15. package/pyproject.toml +6 -4
  16. package/scripts/context-stats.sh +194 -26
  17. package/scripts/statusline-full.sh +57 -2
  18. package/scripts/statusline-git.sh +50 -1
  19. package/scripts/statusline-minimal.sh +50 -1
  20. package/scripts/statusline.js +50 -4
  21. package/scripts/statusline.py +44 -3
  22. package/src/claude_statusline/__init__.py +1 -1
  23. package/src/claude_statusline/cli/context_stats.py +106 -34
  24. package/src/claude_statusline/cli/statusline.py +5 -4
  25. package/src/claude_statusline/core/config.py +7 -0
  26. package/src/claude_statusline/formatters/layout.py +52 -0
  27. package/src/claude_statusline/graphs/renderer.py +44 -24
  28. package/src/claude_statusline/graphs/statistics.py +34 -0
  29. package/src/claude_statusline/ui/__init__.py +1 -0
  30. package/src/claude_statusline/ui/icons.py +93 -0
  31. package/src/claude_statusline/ui/waiting.py +62 -0
  32. package/tests/bash/test_statusline_full.bats +30 -0
  33. package/tests/node/statusline.test.js +44 -3
  34. package/tests/python/test_icons.py +152 -0
  35. package/tests/python/test_layout.py +110 -0
  36. package/tests/python/test_statusline.py +62 -3
  37. package/tests/python/test_waiting.py +127 -0
  38. package/PUBLISHING_GUIDE.md +0 -69
  39. package/npm-publish.sh +0 -33
  40. package/publish.sh +0 -24
  41. package/show_raw_claude_code_api.js +0 -11
@@ -0,0 +1,62 @@
1
+ """Rotating waiting text for active sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from claude_statusline.core.state import StateEntry
8
+
9
+ WAITING_MESSAGES = [
10
+ "Thinking...",
11
+ "Cooking...",
12
+ "Crunching tokens...",
13
+ "Compiling plan...",
14
+ "Running steps...",
15
+ "Processing...",
16
+ "Working on it...",
17
+ "Analyzing...",
18
+ ]
19
+
20
+ # Static message for reduced-motion mode
21
+ STATIC_MESSAGE = "Working..."
22
+
23
+
24
+ def get_waiting_text(cycle_index: int, reduced_motion: bool = False) -> str:
25
+ """Get the current waiting text based on the refresh cycle.
26
+
27
+ Messages rotate every 2 cycles (approximately every 4 seconds at 2s refresh).
28
+
29
+ Args:
30
+ cycle_index: The current watch-mode refresh counter
31
+ reduced_motion: If True, return a static message instead of rotating
32
+
33
+ Returns:
34
+ A waiting message string
35
+ """
36
+ if reduced_motion:
37
+ return STATIC_MESSAGE
38
+
39
+ # Rotate every 2 cycles to keep it readable
40
+ message_index = (cycle_index // 2) % len(WAITING_MESSAGES)
41
+ return WAITING_MESSAGES[message_index]
42
+
43
+
44
+ def is_active(entries: list[StateEntry], timeout: int = 30) -> bool:
45
+ """Determine if the session is currently active.
46
+
47
+ A session is considered active if the most recent state entry
48
+ was recorded within `timeout` seconds of the current time.
49
+
50
+ Args:
51
+ entries: List of StateEntry objects (chronological order)
52
+ timeout: Seconds since last entry to consider session active (default: 30)
53
+
54
+ Returns:
55
+ True if the session appears to be actively running
56
+ """
57
+ if not entries:
58
+ return False
59
+
60
+ now = int(time.time())
61
+ last_timestamp = entries[-1].timestamp
62
+ return (now - last_timestamp) <= timeout
@@ -107,3 +107,33 @@ teardown() {
107
107
  run bash "$SCRIPT" <<< "$input"
108
108
  [ "$status" -eq 0 ]
109
109
  }
110
+
111
+ # Width truncation tests
112
+
113
+ strip_ansi() {
114
+ printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g'
115
+ }
116
+
117
+ @test "output fits within 80 columns" {
118
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/proj","project_dir":"/tmp/proj"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}},"session_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}'
119
+ result=$(COLUMNS=80 bash "$SCRIPT" <<< "$input")
120
+ visible=$(strip_ansi "$result")
121
+ len=$(printf '%s' "$visible" | wc -m | tr -d ' ')
122
+ [ "$len" -le 80 ]
123
+ }
124
+
125
+ @test "narrow terminal still shows model and directory" {
126
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/proj","project_dir":"/tmp/proj"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}}}'
127
+ result=$(COLUMNS=40 bash "$SCRIPT" <<< "$input")
128
+ visible=$(strip_ansi "$result")
129
+ len=$(printf '%s' "$visible" | wc -m | tr -d ' ')
130
+ [ "$len" -le 40 ]
131
+ [[ "$visible" == *"Claude"* ]]
132
+ [[ "$visible" == *"proj"* ]]
133
+ }
134
+
135
+ @test "wide terminal shows session_id" {
136
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/proj","project_dir":"/tmp/proj"},"session_id":"test-wide-session-uuid"}'
137
+ result=$(COLUMNS=200 bash "$SCRIPT" <<< "$input")
138
+ [[ "$result" == *"test-wide-session-uuid"* ]]
139
+ }
@@ -5,14 +5,23 @@ const fs = require('fs');
5
5
  const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'statusline.js');
6
6
  const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures', 'json');
7
7
 
8
+ /**
9
+ * Strip ANSI escape sequences from a string
10
+ */
11
+ function stripAnsi(s) {
12
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
13
+ }
14
+
8
15
  /**
9
16
  * Run the statusline.js script with the given input data
10
17
  * @param {Object|string} inputData - JSON input or string
18
+ * @param {Object} [envOverrides] - Optional environment variable overrides
11
19
  * @returns {Promise<{stdout: string, stderr: string, code: number}>}
12
20
  */
13
- function runScript(inputData) {
21
+ function runScript(inputData, envOverrides) {
14
22
  return new Promise((resolve, reject) => {
15
- const child = spawn('node', [SCRIPT_PATH]);
23
+ const env = { ...process.env, ...envOverrides };
24
+ const child = spawn('node', [SCRIPT_PATH], { env });
16
25
  let stdout = '';
17
26
  let stderr = '';
18
27
 
@@ -186,7 +195,7 @@ describe('statusline.js', () => {
186
195
  ...sampleInput,
187
196
  session_id: 'test-session-abc123',
188
197
  };
189
- const result = await runScript(inputWithSession);
198
+ const result = await runScript(inputWithSession, { COLUMNS: '200' });
190
199
  expect(result.code).toBe(0);
191
200
  expect(result.stdout).toContain('test-session-abc123');
192
201
  });
@@ -196,4 +205,36 @@ describe('statusline.js', () => {
196
205
  expect(result.code).toBe(0);
197
206
  });
198
207
  });
208
+
209
+ describe('Width truncation', () => {
210
+ test('output fits 80 columns', async () => {
211
+ const inputWithSession = {
212
+ ...sampleInput,
213
+ session_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
214
+ };
215
+ const result = await runScript(inputWithSession, { COLUMNS: '80' });
216
+ expect(result.code).toBe(0);
217
+ const visible = stripAnsi(result.stdout);
218
+ expect(visible.length).toBeLessThanOrEqual(80);
219
+ });
220
+
221
+ test('narrow terminal drops parts', async () => {
222
+ const result = await runScript(sampleInput, { COLUMNS: '40' });
223
+ expect(result.code).toBe(0);
224
+ const visible = stripAnsi(result.stdout);
225
+ expect(visible.length).toBeLessThanOrEqual(40);
226
+ expect(visible).toContain('Claude 3.5 Sonnet');
227
+ expect(visible).toContain('myproject');
228
+ });
229
+
230
+ test('wide terminal shows all', async () => {
231
+ const inputWithSession = {
232
+ ...sampleInput,
233
+ session_id: 'test-wide-session-uuid',
234
+ };
235
+ const result = await runScript(inputWithSession, { COLUMNS: '200' });
236
+ expect(result.code).toBe(0);
237
+ expect(result.stdout).toContain('test-wide-session-uuid');
238
+ });
239
+ });
199
240
  });
@@ -0,0 +1,152 @@
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!"
@@ -0,0 +1,110 @@
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_80(self):
42
+ with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
43
+ mock.return_value = type("Size", (), {"columns": 80})()
44
+ assert get_terminal_width() == 80
45
+
46
+ def test_custom_width(self):
47
+ with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
48
+ mock.return_value = type("Size", (), {"columns": 120})()
49
+ assert get_terminal_width() == 120
50
+
51
+
52
+ class TestFitToWidth:
53
+ """Tests for fit_to_width()."""
54
+
55
+ def test_all_parts_fit(self):
56
+ parts = ["base", " | git", " | ctx"]
57
+ result = fit_to_width(parts, 80)
58
+ assert result == "base | git | ctx"
59
+
60
+ def test_drops_lowest_priority(self):
61
+ parts = ["base", " | git", " | ctx", " session-uuid-here"]
62
+ result = fit_to_width(parts, 25)
63
+ assert "base" in result
64
+ assert "session-uuid-here" not in result
65
+
66
+ def test_base_always_included(self):
67
+ parts = ["very-long-base-string-that-exceeds-width"]
68
+ result = fit_to_width(parts, 10)
69
+ assert result == "very-long-base-string-that-exceeds-width"
70
+
71
+ def test_empty_parts_skipped(self):
72
+ parts = ["base", "", " | ctx", "", " session"]
73
+ result = fit_to_width(parts, 30)
74
+ assert result == "base | ctx session"
75
+
76
+ def test_exact_boundary(self):
77
+ parts = ["12345", "67890"]
78
+ result = fit_to_width(parts, 10)
79
+ assert result == "1234567890"
80
+
81
+ def test_one_char_over_boundary(self):
82
+ parts = ["12345", "678901"]
83
+ result = fit_to_width(parts, 10)
84
+ assert result == "12345"
85
+
86
+ def test_empty_parts_list(self):
87
+ assert fit_to_width([], 80) == ""
88
+
89
+ def test_realistic_ansi_strings(self):
90
+ base = "\033[2m[Claude]\033[0m \033[0;34mdir\033[0m"
91
+ git = " | \033[0;35mmain\033[0m"
92
+ ctx = " | \033[0;32m150.0k free (75.0%)\033[0m"
93
+ session = " \033[2mtest-session-uuid-1234\033[0m"
94
+
95
+ # base=[Claude] dir = 12, git= | main = 7, ctx= | 150.0k free (75.0%) = 22,
96
+ # session= test-session-uuid-1234 = 23 => total = 64
97
+ result = fit_to_width([base, git, ctx, session], 80)
98
+ assert visible_width(result) == 64
99
+
100
+ # With tight width, session should be dropped
101
+ result = fit_to_width([base, git, ctx, session], 50)
102
+ assert "test-session-uuid-1234" not in result
103
+ assert visible_width(result) <= 50
104
+
105
+ def test_priority_order_preserved(self):
106
+ parts = ["base", " A", " B", " C", " D"]
107
+ # base=4, A=2, B=2, C=2, D=2 => total 12
108
+ # max_width=8 => base + A + B fits (8), C dropped, D dropped
109
+ result = fit_to_width(parts, 8)
110
+ assert result == "base A B"
@@ -1,24 +1,42 @@
1
1
  """Tests for statusline.py script."""
2
2
 
3
3
  import json
4
+ import os
5
+ import re
4
6
  import subprocess
5
7
  import sys
6
8
  from pathlib import Path
7
9
 
8
10
  SCRIPT_PATH = Path(__file__).parent.parent.parent / "scripts" / "statusline.py"
9
11
 
12
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
10
13
 
11
- def run_script(input_data: dict) -> tuple[str, int]:
14
+
15
+ def strip_ansi(s: str) -> str:
16
+ """Strip ANSI escape sequences from a string."""
17
+ return _ANSI_RE.sub("", s)
18
+
19
+
20
+ def run_script(input_data: dict, env_overrides: dict | None = None) -> tuple[str, int]:
12
21
  """Run the statusline.py script with the given input.
13
22
 
23
+ Args:
24
+ input_data: JSON-serializable dict to pass as stdin.
25
+ env_overrides: Optional dict of environment variable overrides.
26
+
14
27
  Returns:
15
28
  Tuple of (stdout, return_code)
16
29
  """
30
+ env = os.environ.copy()
31
+ env["PYTHONUTF8"] = "1"
32
+ if env_overrides:
33
+ env.update(env_overrides)
17
34
  result = subprocess.run(
18
35
  [sys.executable, str(SCRIPT_PATH)],
19
36
  input=json.dumps(input_data),
20
37
  capture_output=True,
21
38
  text=True,
39
+ env=env,
22
40
  )
23
41
  return result.stdout.strip(), result.returncode
24
42
 
@@ -32,7 +50,7 @@ class TestStatuslineScript:
32
50
 
33
51
  def test_script_is_python(self):
34
52
  """Script should have Python shebang."""
35
- content = SCRIPT_PATH.read_text()
53
+ content = SCRIPT_PATH.read_text(encoding="utf-8")
36
54
  assert content.startswith("#!/usr/bin/env python3")
37
55
 
38
56
  def test_outputs_model_name(self, sample_input):
@@ -68,22 +86,28 @@ class TestStatuslineScript:
68
86
 
69
87
  def test_handles_invalid_json(self):
70
88
  """Should handle invalid JSON gracefully."""
89
+ env = os.environ.copy()
90
+ env["PYTHONUTF8"] = "1"
71
91
  result = subprocess.run(
72
92
  [sys.executable, str(SCRIPT_PATH)],
73
93
  input="invalid json",
74
94
  capture_output=True,
75
95
  text=True,
96
+ env=env,
76
97
  )
77
98
  assert result.returncode == 0
78
99
  assert "Claude" in result.stdout
79
100
 
80
101
  def test_handles_empty_input(self):
81
102
  """Should handle empty input gracefully."""
103
+ env = os.environ.copy()
104
+ env["PYTHONUTF8"] = "1"
82
105
  result = subprocess.run(
83
106
  [sys.executable, str(SCRIPT_PATH)],
84
107
  input="",
85
108
  capture_output=True,
86
109
  text=True,
110
+ env=env,
87
111
  )
88
112
  assert result.returncode == 0
89
113
 
@@ -141,7 +165,7 @@ class TestSessionDisplay:
141
165
  def test_shows_session_id_by_default(self, sample_input):
142
166
  """Should show session_id by default (show_session=true)."""
143
167
  sample_input["session_id"] = "test-session-12345"
144
- output, code = run_script(sample_input)
168
+ output, code = run_script(sample_input, {"COLUMNS": "200"})
145
169
  assert code == 0
146
170
  assert "test-session-12345" in output
147
171
 
@@ -152,3 +176,38 @@ class TestSessionDisplay:
152
176
  del sample_input["session_id"]
153
177
  output, code = run_script(sample_input)
154
178
  assert code == 0
179
+
180
+
181
+ class TestWidthTruncation:
182
+ """Tests for width truncation to fit terminal width."""
183
+
184
+ def test_output_fits_80_columns(self, sample_input):
185
+ """Output should fit within 80 columns."""
186
+ sample_input["session_id"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
187
+ output, code = run_script(sample_input, {"COLUMNS": "80"})
188
+ assert code == 0
189
+ visible = strip_ansi(output)
190
+ assert len(visible) <= 80
191
+
192
+ def test_output_fits_narrow_terminal(self, sample_input):
193
+ """Output should fit within 40 columns and still show model+dir."""
194
+ output, code = run_script(sample_input, {"COLUMNS": "40"})
195
+ assert code == 0
196
+ visible = strip_ansi(output)
197
+ assert len(visible) <= 40
198
+ assert "Claude 3.5 Sonnet" in visible
199
+ assert "myproject" in visible
200
+
201
+ def test_wide_terminal_shows_all(self, sample_input):
202
+ """Wide terminal should show session_id."""
203
+ sample_input["session_id"] = "test-wide-session-uuid"
204
+ output, code = run_script(sample_input, {"COLUMNS": "200"})
205
+ assert code == 0
206
+ assert "test-wide-session-uuid" in output
207
+
208
+ def test_full_input_truncated(self, valid_full_input):
209
+ """Full input with all features should fit within 80 columns."""
210
+ output, code = run_script(valid_full_input, {"COLUMNS": "80"})
211
+ assert code == 0
212
+ visible = strip_ansi(output)
213
+ assert len(visible) <= 80
@@ -0,0 +1,127 @@
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