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.
- package/.claude/commands/context-stats.md +17 -0
- package/.claude/settings.local.json +85 -0
- package/.editorconfig +60 -0
- package/.eslintrc.json +35 -0
- package/.github/dependabot.yml +44 -0
- package/.github/workflows/ci.yml +255 -0
- package/.github/workflows/release.yml +149 -0
- package/.pre-commit-config.yaml +74 -0
- package/.prettierrc +33 -0
- package/.shellcheckrc +10 -0
- package/CHANGELOG.md +100 -0
- package/CONTRIBUTING.md +240 -0
- package/PUBLISHING_GUIDE.md +69 -0
- package/README.md +179 -0
- package/config/settings-example.json +7 -0
- package/config/settings-node.json +7 -0
- package/config/settings-python.json +7 -0
- package/docs/configuration.md +83 -0
- package/docs/context-stats.md +132 -0
- package/docs/installation.md +195 -0
- package/docs/scripts.md +116 -0
- package/docs/troubleshooting.md +189 -0
- package/images/claude-statusline-token-graph.gif +0 -0
- package/images/claude-statusline.png +0 -0
- package/images/context-status-dumbzone.png +0 -0
- package/images/context-status.png +0 -0
- package/images/statusline-detail.png +0 -0
- package/images/token-graph.jpeg +0 -0
- package/images/token-graph.png +0 -0
- package/install +344 -0
- package/install.sh +272 -0
- package/jest.config.js +11 -0
- package/npm-publish.sh +33 -0
- package/package.json +36 -0
- package/publish.sh +24 -0
- package/pyproject.toml +113 -0
- package/requirements-dev.txt +12 -0
- package/scripts/context-stats.sh +970 -0
- package/scripts/statusline-full.sh +241 -0
- package/scripts/statusline-git.sh +32 -0
- package/scripts/statusline-minimal.sh +11 -0
- package/scripts/statusline.js +350 -0
- package/scripts/statusline.py +312 -0
- package/show_raw_claude_code_api.js +11 -0
- package/src/claude_statusline/__init__.py +11 -0
- package/src/claude_statusline/__main__.py +6 -0
- package/src/claude_statusline/cli/__init__.py +1 -0
- package/src/claude_statusline/cli/context_stats.py +379 -0
- package/src/claude_statusline/cli/statusline.py +172 -0
- package/src/claude_statusline/core/__init__.py +1 -0
- package/src/claude_statusline/core/colors.py +55 -0
- package/src/claude_statusline/core/config.py +98 -0
- package/src/claude_statusline/core/git.py +67 -0
- package/src/claude_statusline/core/state.py +266 -0
- package/src/claude_statusline/formatters/__init__.py +1 -0
- package/src/claude_statusline/formatters/time.py +50 -0
- package/src/claude_statusline/formatters/tokens.py +70 -0
- package/src/claude_statusline/graphs/__init__.py +1 -0
- package/src/claude_statusline/graphs/renderer.py +346 -0
- package/src/claude_statusline/graphs/statistics.py +58 -0
- package/tests/bash/test_install.bats +29 -0
- package/tests/bash/test_statusline_full.bats +109 -0
- package/tests/bash/test_statusline_git.bats +42 -0
- package/tests/bash/test_statusline_minimal.bats +37 -0
- package/tests/fixtures/json/high_usage.json +17 -0
- package/tests/fixtures/json/low_usage.json +17 -0
- package/tests/fixtures/json/medium_usage.json +17 -0
- package/tests/fixtures/json/valid_full.json +30 -0
- package/tests/fixtures/json/valid_minimal.json +9 -0
- package/tests/node/statusline.test.js +199 -0
- package/tests/python/conftest.py +84 -0
- 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
|
+
}
|