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.
- package/.claude/settings.local.json +33 -1
- package/.github/workflows/ci.yml +4 -2
- package/CHANGELOG.md +17 -0
- package/README.md +33 -11
- package/RELEASE_NOTES.md +9 -0
- package/assets/logo/favicon.svg +16 -0
- package/assets/logo/logo-black.svg +23 -0
- package/assets/logo/logo-full.svg +30 -0
- package/assets/logo/logo-icon.svg +26 -0
- package/assets/logo/logo-mark.svg +26 -0
- package/assets/logo/logo-white.svg +23 -0
- package/assets/logo/logo-wordmark.svg +7 -0
- package/install.sh +21 -2
- package/package.json +7 -3
- package/pyproject.toml +6 -4
- package/scripts/context-stats.sh +194 -26
- package/scripts/statusline-full.sh +57 -2
- package/scripts/statusline-git.sh +50 -1
- package/scripts/statusline-minimal.sh +50 -1
- package/scripts/statusline.js +50 -4
- package/scripts/statusline.py +44 -3
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +106 -34
- package/src/claude_statusline/cli/statusline.py +5 -4
- package/src/claude_statusline/core/config.py +7 -0
- package/src/claude_statusline/formatters/layout.py +52 -0
- package/src/claude_statusline/graphs/renderer.py +44 -24
- package/src/claude_statusline/graphs/statistics.py +34 -0
- package/src/claude_statusline/ui/__init__.py +1 -0
- package/src/claude_statusline/ui/icons.py +93 -0
- package/src/claude_statusline/ui/waiting.py +62 -0
- package/tests/bash/test_statusline_full.bats +30 -0
- package/tests/node/statusline.test.js +44 -3
- package/tests/python/test_icons.py +152 -0
- package/tests/python/test_layout.py +110 -0
- package/tests/python/test_statusline.py +62 -3
- package/tests/python/test_waiting.py +127 -0
- package/PUBLISHING_GUIDE.md +0 -69
- package/npm-publish.sh +0 -33
- package/publish.sh +0 -24
- 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
|
|
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
|
-
|
|
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
|