cc-context-stats 1.7.0 → 1.8.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.
- package/package.json +9 -1
- package/scripts/context-stats.sh +1 -1
- package/scripts/statusline.js +128 -18
- package/.editorconfig +0 -60
- package/.eslintrc.json +0 -35
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
- package/.github/dependabot.yml +0 -44
- package/.github/workflows/ci.yml +0 -294
- package/.github/workflows/release.yml +0 -151
- package/.pre-commit-config.yaml +0 -74
- package/.prettierrc +0 -33
- package/.shellcheckrc +0 -10
- package/CHANGELOG.md +0 -163
- package/CLAUDE.md +0 -66
- package/CODE_OF_CONDUCT.md +0 -59
- package/CONTRIBUTING.md +0 -240
- package/RELEASE_NOTES.md +0 -19
- package/SECURITY.md +0 -44
- package/TODOS.md +0 -72
- package/assets/logo/favicon.svg +0 -19
- package/assets/logo/logo-black.svg +0 -24
- package/assets/logo/logo-full.svg +0 -40
- package/assets/logo/logo-icon.svg +0 -27
- package/assets/logo/logo-mark.svg +0 -28
- package/assets/logo/logo-white.svg +0 -24
- package/assets/logo/logo-wordmark.svg +0 -6
- package/config/settings-example.json +0 -7
- package/config/settings-node.json +0 -7
- package/config/settings-python.json +0 -7
- package/docs/ARCHITECTURE.md +0 -128
- package/docs/CSV_FORMAT.md +0 -42
- package/docs/DEPLOYMENT.md +0 -71
- package/docs/DEVELOPMENT.md +0 -161
- package/docs/configuration.md +0 -118
- package/docs/context-stats.md +0 -143
- package/docs/installation.md +0 -255
- package/docs/scripts.md +0 -140
- package/docs/troubleshooting.md +0 -278
- 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/images/v1.6.1.png +0 -0
- package/install +0 -351
- package/install.sh +0 -298
- package/jest.config.js +0 -11
- package/pyproject.toml +0 -115
- package/requirements-dev.txt +0 -12
- package/scripts/statusline-full.sh +0 -304
- package/scripts/statusline-git.sh +0 -88
- package/scripts/statusline-minimal.sh +0 -67
- package/scripts/statusline.py +0 -485
- package/src/claude_statusline/__init__.py +0 -11
- package/src/claude_statusline/__main__.py +0 -6
- package/src/claude_statusline/cli/__init__.py +0 -1
- package/src/claude_statusline/cli/context_stats.py +0 -512
- package/src/claude_statusline/cli/explain.py +0 -228
- package/src/claude_statusline/cli/statusline.py +0 -169
- package/src/claude_statusline/core/__init__.py +0 -1
- package/src/claude_statusline/core/colors.py +0 -124
- package/src/claude_statusline/core/config.py +0 -148
- package/src/claude_statusline/core/git.py +0 -78
- package/src/claude_statusline/core/state.py +0 -323
- package/src/claude_statusline/formatters/__init__.py +0 -1
- package/src/claude_statusline/formatters/layout.py +0 -67
- package/src/claude_statusline/formatters/time.py +0 -50
- package/src/claude_statusline/formatters/tokens.py +0 -70
- package/src/claude_statusline/graphs/__init__.py +0 -1
- package/src/claude_statusline/graphs/renderer.py +0 -366
- package/src/claude_statusline/graphs/statistics.py +0 -92
- package/src/claude_statusline/ui/__init__.py +0 -1
- package/src/claude_statusline/ui/icons.py +0 -93
- package/src/claude_statusline/ui/waiting.py +0 -62
- package/tests/bash/test_delta_parity.bats +0 -199
- package/tests/bash/test_install.bats +0 -29
- package/tests/bash/test_parity.bats +0 -315
- package/tests/bash/test_statusline_full.bats +0 -139
- package/tests/bash/test_statusline_git.bats +0 -42
- package/tests/bash/test_statusline_minimal.bats +0 -37
- package/tests/fixtures/json/comma_in_path.json +0 -31
- package/tests/fixtures/json/high_usage.json +0 -17
- package/tests/fixtures/json/low_usage.json +0 -17
- package/tests/fixtures/json/medium_usage.json +0 -17
- package/tests/fixtures/json/valid_full.json +0 -30
- package/tests/fixtures/json/valid_minimal.json +0 -9
- package/tests/node/rotation.test.js +0 -89
- package/tests/node/statusline.test.js +0 -240
- package/tests/python/conftest.py +0 -84
- package/tests/python/test_colors.py +0 -105
- package/tests/python/test_config_colors.py +0 -78
- package/tests/python/test_data_pipeline.py +0 -446
- package/tests/python/test_explain.py +0 -177
- package/tests/python/test_icons.py +0 -152
- package/tests/python/test_layout.py +0 -127
- package/tests/python/test_state_rotation_validation.py +0 -232
- package/tests/python/test_statusline.py +0 -215
- package/tests/python/test_waiting.py +0 -127
|
@@ -1,366 +0,0 @@
|
|
|
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_deltas, 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
|
-
self._output_lines: list[str] | None = None
|
|
71
|
-
|
|
72
|
-
def begin_buffering(self) -> None:
|
|
73
|
-
"""Start buffering output instead of printing directly."""
|
|
74
|
-
self._output_lines = []
|
|
75
|
-
|
|
76
|
-
def get_buffer(self) -> str:
|
|
77
|
-
"""Return buffered output as a single string and stop buffering."""
|
|
78
|
-
if self._output_lines is None:
|
|
79
|
-
return ""
|
|
80
|
-
result = "\n".join(self._output_lines)
|
|
81
|
-
self._output_lines = None
|
|
82
|
-
return result
|
|
83
|
-
|
|
84
|
-
def _emit(self, line: str = "") -> None:
|
|
85
|
-
"""Emit a line of output. Buffers if buffering is active, otherwise prints."""
|
|
86
|
-
if self._output_lines is not None:
|
|
87
|
-
self._output_lines.append(line)
|
|
88
|
-
else:
|
|
89
|
-
print(line)
|
|
90
|
-
|
|
91
|
-
def render_timeseries(
|
|
92
|
-
self,
|
|
93
|
-
data: list[int],
|
|
94
|
-
timestamps: list[int],
|
|
95
|
-
title: str,
|
|
96
|
-
color: str,
|
|
97
|
-
) -> None:
|
|
98
|
-
"""Render a timeseries ASCII graph.
|
|
99
|
-
|
|
100
|
-
Args:
|
|
101
|
-
data: List of values to plot
|
|
102
|
-
timestamps: Corresponding timestamps for x-axis labels
|
|
103
|
-
title: Graph title
|
|
104
|
-
color: ANSI color code for the graph
|
|
105
|
-
"""
|
|
106
|
-
n = len(data)
|
|
107
|
-
if n == 0:
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
stats = calculate_stats(data)
|
|
111
|
-
min_val = stats.min_val
|
|
112
|
-
max_val = stats.max_val
|
|
113
|
-
|
|
114
|
-
# Avoid division by zero
|
|
115
|
-
if min_val == max_val:
|
|
116
|
-
max_val = min_val + 1
|
|
117
|
-
value_range = max_val - min_val
|
|
118
|
-
|
|
119
|
-
width = self.dimensions.graph_width
|
|
120
|
-
height = self.dimensions.graph_height
|
|
121
|
-
|
|
122
|
-
# Print title and stats
|
|
123
|
-
self._emit()
|
|
124
|
-
self._emit(f"{self.colors.bold}{title}{self.colors.reset}")
|
|
125
|
-
self._emit(
|
|
126
|
-
f"{self.colors.dim}Max: {format_tokens(max_val, self.token_detail)} "
|
|
127
|
-
f"Min: {format_tokens(min_val, self.token_detail)} "
|
|
128
|
-
f"Points: {n}{self.colors.reset}"
|
|
129
|
-
)
|
|
130
|
-
self._emit()
|
|
131
|
-
|
|
132
|
-
# Build the graph grid
|
|
133
|
-
grid = self._build_grid(data, min_val, max_val, value_range, width, height)
|
|
134
|
-
|
|
135
|
-
# Print grid with Y-axis labels
|
|
136
|
-
for r in range(height):
|
|
137
|
-
val = max_val - r * value_range // (height - 1)
|
|
138
|
-
|
|
139
|
-
# Show labels at top, middle, and bottom
|
|
140
|
-
if r == 0 or r == height // 2 or r == height - 1:
|
|
141
|
-
label = format_tokens(val, self.token_detail)
|
|
142
|
-
else:
|
|
143
|
-
label = ""
|
|
144
|
-
|
|
145
|
-
row_data = grid[r] if r < len(grid) else " " * width
|
|
146
|
-
self._emit(
|
|
147
|
-
f"{label:>10} {self.colors.dim}│{self.colors.reset}"
|
|
148
|
-
f"{color}{row_data}{self.colors.reset}"
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
# X-axis
|
|
152
|
-
self._emit(f"{'':>10} {self.colors.dim}└{'─' * width}{self.colors.reset}")
|
|
153
|
-
|
|
154
|
-
# Time labels
|
|
155
|
-
if timestamps:
|
|
156
|
-
first_time = format_timestamp(timestamps[0])
|
|
157
|
-
last_time = format_timestamp(timestamps[-1])
|
|
158
|
-
mid_idx = (n - 1) // 2
|
|
159
|
-
mid_time = format_timestamp(timestamps[mid_idx]) if n > 2 else ""
|
|
160
|
-
|
|
161
|
-
spacing = width // 3
|
|
162
|
-
self._emit(
|
|
163
|
-
f"{' ':>11}{self.colors.dim}"
|
|
164
|
-
f"{first_time:<{spacing}}{mid_time}{last_time:>{spacing}}"
|
|
165
|
-
f"{self.colors.reset}"
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
def _build_grid(
|
|
169
|
-
self,
|
|
170
|
-
data: list[int],
|
|
171
|
-
min_val: int,
|
|
172
|
-
max_val: int,
|
|
173
|
-
value_range: int,
|
|
174
|
-
width: int,
|
|
175
|
-
height: int,
|
|
176
|
-
) -> list[str]:
|
|
177
|
-
"""Build the ASCII grid for the graph.
|
|
178
|
-
|
|
179
|
-
Args:
|
|
180
|
-
data: List of values to plot
|
|
181
|
-
min_val: Minimum value in data
|
|
182
|
-
max_val: Maximum value in data
|
|
183
|
-
value_range: max_val - min_val
|
|
184
|
-
width: Graph width in characters
|
|
185
|
-
height: Graph height in rows
|
|
186
|
-
|
|
187
|
-
Returns:
|
|
188
|
-
List of strings, one per row
|
|
189
|
-
"""
|
|
190
|
-
n = len(data)
|
|
191
|
-
if n == 0:
|
|
192
|
-
return [" " * width for _ in range(height)]
|
|
193
|
-
|
|
194
|
-
# Initialize grid with empty spaces
|
|
195
|
-
grid = [[" " for _ in range(width)] for _ in range(height)]
|
|
196
|
-
|
|
197
|
-
# Calculate y positions for each data point
|
|
198
|
-
data_x = []
|
|
199
|
-
data_y = []
|
|
200
|
-
for i, val in enumerate(data):
|
|
201
|
-
# Map index to x coordinate
|
|
202
|
-
if n == 1:
|
|
203
|
-
x = width // 2
|
|
204
|
-
else:
|
|
205
|
-
x = int((i) * (width - 1) / (n - 1))
|
|
206
|
-
x = max(0, min(width - 1, x))
|
|
207
|
-
|
|
208
|
-
# Map value to y coordinate (inverted: 0=top)
|
|
209
|
-
if value_range == 0:
|
|
210
|
-
y = height // 2
|
|
211
|
-
else:
|
|
212
|
-
y = int((max_val - val) * (height - 1) / value_range)
|
|
213
|
-
y = max(0, min(height - 1, y))
|
|
214
|
-
|
|
215
|
-
data_x.append(x)
|
|
216
|
-
data_y.append(y)
|
|
217
|
-
|
|
218
|
-
# Interpolate between points to fill every x position
|
|
219
|
-
line_y = [-1.0] * width
|
|
220
|
-
for i in range(len(data) - 1):
|
|
221
|
-
x1, y1 = data_x[i], data_y[i]
|
|
222
|
-
x2, y2 = data_x[i + 1], data_y[i + 1]
|
|
223
|
-
|
|
224
|
-
# Ensure we don't go out of bounds
|
|
225
|
-
for x in range(x1, min(x2 + 1, width)):
|
|
226
|
-
if x2 == x1:
|
|
227
|
-
y_interp = float(y1)
|
|
228
|
-
else:
|
|
229
|
-
# Linear interpolation
|
|
230
|
-
t = (x - x1) / (x2 - x1)
|
|
231
|
-
y_interp = y1 + t * (y2 - y1)
|
|
232
|
-
line_y[x] = y_interp
|
|
233
|
-
|
|
234
|
-
# Draw filled area and line
|
|
235
|
-
for c in range(width):
|
|
236
|
-
if line_y[c] >= 0:
|
|
237
|
-
line_row = int(line_y[c] + 0.5) # Round to nearest integer
|
|
238
|
-
line_row = max(0, min(height - 1, line_row))
|
|
239
|
-
|
|
240
|
-
# Fill area below the line with gradient
|
|
241
|
-
for r in range(line_row, height):
|
|
242
|
-
if r == line_row:
|
|
243
|
-
grid[r][c] = self.DOT
|
|
244
|
-
elif r < line_row + 2:
|
|
245
|
-
grid[r][c] = self.FILL_LIGHT
|
|
246
|
-
else:
|
|
247
|
-
grid[r][c] = self.FILL_DARK
|
|
248
|
-
|
|
249
|
-
# Mark actual data points
|
|
250
|
-
for i in range(len(data)):
|
|
251
|
-
x = data_x[i]
|
|
252
|
-
y = max(0, min(height - 1, int(data_y[i] + 0.5)))
|
|
253
|
-
grid[y][x] = self.DOT
|
|
254
|
-
|
|
255
|
-
# Convert grid to strings
|
|
256
|
-
return ["".join(row) for row in grid]
|
|
257
|
-
|
|
258
|
-
def render_summary(
|
|
259
|
-
self,
|
|
260
|
-
entries: list, # list[StateEntry]
|
|
261
|
-
deltas: list[int],
|
|
262
|
-
) -> None:
|
|
263
|
-
"""Render summary statistics.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
entries: List of StateEntry objects
|
|
267
|
-
deltas: List of token deltas
|
|
268
|
-
"""
|
|
269
|
-
if not entries:
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
first = entries[0]
|
|
273
|
-
last = entries[-1]
|
|
274
|
-
duration = last.timestamp - first.timestamp
|
|
275
|
-
|
|
276
|
-
# Context window info - use current_used_tokens which represents actual context usage
|
|
277
|
-
remaining_context = 0
|
|
278
|
-
remaining_percentage = 0
|
|
279
|
-
usage_percentage = 0
|
|
280
|
-
if last.context_window_size > 0:
|
|
281
|
-
# current_used_tokens = current_input_tokens + cache_creation + cache_read
|
|
282
|
-
current_used = last.current_used_tokens
|
|
283
|
-
remaining_context = max(0, last.context_window_size - current_used)
|
|
284
|
-
remaining_percentage = remaining_context * 100 // last.context_window_size
|
|
285
|
-
usage_percentage = 100 - remaining_percentage
|
|
286
|
-
|
|
287
|
-
# Determine status based on context usage
|
|
288
|
-
if usage_percentage < 40:
|
|
289
|
-
status_color = self.colors.green
|
|
290
|
-
status_text = "Smart Zone"
|
|
291
|
-
status_hint = "You are in the smart zone"
|
|
292
|
-
elif usage_percentage < 80:
|
|
293
|
-
status_color = self.colors.yellow
|
|
294
|
-
status_text = "Dumb Zone"
|
|
295
|
-
status_hint = "You are in the dumb zone - Dex Horthy says so"
|
|
296
|
-
else:
|
|
297
|
-
status_color = self.colors.red
|
|
298
|
-
status_text = "Wrap Up Zone"
|
|
299
|
-
status_hint = "Better to wrap up and start a new session"
|
|
300
|
-
|
|
301
|
-
self._emit()
|
|
302
|
-
self._emit(f"{self.colors.bold}Session Summary{self.colors.reset}")
|
|
303
|
-
line_width = self.dimensions.graph_width + 11
|
|
304
|
-
self._emit(f"{self.colors.dim}{'-' * line_width}{self.colors.reset}")
|
|
305
|
-
|
|
306
|
-
# Context remaining (before status)
|
|
307
|
-
if last.context_window_size > 0:
|
|
308
|
-
self._emit(
|
|
309
|
-
f" {status_color}{'Context Remaining:':<20}{self.colors.reset} "
|
|
310
|
-
f"{format_tokens(remaining_context, self.token_detail)}/{format_tokens(last.context_window_size, self.token_detail)} ({remaining_percentage}%)"
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
# Status indicator - highlighted
|
|
314
|
-
if last.context_window_size > 0:
|
|
315
|
-
self._emit(
|
|
316
|
-
f" {status_color}{self.colors.bold}>>> {status_text.upper()} <<<{self.colors.reset} "
|
|
317
|
-
f"{self.colors.dim}({status_hint}){self.colors.reset}"
|
|
318
|
-
)
|
|
319
|
-
self._emit()
|
|
320
|
-
|
|
321
|
-
# Session details (ordered: Last Growth, I/O, Lines, Cost, Model, Duration)
|
|
322
|
-
if deltas:
|
|
323
|
-
current_growth = deltas[-1]
|
|
324
|
-
self._emit(
|
|
325
|
-
f" {self.colors.cyan}{'Last Growth:':<20}{self.colors.reset} "
|
|
326
|
-
f"+{format_tokens(current_growth, self.token_detail)}"
|
|
327
|
-
)
|
|
328
|
-
self._emit(
|
|
329
|
-
f" {self.colors.blue}{'Input Tokens:':<20}{self.colors.reset} "
|
|
330
|
-
f"{format_tokens(last.current_input_tokens, self.token_detail)}"
|
|
331
|
-
)
|
|
332
|
-
self._emit(
|
|
333
|
-
f" {self.colors.magenta}{'Output Tokens:':<20}{self.colors.reset} "
|
|
334
|
-
f"{format_tokens(last.current_output_tokens, self.token_detail)}"
|
|
335
|
-
)
|
|
336
|
-
if last.lines_added > 0 or last.lines_removed > 0:
|
|
337
|
-
self._emit(
|
|
338
|
-
f" {self.colors.dim}{'Lines Changed:':<20}{self.colors.reset} "
|
|
339
|
-
f"{self.colors.green}+{last.lines_added:,}{self.colors.reset} / "
|
|
340
|
-
f"{self.colors.red}-{last.lines_removed:,}{self.colors.reset}"
|
|
341
|
-
)
|
|
342
|
-
if last.cost_usd > 0:
|
|
343
|
-
self._emit(
|
|
344
|
-
f" {self.colors.yellow}{'Total Cost:':<20}{self.colors.reset} ${last.cost_usd:.4f}"
|
|
345
|
-
)
|
|
346
|
-
if last.model_id:
|
|
347
|
-
self._emit(f" {self.colors.dim}{'Model:':<20}{self.colors.reset} {last.model_id}")
|
|
348
|
-
self._emit(
|
|
349
|
-
f" {self.colors.cyan}{'Session Duration:':<20}{self.colors.reset} "
|
|
350
|
-
f"{format_duration(duration)}"
|
|
351
|
-
)
|
|
352
|
-
self._emit()
|
|
353
|
-
|
|
354
|
-
def render_footer(self, version: str = "1.6.1", commit_hash: str = "dev") -> None:
|
|
355
|
-
"""Render the footer with version info.
|
|
356
|
-
|
|
357
|
-
Args:
|
|
358
|
-
version: Package version
|
|
359
|
-
commit_hash: Git commit hash
|
|
360
|
-
"""
|
|
361
|
-
self._emit(
|
|
362
|
-
f"{self.colors.dim}Powered by {self.colors.cyan}cc-context-stats"
|
|
363
|
-
f"{self.colors.dim} v{version}-{commit_hash} - "
|
|
364
|
-
f"https://github.com/luongnv89/cc-context-stats{self.colors.reset}"
|
|
365
|
-
)
|
|
366
|
-
self._emit()
|
|
@@ -1,92 +0,0 @@
|
|
|
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 detect_spike(deltas: list[int], context_window_size: int, window: int = 5) -> bool:
|
|
41
|
-
"""Check if the latest delta is a spike.
|
|
42
|
-
|
|
43
|
-
A spike is defined as:
|
|
44
|
-
- Latest delta > 15% of context window size, OR
|
|
45
|
-
- Latest delta > 3x the rolling average of the last `window` deltas
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
deltas: List of token deltas
|
|
49
|
-
context_window_size: Total context window size in tokens
|
|
50
|
-
window: Number of recent deltas for rolling average (default: 5)
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
True if the latest delta qualifies as a spike
|
|
54
|
-
"""
|
|
55
|
-
if not deltas:
|
|
56
|
-
return False
|
|
57
|
-
|
|
58
|
-
latest = deltas[-1]
|
|
59
|
-
|
|
60
|
-
# Check absolute threshold: > 15% of context window
|
|
61
|
-
if context_window_size > 0 and latest > context_window_size * 0.15:
|
|
62
|
-
return True
|
|
63
|
-
|
|
64
|
-
# Check relative threshold: > 3x rolling average of previous deltas
|
|
65
|
-
previous = deltas[-(window + 1):-1] if len(deltas) > window else deltas[:-1]
|
|
66
|
-
if previous:
|
|
67
|
-
avg = sum(previous) / len(previous)
|
|
68
|
-
if avg > 0 and latest > avg * 3:
|
|
69
|
-
return True
|
|
70
|
-
|
|
71
|
-
return False
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def calculate_deltas(values: list[int]) -> list[int]:
|
|
75
|
-
"""Calculate deltas between consecutive values.
|
|
76
|
-
|
|
77
|
-
Args:
|
|
78
|
-
values: List of values (e.g., cumulative token counts)
|
|
79
|
-
|
|
80
|
-
Returns:
|
|
81
|
-
List of deltas (length = len(values) - 1)
|
|
82
|
-
"""
|
|
83
|
-
if len(values) < 2:
|
|
84
|
-
return []
|
|
85
|
-
|
|
86
|
-
deltas = []
|
|
87
|
-
for i in range(1, len(values)):
|
|
88
|
-
delta = values[i] - values[i - 1]
|
|
89
|
-
# Handle negative deltas (session reset) by showing 0
|
|
90
|
-
deltas.append(max(0, delta))
|
|
91
|
-
|
|
92
|
-
return deltas
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""UI components for context-stats display."""
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"""Activity tier detection for token usage visualization."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from enum import Enum
|
|
6
|
-
|
|
7
|
-
from claude_statusline.core.state import StateEntry
|
|
8
|
-
from claude_statusline.graphs.statistics import calculate_deltas, detect_spike
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ActivityTier(Enum):
|
|
12
|
-
"""Token activity intensity tiers."""
|
|
13
|
-
|
|
14
|
-
IDLE = "idle"
|
|
15
|
-
LOW = "low"
|
|
16
|
-
MEDIUM = "medium"
|
|
17
|
-
HIGH = "high"
|
|
18
|
-
SPIKE = "spike"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# Tier labels for accessibility (understandable without color)
|
|
22
|
-
TIER_LABELS: dict[ActivityTier, str] = {
|
|
23
|
-
ActivityTier.IDLE: "Idle",
|
|
24
|
-
ActivityTier.LOW: "Low activity",
|
|
25
|
-
ActivityTier.MEDIUM: "Active",
|
|
26
|
-
ActivityTier.HIGH: "High activity",
|
|
27
|
-
ActivityTier.SPIKE: "Spike!",
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def get_activity_tier(
|
|
32
|
-
entries: list[StateEntry],
|
|
33
|
-
context_window_size: int,
|
|
34
|
-
) -> ActivityTier:
|
|
35
|
-
"""Determine the current activity tier based on recent token deltas.
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
entries: List of StateEntry objects (chronological order)
|
|
39
|
-
context_window_size: Total context window size in tokens
|
|
40
|
-
|
|
41
|
-
Returns:
|
|
42
|
-
The current ActivityTier
|
|
43
|
-
"""
|
|
44
|
-
if len(entries) < 2:
|
|
45
|
-
return ActivityTier.IDLE
|
|
46
|
-
|
|
47
|
-
# Check if session is idle (>30s since last entry)
|
|
48
|
-
import time
|
|
49
|
-
|
|
50
|
-
now = int(time.time())
|
|
51
|
-
last_timestamp = entries[-1].timestamp
|
|
52
|
-
if now - last_timestamp > 30:
|
|
53
|
-
return ActivityTier.IDLE
|
|
54
|
-
|
|
55
|
-
# Calculate deltas from context usage
|
|
56
|
-
context_used = [e.current_used_tokens for e in entries]
|
|
57
|
-
deltas = calculate_deltas(context_used)
|
|
58
|
-
|
|
59
|
-
if not deltas:
|
|
60
|
-
return ActivityTier.IDLE
|
|
61
|
-
|
|
62
|
-
latest_delta = deltas[-1]
|
|
63
|
-
|
|
64
|
-
if context_window_size <= 0:
|
|
65
|
-
return ActivityTier.LOW if latest_delta > 0 else ActivityTier.IDLE
|
|
66
|
-
|
|
67
|
-
# Check for spike first (highest priority)
|
|
68
|
-
if detect_spike(deltas, context_window_size):
|
|
69
|
-
return ActivityTier.SPIKE
|
|
70
|
-
|
|
71
|
-
# Calculate delta as percentage of context window
|
|
72
|
-
delta_pct = (latest_delta / context_window_size) * 100
|
|
73
|
-
|
|
74
|
-
if delta_pct > 5:
|
|
75
|
-
return ActivityTier.HIGH
|
|
76
|
-
elif delta_pct > 2:
|
|
77
|
-
return ActivityTier.MEDIUM
|
|
78
|
-
elif latest_delta > 0:
|
|
79
|
-
return ActivityTier.LOW
|
|
80
|
-
else:
|
|
81
|
-
return ActivityTier.IDLE
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def get_tier_label(tier: ActivityTier) -> str:
|
|
85
|
-
"""Get an accessible text label for a tier.
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
tier: The activity tier
|
|
89
|
-
|
|
90
|
-
Returns:
|
|
91
|
-
Human-readable label string
|
|
92
|
-
"""
|
|
93
|
-
return TIER_LABELS.get(tier, "")
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|