cc-context-stats 1.3.0 → 1.5.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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +36 -1
  2. package/.github/workflows/ci.yml +4 -2
  3. package/.github/workflows/release.yml +3 -1
  4. package/CHANGELOG.md +17 -0
  5. package/README.md +33 -11
  6. package/RELEASE_NOTES.md +10 -0
  7. package/assets/logo/favicon.svg +19 -0
  8. package/assets/logo/logo-black.svg +24 -0
  9. package/assets/logo/logo-full.svg +40 -0
  10. package/assets/logo/logo-icon.svg +27 -0
  11. package/assets/logo/logo-mark.svg +28 -0
  12. package/assets/logo/logo-white.svg +24 -0
  13. package/assets/logo/logo-wordmark.svg +6 -0
  14. package/install.sh +21 -2
  15. package/package.json +7 -3
  16. package/pyproject.toml +6 -4
  17. package/scripts/context-stats.sh +194 -26
  18. package/scripts/statusline-full.sh +65 -2
  19. package/scripts/statusline-git.sh +57 -1
  20. package/scripts/statusline-minimal.sh +57 -1
  21. package/scripts/statusline.js +51 -4
  22. package/scripts/statusline.py +57 -3
  23. package/src/claude_statusline/__init__.py +1 -1
  24. package/src/claude_statusline/cli/context_stats.py +106 -34
  25. package/src/claude_statusline/cli/statusline.py +5 -4
  26. package/src/claude_statusline/core/config.py +7 -0
  27. package/src/claude_statusline/formatters/layout.py +67 -0
  28. package/src/claude_statusline/graphs/renderer.py +44 -24
  29. package/src/claude_statusline/graphs/statistics.py +34 -0
  30. package/src/claude_statusline/ui/__init__.py +1 -0
  31. package/src/claude_statusline/ui/icons.py +93 -0
  32. package/src/claude_statusline/ui/waiting.py +62 -0
  33. package/tests/bash/test_statusline_full.bats +30 -0
  34. package/tests/node/statusline.test.js +44 -3
  35. package/tests/python/test_icons.py +152 -0
  36. package/tests/python/test_layout.py +127 -0
  37. package/tests/python/test_statusline.py +64 -3
  38. package/tests/python/test_waiting.py +127 -0
  39. package/PUBLISHING_GUIDE.md +0 -69
  40. package/npm-publish.sh +0 -33
  41. package/publish.sh +0 -24
  42. 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,127 @@
1
+ """Tests for layout helpers (visible_width, get_terminal_width, fit_to_width)."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from claude_statusline.formatters.layout import (
6
+ fit_to_width,
7
+ get_terminal_width,
8
+ visible_width,
9
+ )
10
+
11
+
12
+ class TestVisibleWidth:
13
+ """Tests for visible_width()."""
14
+
15
+ def test_plain_text(self):
16
+ assert visible_width("hello") == 5
17
+
18
+ def test_ansi_colored_string(self):
19
+ s = "\033[0;32mgreen\033[0m"
20
+ assert visible_width(s) == 5
21
+
22
+ def test_multiple_ansi_codes(self):
23
+ s = "\033[2m[\033[0m\033[0;34mdir\033[0m"
24
+ assert visible_width(s) == 4 # [dir
25
+
26
+ def test_empty_string(self):
27
+ assert visible_width("") == 0
28
+
29
+ def test_unicode_icons(self):
30
+ assert visible_width("\u25cb") == 1 # ○
31
+ assert visible_width("\u26a1") == 1 # ⚡
32
+
33
+ def test_ansi_with_semicolons(self):
34
+ s = "\033[1;31;42mtext\033[0m"
35
+ assert visible_width(s) == 4
36
+
37
+
38
+ class TestGetTerminalWidth:
39
+ """Tests for get_terminal_width()."""
40
+
41
+ def test_fallback_to_200_when_no_columns_env(self):
42
+ """When COLUMNS is not set and shutil returns 80, use 200 (Claude Code subprocess)."""
43
+ with (
44
+ patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
45
+ patch.dict("os.environ", {}, clear=False),
46
+ ):
47
+ # Remove COLUMNS if present
48
+ import os
49
+
50
+ os.environ.pop("COLUMNS", None)
51
+ mock.return_value = type("Size", (), {"columns": 80})()
52
+ assert get_terminal_width() == 200
53
+
54
+ def test_respects_columns_env_80(self):
55
+ """When COLUMNS=80 is explicitly set, trust it."""
56
+ with (
57
+ patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
58
+ patch.dict("os.environ", {"COLUMNS": "80"}),
59
+ ):
60
+ mock.return_value = type("Size", (), {"columns": 80})()
61
+ assert get_terminal_width() == 80
62
+
63
+ def test_custom_width(self):
64
+ with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
65
+ mock.return_value = type("Size", (), {"columns": 120})()
66
+ assert get_terminal_width() == 120
67
+
68
+
69
+ class TestFitToWidth:
70
+ """Tests for fit_to_width()."""
71
+
72
+ def test_all_parts_fit(self):
73
+ parts = ["base", " | git", " | ctx"]
74
+ result = fit_to_width(parts, 80)
75
+ assert result == "base | git | ctx"
76
+
77
+ def test_drops_lowest_priority(self):
78
+ parts = ["base", " | git", " | ctx", " session-uuid-here"]
79
+ result = fit_to_width(parts, 25)
80
+ assert "base" in result
81
+ assert "session-uuid-here" not in result
82
+
83
+ def test_base_always_included(self):
84
+ parts = ["very-long-base-string-that-exceeds-width"]
85
+ result = fit_to_width(parts, 10)
86
+ assert result == "very-long-base-string-that-exceeds-width"
87
+
88
+ def test_empty_parts_skipped(self):
89
+ parts = ["base", "", " | ctx", "", " session"]
90
+ result = fit_to_width(parts, 30)
91
+ assert result == "base | ctx session"
92
+
93
+ def test_exact_boundary(self):
94
+ parts = ["12345", "67890"]
95
+ result = fit_to_width(parts, 10)
96
+ assert result == "1234567890"
97
+
98
+ def test_one_char_over_boundary(self):
99
+ parts = ["12345", "678901"]
100
+ result = fit_to_width(parts, 10)
101
+ assert result == "12345"
102
+
103
+ def test_empty_parts_list(self):
104
+ assert fit_to_width([], 80) == ""
105
+
106
+ def test_realistic_ansi_strings(self):
107
+ base = "\033[2m[Claude]\033[0m \033[0;34mdir\033[0m"
108
+ git = " | \033[0;35mmain\033[0m"
109
+ ctx = " | \033[0;32m150.0k free (75.0%)\033[0m"
110
+ session = " \033[2mtest-session-uuid-1234\033[0m"
111
+
112
+ # base=[Claude] dir = 12, git= | main = 7, ctx= | 150.0k free (75.0%) = 22,
113
+ # session= test-session-uuid-1234 = 23 => total = 64
114
+ result = fit_to_width([base, git, ctx, session], 80)
115
+ assert visible_width(result) == 64
116
+
117
+ # With tight width, session should be dropped
118
+ result = fit_to_width([base, git, ctx, session], 50)
119
+ assert "test-session-uuid-1234" not in result
120
+ assert visible_width(result) <= 50
121
+
122
+ def test_priority_order_preserved(self):
123
+ parts = ["base", " A", " B", " C", " D"]
124
+ # base=4, A=2, B=2, C=2, D=2 => total 12
125
+ # max_width=8 => base + A + B fits (8), C dropped, D dropped
126
+ result = fit_to_width(parts, 8)
127
+ assert result == "base A B"
@@ -1,24 +1,44 @@
1
1
  """Tests for statusline.py script."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
6
+ import os
7
+ import re
4
8
  import subprocess
5
9
  import sys
6
10
  from pathlib import Path
7
11
 
8
12
  SCRIPT_PATH = Path(__file__).parent.parent.parent / "scripts" / "statusline.py"
9
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
+
10
21
 
11
- def run_script(input_data: dict) -> tuple[str, int]:
22
+ def run_script(input_data: dict, env_overrides: dict | None = None) -> tuple[str, int]:
12
23
  """Run the statusline.py script with the given input.
13
24
 
25
+ Args:
26
+ input_data: JSON-serializable dict to pass as stdin.
27
+ env_overrides: Optional dict of environment variable overrides.
28
+
14
29
  Returns:
15
30
  Tuple of (stdout, return_code)
16
31
  """
32
+ env = os.environ.copy()
33
+ env["PYTHONUTF8"] = "1"
34
+ if env_overrides:
35
+ env.update(env_overrides)
17
36
  result = subprocess.run(
18
37
  [sys.executable, str(SCRIPT_PATH)],
19
38
  input=json.dumps(input_data),
20
39
  capture_output=True,
21
40
  text=True,
41
+ env=env,
22
42
  )
23
43
  return result.stdout.strip(), result.returncode
24
44
 
@@ -32,7 +52,7 @@ class TestStatuslineScript:
32
52
 
33
53
  def test_script_is_python(self):
34
54
  """Script should have Python shebang."""
35
- content = SCRIPT_PATH.read_text()
55
+ content = SCRIPT_PATH.read_text(encoding="utf-8")
36
56
  assert content.startswith("#!/usr/bin/env python3")
37
57
 
38
58
  def test_outputs_model_name(self, sample_input):
@@ -68,22 +88,28 @@ class TestStatuslineScript:
68
88
 
69
89
  def test_handles_invalid_json(self):
70
90
  """Should handle invalid JSON gracefully."""
91
+ env = os.environ.copy()
92
+ env["PYTHONUTF8"] = "1"
71
93
  result = subprocess.run(
72
94
  [sys.executable, str(SCRIPT_PATH)],
73
95
  input="invalid json",
74
96
  capture_output=True,
75
97
  text=True,
98
+ env=env,
76
99
  )
77
100
  assert result.returncode == 0
78
101
  assert "Claude" in result.stdout
79
102
 
80
103
  def test_handles_empty_input(self):
81
104
  """Should handle empty input gracefully."""
105
+ env = os.environ.copy()
106
+ env["PYTHONUTF8"] = "1"
82
107
  result = subprocess.run(
83
108
  [sys.executable, str(SCRIPT_PATH)],
84
109
  input="",
85
110
  capture_output=True,
86
111
  text=True,
112
+ env=env,
87
113
  )
88
114
  assert result.returncode == 0
89
115
 
@@ -141,7 +167,7 @@ class TestSessionDisplay:
141
167
  def test_shows_session_id_by_default(self, sample_input):
142
168
  """Should show session_id by default (show_session=true)."""
143
169
  sample_input["session_id"] = "test-session-12345"
144
- output, code = run_script(sample_input)
170
+ output, code = run_script(sample_input, {"COLUMNS": "200"})
145
171
  assert code == 0
146
172
  assert "test-session-12345" in output
147
173
 
@@ -152,3 +178,38 @@ class TestSessionDisplay:
152
178
  del sample_input["session_id"]
153
179
  output, code = run_script(sample_input)
154
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