cc-context-stats 1.3.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 (72) hide show
  1. package/.claude/commands/context-stats.md +17 -0
  2. package/.claude/settings.local.json +85 -0
  3. package/.editorconfig +60 -0
  4. package/.eslintrc.json +35 -0
  5. package/.github/dependabot.yml +44 -0
  6. package/.github/workflows/ci.yml +255 -0
  7. package/.github/workflows/release.yml +149 -0
  8. package/.pre-commit-config.yaml +74 -0
  9. package/.prettierrc +33 -0
  10. package/.shellcheckrc +10 -0
  11. package/CHANGELOG.md +100 -0
  12. package/CONTRIBUTING.md +240 -0
  13. package/PUBLISHING_GUIDE.md +69 -0
  14. package/README.md +179 -0
  15. package/config/settings-example.json +7 -0
  16. package/config/settings-node.json +7 -0
  17. package/config/settings-python.json +7 -0
  18. package/docs/configuration.md +83 -0
  19. package/docs/context-stats.md +132 -0
  20. package/docs/installation.md +195 -0
  21. package/docs/scripts.md +116 -0
  22. package/docs/troubleshooting.md +189 -0
  23. package/images/claude-statusline-token-graph.gif +0 -0
  24. package/images/claude-statusline.png +0 -0
  25. package/images/context-status-dumbzone.png +0 -0
  26. package/images/context-status.png +0 -0
  27. package/images/statusline-detail.png +0 -0
  28. package/images/token-graph.jpeg +0 -0
  29. package/images/token-graph.png +0 -0
  30. package/install +344 -0
  31. package/install.sh +272 -0
  32. package/jest.config.js +11 -0
  33. package/npm-publish.sh +33 -0
  34. package/package.json +36 -0
  35. package/publish.sh +24 -0
  36. package/pyproject.toml +113 -0
  37. package/requirements-dev.txt +12 -0
  38. package/scripts/context-stats.sh +970 -0
  39. package/scripts/statusline-full.sh +241 -0
  40. package/scripts/statusline-git.sh +32 -0
  41. package/scripts/statusline-minimal.sh +11 -0
  42. package/scripts/statusline.js +350 -0
  43. package/scripts/statusline.py +312 -0
  44. package/show_raw_claude_code_api.js +11 -0
  45. package/src/claude_statusline/__init__.py +11 -0
  46. package/src/claude_statusline/__main__.py +6 -0
  47. package/src/claude_statusline/cli/__init__.py +1 -0
  48. package/src/claude_statusline/cli/context_stats.py +379 -0
  49. package/src/claude_statusline/cli/statusline.py +172 -0
  50. package/src/claude_statusline/core/__init__.py +1 -0
  51. package/src/claude_statusline/core/colors.py +55 -0
  52. package/src/claude_statusline/core/config.py +98 -0
  53. package/src/claude_statusline/core/git.py +67 -0
  54. package/src/claude_statusline/core/state.py +266 -0
  55. package/src/claude_statusline/formatters/__init__.py +1 -0
  56. package/src/claude_statusline/formatters/time.py +50 -0
  57. package/src/claude_statusline/formatters/tokens.py +70 -0
  58. package/src/claude_statusline/graphs/__init__.py +1 -0
  59. package/src/claude_statusline/graphs/renderer.py +346 -0
  60. package/src/claude_statusline/graphs/statistics.py +58 -0
  61. package/tests/bash/test_install.bats +29 -0
  62. package/tests/bash/test_statusline_full.bats +109 -0
  63. package/tests/bash/test_statusline_git.bats +42 -0
  64. package/tests/bash/test_statusline_minimal.bats +37 -0
  65. package/tests/fixtures/json/high_usage.json +17 -0
  66. package/tests/fixtures/json/low_usage.json +17 -0
  67. package/tests/fixtures/json/medium_usage.json +17 -0
  68. package/tests/fixtures/json/valid_full.json +30 -0
  69. package/tests/fixtures/json/valid_minimal.json +9 -0
  70. package/tests/node/statusline.test.js +199 -0
  71. package/tests/python/conftest.py +84 -0
  72. package/tests/python/test_statusline.py +154 -0
@@ -0,0 +1,346 @@
1
+ """ASCII graph rendering engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+
8
+ from claude_statusline.core.colors import ColorManager
9
+ from claude_statusline.formatters.time import format_duration, format_timestamp
10
+ from claude_statusline.formatters.tokens import format_tokens
11
+ from claude_statusline.graphs.statistics import calculate_stats
12
+
13
+
14
+ @dataclass
15
+ class GraphDimensions:
16
+ """Terminal and graph dimensions."""
17
+
18
+ term_width: int
19
+ term_height: int
20
+ graph_width: int
21
+ graph_height: int
22
+
23
+ @classmethod
24
+ def detect(cls) -> GraphDimensions:
25
+ """Detect terminal dimensions and calculate graph size."""
26
+ term_size = shutil.get_terminal_size((80, 24))
27
+ term_width = term_size.columns
28
+ term_height = term_size.lines
29
+
30
+ # Calculate graph dimensions
31
+ graph_width = term_width - 15 # Reserve space for Y-axis labels
32
+ graph_height = term_height // 3 # Each graph takes 1/3 of terminal
33
+
34
+ # Enforce minimums and maximums
35
+ graph_width = max(30, graph_width)
36
+ graph_height = max(8, min(20, graph_height))
37
+
38
+ return cls(
39
+ term_width=term_width,
40
+ term_height=term_height,
41
+ graph_width=graph_width,
42
+ graph_height=graph_height,
43
+ )
44
+
45
+
46
+ class GraphRenderer:
47
+ """ASCII graph rendering engine."""
48
+
49
+ # Characters for graph rendering
50
+ DOT = "●"
51
+ FILL_LIGHT = "▒"
52
+ FILL_DARK = "░"
53
+
54
+ def __init__(
55
+ self,
56
+ colors: ColorManager | None = None,
57
+ dimensions: GraphDimensions | None = None,
58
+ token_detail: bool = True,
59
+ ) -> None:
60
+ """Initialize graph renderer.
61
+
62
+ Args:
63
+ colors: ColorManager instance. Creates default if None.
64
+ dimensions: GraphDimensions instance. Detects if None.
65
+ token_detail: Whether to show detailed token counts.
66
+ """
67
+ self.colors = colors or ColorManager(enabled=True)
68
+ self.dimensions = dimensions or GraphDimensions.detect()
69
+ self.token_detail = token_detail
70
+
71
+ def render_timeseries(
72
+ self,
73
+ data: list[int],
74
+ timestamps: list[int],
75
+ title: str,
76
+ color: str,
77
+ ) -> None:
78
+ """Render a timeseries ASCII graph.
79
+
80
+ Args:
81
+ data: List of values to plot
82
+ timestamps: Corresponding timestamps for x-axis labels
83
+ title: Graph title
84
+ color: ANSI color code for the graph
85
+ """
86
+ n = len(data)
87
+ if n == 0:
88
+ return
89
+
90
+ stats = calculate_stats(data)
91
+ min_val = stats.min_val
92
+ max_val = stats.max_val
93
+
94
+ # Avoid division by zero
95
+ if min_val == max_val:
96
+ max_val = min_val + 1
97
+ value_range = max_val - min_val
98
+
99
+ width = self.dimensions.graph_width
100
+ height = self.dimensions.graph_height
101
+
102
+ # Print title and stats
103
+ print()
104
+ print(f"{self.colors.bold}{title}{self.colors.reset}")
105
+ print(
106
+ f"{self.colors.dim}Max: {format_tokens(max_val, self.token_detail)} "
107
+ f"Min: {format_tokens(min_val, self.token_detail)} "
108
+ f"Points: {n}{self.colors.reset}"
109
+ )
110
+ print()
111
+
112
+ # Build the graph grid
113
+ grid = self._build_grid(data, min_val, max_val, value_range, width, height)
114
+
115
+ # Print grid with Y-axis labels
116
+ for r in range(height):
117
+ val = max_val - r * value_range // (height - 1)
118
+
119
+ # Show labels at top, middle, and bottom
120
+ if r == 0 or r == height // 2 or r == height - 1:
121
+ label = format_tokens(val, self.token_detail)
122
+ else:
123
+ label = ""
124
+
125
+ row_data = grid[r] if r < len(grid) else " " * width
126
+ print(
127
+ f"{label:>10} {self.colors.dim}│{self.colors.reset}"
128
+ f"{color}{row_data}{self.colors.reset}"
129
+ )
130
+
131
+ # X-axis
132
+ print(f"{'':>10} {self.colors.dim}└{'─' * width}{self.colors.reset}")
133
+
134
+ # Time labels
135
+ if timestamps:
136
+ first_time = format_timestamp(timestamps[0])
137
+ last_time = format_timestamp(timestamps[-1])
138
+ mid_idx = (n - 1) // 2
139
+ mid_time = format_timestamp(timestamps[mid_idx]) if n > 2 else ""
140
+
141
+ spacing = width // 3
142
+ print(
143
+ f"{' ':>11}{self.colors.dim}"
144
+ f"{first_time:<{spacing}}{mid_time}{last_time:>{spacing}}"
145
+ f"{self.colors.reset}"
146
+ )
147
+
148
+ def _build_grid(
149
+ self,
150
+ data: list[int],
151
+ min_val: int,
152
+ max_val: int,
153
+ value_range: int,
154
+ width: int,
155
+ height: int,
156
+ ) -> list[str]:
157
+ """Build the ASCII grid for the graph.
158
+
159
+ Args:
160
+ data: List of values to plot
161
+ min_val: Minimum value in data
162
+ max_val: Maximum value in data
163
+ value_range: max_val - min_val
164
+ width: Graph width in characters
165
+ height: Graph height in rows
166
+
167
+ Returns:
168
+ List of strings, one per row
169
+ """
170
+ n = len(data)
171
+ if n == 0:
172
+ return [" " * width for _ in range(height)]
173
+
174
+ # Initialize grid with empty spaces
175
+ grid = [[" " for _ in range(width)] for _ in range(height)]
176
+
177
+ # Calculate y positions for each data point
178
+ data_x = []
179
+ data_y = []
180
+ for i, val in enumerate(data):
181
+ # Map index to x coordinate
182
+ if n == 1:
183
+ x = width // 2
184
+ else:
185
+ x = int((i) * (width - 1) / (n - 1))
186
+ x = max(0, min(width - 1, x))
187
+
188
+ # Map value to y coordinate (inverted: 0=top)
189
+ if value_range == 0:
190
+ y = height // 2
191
+ else:
192
+ y = int((max_val - val) * (height - 1) / value_range)
193
+ y = max(0, min(height - 1, y))
194
+
195
+ data_x.append(x)
196
+ data_y.append(y)
197
+
198
+ # Interpolate between points to fill every x position
199
+ line_y = [-1.0] * width
200
+ for i in range(len(data) - 1):
201
+ x1, y1 = data_x[i], data_y[i]
202
+ x2, y2 = data_x[i + 1], data_y[i + 1]
203
+
204
+ # Ensure we don't go out of bounds
205
+ for x in range(x1, min(x2 + 1, width)):
206
+ if x2 == x1:
207
+ y_interp = float(y1)
208
+ else:
209
+ # Linear interpolation
210
+ t = (x - x1) / (x2 - x1)
211
+ y_interp = y1 + t * (y2 - y1)
212
+ line_y[x] = y_interp
213
+
214
+ # Draw filled area and line
215
+ for c in range(width):
216
+ if line_y[c] >= 0:
217
+ line_row = int(line_y[c] + 0.5) # Round to nearest integer
218
+ line_row = max(0, min(height - 1, line_row))
219
+
220
+ # Fill area below the line with gradient
221
+ for r in range(line_row, height):
222
+ if r == line_row:
223
+ grid[r][c] = self.DOT
224
+ elif r < line_row + 2:
225
+ grid[r][c] = self.FILL_LIGHT
226
+ else:
227
+ grid[r][c] = self.FILL_DARK
228
+
229
+ # Mark actual data points
230
+ for i in range(len(data)):
231
+ x = data_x[i]
232
+ y = max(0, min(height - 1, int(data_y[i] + 0.5)))
233
+ grid[y][x] = self.DOT
234
+
235
+ # Convert grid to strings
236
+ return ["".join(row) for row in grid]
237
+
238
+ def render_summary(
239
+ self,
240
+ entries: list, # list[StateEntry]
241
+ deltas: list[int],
242
+ ) -> None:
243
+ """Render summary statistics.
244
+
245
+ Args:
246
+ entries: List of StateEntry objects
247
+ deltas: List of token deltas
248
+ """
249
+ if not entries:
250
+ return
251
+
252
+ first = entries[0]
253
+ last = entries[-1]
254
+ duration = last.timestamp - first.timestamp
255
+
256
+ # Context window info - use current_used_tokens which represents actual context usage
257
+ remaining_context = 0
258
+ remaining_percentage = 0
259
+ usage_percentage = 0
260
+ if last.context_window_size > 0:
261
+ # current_used_tokens = current_input_tokens + cache_creation + cache_read
262
+ current_used = last.current_used_tokens
263
+ remaining_context = max(0, last.context_window_size - current_used)
264
+ remaining_percentage = remaining_context * 100 // last.context_window_size
265
+ usage_percentage = 100 - remaining_percentage
266
+
267
+ # Determine status based on context usage
268
+ if usage_percentage < 40:
269
+ status_color = self.colors.green
270
+ status_text = "Smart Zone"
271
+ status_hint = "You are in the smart zone"
272
+ elif usage_percentage < 80:
273
+ status_color = self.colors.yellow
274
+ status_text = "Dumb Zone"
275
+ status_hint = "You are in the dumb zone - Dex Horthy says so"
276
+ else:
277
+ status_color = self.colors.red
278
+ status_text = "Wrap Up Zone"
279
+ status_hint = "Better to wrap up and start a new session"
280
+
281
+ print()
282
+ print(f"{self.colors.bold}Session Summary{self.colors.reset}")
283
+ line_width = self.dimensions.graph_width + 11
284
+ print(f"{self.colors.dim}{'-' * line_width}{self.colors.reset}")
285
+
286
+ # Context remaining (before status)
287
+ if last.context_window_size > 0:
288
+ print(
289
+ f" {status_color}{'Context Remaining:':<20}{self.colors.reset} "
290
+ f"{format_tokens(remaining_context, self.token_detail)}/{format_tokens(last.context_window_size, self.token_detail)} ({remaining_percentage}%)"
291
+ )
292
+
293
+ # Status indicator - highlighted
294
+ if last.context_window_size > 0:
295
+ print(
296
+ f" {status_color}{self.colors.bold}>>> {status_text.upper()} <<<{self.colors.reset} "
297
+ f"{self.colors.dim}({status_hint}){self.colors.reset}"
298
+ )
299
+ print()
300
+
301
+ # Session details (ordered: Last Growth, I/O, Lines, Cost, Model, Duration)
302
+ if deltas:
303
+ current_growth = deltas[-1]
304
+ print(
305
+ f" {self.colors.cyan}{'Last Growth:':<20}{self.colors.reset} "
306
+ f"+{format_tokens(current_growth, self.token_detail)}"
307
+ )
308
+ print(
309
+ f" {self.colors.blue}{'Input Tokens:':<20}{self.colors.reset} "
310
+ f"{format_tokens(last.current_input_tokens, self.token_detail)}"
311
+ )
312
+ print(
313
+ f" {self.colors.magenta}{'Output Tokens:':<20}{self.colors.reset} "
314
+ f"{format_tokens(last.current_output_tokens, self.token_detail)}"
315
+ )
316
+ if last.lines_added > 0 or last.lines_removed > 0:
317
+ print(
318
+ f" {self.colors.dim}{'Lines Changed:':<20}{self.colors.reset} "
319
+ f"{self.colors.green}+{last.lines_added:,}{self.colors.reset} / "
320
+ f"{self.colors.red}-{last.lines_removed:,}{self.colors.reset}"
321
+ )
322
+ if last.cost_usd > 0:
323
+ print(
324
+ f" {self.colors.yellow}{'Total Cost:':<20}{self.colors.reset} ${last.cost_usd:.4f}"
325
+ )
326
+ if last.model_id:
327
+ print(f" {self.colors.dim}{'Model:':<20}{self.colors.reset} {last.model_id}")
328
+ print(
329
+ f" {self.colors.cyan}{'Session Duration:':<20}{self.colors.reset} "
330
+ f"{format_duration(duration)}"
331
+ )
332
+ print()
333
+
334
+ def render_footer(self, version: str = "1.0.0", commit_hash: str = "dev") -> None:
335
+ """Render the footer with version info.
336
+
337
+ Args:
338
+ version: Package version
339
+ commit_hash: Git commit hash
340
+ """
341
+ print(
342
+ f"{self.colors.dim}Powered by {self.colors.cyan}claude-statusline"
343
+ f"{self.colors.dim} v{version}-{commit_hash} - "
344
+ f"https://github.com/luongnv89/cc-context-stats{self.colors.reset}"
345
+ )
346
+ print()
@@ -0,0 +1,58 @@
1
+ """Statistical calculations for token data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Stats:
10
+ """Statistical summary of a data series."""
11
+
12
+ min_val: int
13
+ max_val: int
14
+ avg_val: int
15
+ total: int
16
+ count: int
17
+
18
+
19
+ def calculate_stats(data: list[int]) -> Stats:
20
+ """Calculate basic statistics for a list of integers.
21
+
22
+ Args:
23
+ data: List of integer values
24
+
25
+ Returns:
26
+ Stats object with min, max, avg, total, and count
27
+ """
28
+ if not data:
29
+ return Stats(min_val=0, max_val=0, avg_val=0, total=0, count=0)
30
+
31
+ min_val = min(data)
32
+ max_val = max(data)
33
+ total = sum(data)
34
+ count = len(data)
35
+ avg_val = total // count if count > 0 else 0
36
+
37
+ return Stats(min_val=min_val, max_val=max_val, avg_val=avg_val, total=total, count=count)
38
+
39
+
40
+ def calculate_deltas(values: list[int]) -> list[int]:
41
+ """Calculate deltas between consecutive values.
42
+
43
+ Args:
44
+ values: List of values (e.g., cumulative token counts)
45
+
46
+ Returns:
47
+ List of deltas (length = len(values) - 1)
48
+ """
49
+ if len(values) < 2:
50
+ return []
51
+
52
+ deltas = []
53
+ for i in range(1, len(values)):
54
+ delta = values[i] - values[i - 1]
55
+ # Handle negative deltas (session reset) by showing 0
56
+ deltas.append(max(0, delta))
57
+
58
+ return deltas
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Test suite for install.sh
4
+
5
+ setup() {
6
+ PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
7
+ SCRIPT="$PROJECT_ROOT/install.sh"
8
+ }
9
+
10
+ @test "install.sh exists and is executable" {
11
+ [ -f "$SCRIPT" ]
12
+ [ -x "$SCRIPT" ]
13
+ }
14
+
15
+ @test "install.sh contains expected functions" {
16
+ grep -q "check_jq" "$SCRIPT"
17
+ grep -q "select_script" "$SCRIPT"
18
+ grep -q "ensure_claude_dir" "$SCRIPT"
19
+ grep -q "install_script" "$SCRIPT"
20
+ grep -q "update_settings" "$SCRIPT"
21
+ }
22
+
23
+ @test "install.sh has correct shebang" {
24
+ head -1 "$SCRIPT" | grep -q "#!/bin/bash"
25
+ }
26
+
27
+ @test "install.sh uses set -e for error handling" {
28
+ grep -q "set -e" "$SCRIPT"
29
+ }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Test suite for statusline-full.sh
4
+
5
+ setup() {
6
+ PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
7
+ SCRIPT="$PROJECT_ROOT/scripts/statusline-full.sh"
8
+ FIXTURES="$PROJECT_ROOT/tests/fixtures/json"
9
+
10
+ # Create a temp directory for config tests
11
+ TEST_HOME=$(mktemp -d)
12
+ export HOME="$TEST_HOME"
13
+ mkdir -p "$TEST_HOME/.claude"
14
+ }
15
+
16
+ teardown() {
17
+ rm -rf "$TEST_HOME"
18
+ }
19
+
20
+ @test "statusline-full.sh exists and is executable" {
21
+ [ -f "$SCRIPT" ]
22
+ [ -x "$SCRIPT" ]
23
+ }
24
+
25
+ @test "outputs model name from JSON input" {
26
+ input='{"model":{"display_name":"Opus 4.5"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}'
27
+ result=$(echo "$input" | "$SCRIPT")
28
+ [[ "$result" == *"Opus 4.5"* ]]
29
+ }
30
+
31
+ @test "outputs directory name from path" {
32
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/home/user/myproject","project_dir":"/home/user/myproject"}}'
33
+ result=$(echo "$input" | "$SCRIPT")
34
+ [[ "$result" == *"myproject"* ]]
35
+ }
36
+
37
+ @test "handles full valid input with context window" {
38
+ result=$(cat "$FIXTURES/valid_full.json" | "$SCRIPT")
39
+ [[ "$result" == *"Opus 4.5"* ]]
40
+ [[ "$result" == *"my-project"* ]]
41
+ [[ "$result" == *"free"* ]]
42
+ }
43
+
44
+ @test "shows AC indicator when autocompact enabled" {
45
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
46
+ result=$(echo "$input" | "$SCRIPT")
47
+ [[ "$result" == *"[AC:"* ]]
48
+ }
49
+
50
+ @test "shows AC:off when autocompact disabled in config" {
51
+ echo "autocompact=false" > "$TEST_HOME/.claude/statusline.conf"
52
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
53
+ result=$(echo "$input" | "$SCRIPT")
54
+ [[ "$result" == *"[AC:off]"* ]]
55
+ }
56
+
57
+ @test "shows exact tokens by default (token_detail=true)" {
58
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
59
+ result=$(echo "$input" | "$SCRIPT")
60
+ # Should NOT show 'k' suffix by default, should show comma-formatted number
61
+ [[ "$result" != *"k free"* ]]
62
+ [[ "$result" == *"free"* ]]
63
+ }
64
+
65
+ @test "shows abbreviated tokens when token_detail=false" {
66
+ echo "token_detail=false" > "$TEST_HOME/.claude/statusline.conf"
67
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
68
+ result=$(echo "$input" | "$SCRIPT")
69
+ # Should show 'k' suffix for abbreviated format
70
+ [[ "$result" == *"k free"* ]]
71
+ }
72
+
73
+ @test "handles missing context window gracefully" {
74
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}'
75
+ run bash "$SCRIPT" <<< "$input"
76
+ [ "$status" -eq 0 ]
77
+ }
78
+
79
+ @test "calculates free tokens percentage correctly" {
80
+ # Low usage fixture: 30k tokens used out of 200k = 85% free
81
+ result=$(cat "$FIXTURES/low_usage.json" | "$SCRIPT")
82
+ [[ "$result" == *"free"* ]]
83
+ }
84
+
85
+ @test "uses fixture files correctly" {
86
+ for fixture in valid_full valid_minimal low_usage medium_usage high_usage; do
87
+ run bash "$SCRIPT" < "$FIXTURES/${fixture}.json"
88
+ [ "$status" -eq 0 ]
89
+ done
90
+ }
91
+
92
+ @test "shows session_id by default (show_session=true)" {
93
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"session_id":"test-session-123"}'
94
+ result=$(echo "$input" | "$SCRIPT")
95
+ [[ "$result" == *"test-session-123"* ]]
96
+ }
97
+
98
+ @test "hides session_id when show_session=false" {
99
+ echo "show_session=false" > "$TEST_HOME/.claude/statusline.conf"
100
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"session_id":"test-session-123"}'
101
+ result=$(echo "$input" | "$SCRIPT")
102
+ [[ "$result" != *"test-session-123"* ]]
103
+ }
104
+
105
+ @test "handles missing session_id gracefully" {
106
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"}}'
107
+ run bash "$SCRIPT" <<< "$input"
108
+ [ "$status" -eq 0 ]
109
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Test suite for statusline-git.sh
4
+
5
+ setup() {
6
+ PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
7
+ SCRIPT="$PROJECT_ROOT/scripts/statusline-git.sh"
8
+ FIXTURES="$PROJECT_ROOT/tests/fixtures/json"
9
+ }
10
+
11
+ @test "statusline-git.sh exists and is executable" {
12
+ [ -f "$SCRIPT" ]
13
+ [ -x "$SCRIPT" ]
14
+ }
15
+
16
+ @test "outputs model name from JSON input" {
17
+ input='{"model":{"display_name":"Opus 4.5"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}'
18
+ result=$(echo "$input" | "$SCRIPT")
19
+ [[ "$result" == *"Opus 4.5"* ]]
20
+ }
21
+
22
+ @test "outputs directory name" {
23
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/home/user/myproject","project_dir":"/tmp"}}'
24
+ result=$(echo "$input" | "$SCRIPT")
25
+ [[ "$result" == *"myproject"* ]]
26
+ }
27
+
28
+ @test "handles valid input without crashing" {
29
+ run bash "$SCRIPT" < "$FIXTURES/valid_full.json"
30
+ [ "$status" -eq 0 ]
31
+ }
32
+
33
+ @test "shows git branch when in git repo" {
34
+ # Use project dir which is a git repo
35
+ input=$(cat <<EOF
36
+ {"model":{"display_name":"Claude"},"workspace":{"current_dir":"$PROJECT_ROOT","project_dir":"$PROJECT_ROOT"}}
37
+ EOF
38
+ )
39
+ result=$(echo "$input" | "$SCRIPT")
40
+ # Should contain branch name (main or master usually)
41
+ [[ "$result" == *"main"* ]] || [[ "$result" == *"master"* ]] || true
42
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Test suite for statusline-minimal.sh
4
+
5
+ setup() {
6
+ PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
7
+ SCRIPT="$PROJECT_ROOT/scripts/statusline-minimal.sh"
8
+ FIXTURES="$PROJECT_ROOT/tests/fixtures/json"
9
+ }
10
+
11
+ @test "statusline-minimal.sh exists and is executable" {
12
+ [ -f "$SCRIPT" ]
13
+ [ -x "$SCRIPT" ]
14
+ }
15
+
16
+ @test "outputs model name from JSON input" {
17
+ input='{"model":{"display_name":"Opus 4.5"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}'
18
+ result=$(echo "$input" | "$SCRIPT")
19
+ [[ "$result" == *"Opus 4.5"* ]]
20
+ }
21
+
22
+ @test "outputs directory name" {
23
+ input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/home/user/myproject","project_dir":"/tmp"}}'
24
+ result=$(echo "$input" | "$SCRIPT")
25
+ [[ "$result" == *"myproject"* ]]
26
+ }
27
+
28
+ @test "handles minimal valid input" {
29
+ result=$(cat "$FIXTURES/valid_minimal.json" | "$SCRIPT")
30
+ [[ "$result" == *"Claude"* ]]
31
+ [[ "$result" == *"test"* ]]
32
+ }
33
+
34
+ @test "script runs without errors on valid input" {
35
+ run bash "$SCRIPT" < "$FIXTURES/valid_full.json"
36
+ [ "$status" -eq 0 ]
37
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "model": {
3
+ "display_name": "Opus 4.5"
4
+ },
5
+ "workspace": {
6
+ "current_dir": "/home/user/project",
7
+ "project_dir": "/home/user/project"
8
+ },
9
+ "context_window": {
10
+ "context_window_size": 200000,
11
+ "current_usage": {
12
+ "input_tokens": 170000,
13
+ "cache_creation_input_tokens": 0,
14
+ "cache_read_input_tokens": 0
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "model": {
3
+ "display_name": "Opus 4.5"
4
+ },
5
+ "workspace": {
6
+ "current_dir": "/home/user/project",
7
+ "project_dir": "/home/user/project"
8
+ },
9
+ "context_window": {
10
+ "context_window_size": 200000,
11
+ "current_usage": {
12
+ "input_tokens": 20000,
13
+ "cache_creation_input_tokens": 5000,
14
+ "cache_read_input_tokens": 5000
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "model": {
3
+ "display_name": "Opus 4.5"
4
+ },
5
+ "workspace": {
6
+ "current_dir": "/home/user/project",
7
+ "project_dir": "/home/user/project"
8
+ },
9
+ "context_window": {
10
+ "context_window_size": 200000,
11
+ "current_usage": {
12
+ "input_tokens": 100000,
13
+ "cache_creation_input_tokens": 20000,
14
+ "cache_read_input_tokens": 0
15
+ }
16
+ }
17
+ }