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.
- package/.claude/settings.local.json +36 -1
- package/.github/workflows/ci.yml +4 -2
- package/.github/workflows/release.yml +3 -1
- package/CHANGELOG.md +17 -0
- package/README.md +33 -11
- package/RELEASE_NOTES.md +10 -0
- package/assets/logo/favicon.svg +19 -0
- package/assets/logo/logo-black.svg +24 -0
- package/assets/logo/logo-full.svg +40 -0
- package/assets/logo/logo-icon.svg +27 -0
- package/assets/logo/logo-mark.svg +28 -0
- package/assets/logo/logo-white.svg +24 -0
- package/assets/logo/logo-wordmark.svg +6 -0
- package/install.sh +21 -2
- package/package.json +7 -3
- package/pyproject.toml +6 -4
- package/scripts/context-stats.sh +194 -26
- package/scripts/statusline-full.sh +65 -2
- package/scripts/statusline-git.sh +57 -1
- package/scripts/statusline-minimal.sh +57 -1
- package/scripts/statusline.js +51 -4
- package/scripts/statusline.py +57 -3
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +106 -34
- package/src/claude_statusline/cli/statusline.py +5 -4
- package/src/claude_statusline/core/config.py +7 -0
- package/src/claude_statusline/formatters/layout.py +67 -0
- package/src/claude_statusline/graphs/renderer.py +44 -24
- package/src/claude_statusline/graphs/statistics.py +34 -0
- package/src/claude_statusline/ui/__init__.py +1 -0
- package/src/claude_statusline/ui/icons.py +93 -0
- package/src/claude_statusline/ui/waiting.py +62 -0
- package/tests/bash/test_statusline_full.bats +30 -0
- package/tests/node/statusline.test.js +44 -3
- package/tests/python/test_icons.py +152 -0
- package/tests/python/test_layout.py +127 -0
- package/tests/python/test_statusline.py +64 -3
- package/tests/python/test_waiting.py +127 -0
- package/PUBLISHING_GUIDE.md +0 -69
- package/npm-publish.sh +0 -33
- package/publish.sh +0 -24
- package/show_raw_claude_code_api.js +0 -11
|
@@ -27,6 +27,8 @@ from claude_statusline.core.config import Config
|
|
|
27
27
|
from claude_statusline.core.state import StateFile
|
|
28
28
|
from claude_statusline.graphs.renderer import GraphDimensions, GraphRenderer
|
|
29
29
|
from claude_statusline.graphs.statistics import calculate_deltas
|
|
30
|
+
from claude_statusline.ui.icons import get_activity_tier, get_tier_label
|
|
31
|
+
from claude_statusline.ui.waiting import get_waiting_text, is_active
|
|
30
32
|
|
|
31
33
|
# Cursor control sequences
|
|
32
34
|
CURSOR_HOME = "\033[H"
|
|
@@ -145,7 +147,9 @@ def render_once(
|
|
|
145
147
|
renderer: GraphRenderer,
|
|
146
148
|
colors: ColorManager,
|
|
147
149
|
watch_mode: bool = False,
|
|
148
|
-
|
|
150
|
+
config: Config | None = None,
|
|
151
|
+
cycle_index: int = 0,
|
|
152
|
+
) -> bool | str:
|
|
149
153
|
"""Render graphs once.
|
|
150
154
|
|
|
151
155
|
Args:
|
|
@@ -154,19 +158,35 @@ def render_once(
|
|
|
154
158
|
renderer: GraphRenderer instance
|
|
155
159
|
colors: ColorManager instance
|
|
156
160
|
watch_mode: Whether running in watch mode
|
|
161
|
+
config: Config instance for motion settings
|
|
162
|
+
cycle_index: Watch mode refresh counter for rotating text
|
|
157
163
|
|
|
158
164
|
Returns:
|
|
159
|
-
True if rendering was successful
|
|
165
|
+
True if rendering was successful (non-watch mode),
|
|
166
|
+
buffered string if watch_mode is True,
|
|
167
|
+
False if not enough data
|
|
160
168
|
"""
|
|
161
169
|
entries = state_file.read_history()
|
|
162
170
|
|
|
163
171
|
if len(entries) < 2:
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
msg = (
|
|
173
|
+
f"\n{colors.yellow}Need at least 2 data points to generate graphs.{colors.reset}\n"
|
|
166
174
|
f"{colors.dim}Found: {len(entries)} entry. Use Claude Code to accumulate more data.{colors.reset}"
|
|
167
175
|
)
|
|
176
|
+
if watch_mode:
|
|
177
|
+
return msg
|
|
178
|
+
print(msg)
|
|
168
179
|
return False
|
|
169
180
|
|
|
181
|
+
# In watch mode, buffer all output
|
|
182
|
+
lines: list[str] = []
|
|
183
|
+
|
|
184
|
+
def emit(line: str = "") -> None:
|
|
185
|
+
if watch_mode:
|
|
186
|
+
lines.append(line)
|
|
187
|
+
else:
|
|
188
|
+
print(line)
|
|
189
|
+
|
|
170
190
|
# Extract data for graphs
|
|
171
191
|
timestamps = [e.timestamp for e in entries]
|
|
172
192
|
# Current context window usage (what's actually in the context)
|
|
@@ -191,18 +211,34 @@ def render_once(
|
|
|
191
211
|
|
|
192
212
|
# Header
|
|
193
213
|
if not watch_mode:
|
|
194
|
-
|
|
214
|
+
emit()
|
|
195
215
|
if project_name:
|
|
196
|
-
|
|
216
|
+
emit(
|
|
197
217
|
f"{colors.bold}{colors.magenta}Context Stats{colors.reset} "
|
|
198
218
|
f"{colors.dim}({colors.cyan}{project_name}{colors.dim} • {session_name}){colors.reset}"
|
|
199
219
|
)
|
|
200
220
|
else:
|
|
201
|
-
|
|
221
|
+
emit(
|
|
202
222
|
f"{colors.bold}{colors.magenta}Context Stats{colors.reset} "
|
|
203
223
|
f"{colors.dim}(Session: {session_name}){colors.reset}"
|
|
204
224
|
)
|
|
205
225
|
|
|
226
|
+
# Activity indicator (waiting text + label)
|
|
227
|
+
reduced_motion = config.reduced_motion if config else False
|
|
228
|
+
tier = get_activity_tier(entries, last_entry.context_window_size)
|
|
229
|
+
label = get_tier_label(tier)
|
|
230
|
+
active = is_active(entries)
|
|
231
|
+
|
|
232
|
+
if active:
|
|
233
|
+
text = get_waiting_text(cycle_index, reduced_motion)
|
|
234
|
+
emit(f" {colors.dim}{text} [{label}]{colors.reset}")
|
|
235
|
+
else:
|
|
236
|
+
emit(f" {colors.dim}{label}{colors.reset}")
|
|
237
|
+
|
|
238
|
+
# In watch mode, enable renderer buffering
|
|
239
|
+
if watch_mode:
|
|
240
|
+
renderer.begin_buffering()
|
|
241
|
+
|
|
206
242
|
# Render requested graphs
|
|
207
243
|
if graph_type in ("cumulative", "both", "all"):
|
|
208
244
|
renderer.render_timeseries(
|
|
@@ -226,6 +262,12 @@ def render_once(
|
|
|
226
262
|
renderer.render_summary(entries, deltas)
|
|
227
263
|
renderer.render_footer(__version__)
|
|
228
264
|
|
|
265
|
+
if watch_mode:
|
|
266
|
+
# Collect renderer buffer and combine with header lines
|
|
267
|
+
renderer_output = renderer.get_buffer()
|
|
268
|
+
lines.append(renderer_output)
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
229
271
|
return True
|
|
230
272
|
|
|
231
273
|
|
|
@@ -235,6 +277,7 @@ def run_watch_mode(
|
|
|
235
277
|
interval: int,
|
|
236
278
|
renderer: GraphRenderer,
|
|
237
279
|
colors: ColorManager,
|
|
280
|
+
config: Config | None = None,
|
|
238
281
|
) -> None:
|
|
239
282
|
"""Run in watch mode with continuous refresh.
|
|
240
283
|
|
|
@@ -244,6 +287,7 @@ def run_watch_mode(
|
|
|
244
287
|
interval: Refresh interval in seconds
|
|
245
288
|
renderer: GraphRenderer instance
|
|
246
289
|
colors: ColorManager instance
|
|
290
|
+
config: Config instance for motion settings
|
|
247
291
|
"""
|
|
248
292
|
|
|
249
293
|
# Signal handler for clean exit
|
|
@@ -260,18 +304,19 @@ def run_watch_mode(
|
|
|
260
304
|
sys.stdout.write(f"{HIDE_CURSOR}{CLEAR_SCREEN}{CURSOR_HOME}")
|
|
261
305
|
sys.stdout.flush()
|
|
262
306
|
|
|
307
|
+
cycle_counter = 0
|
|
308
|
+
|
|
263
309
|
try:
|
|
264
310
|
while True:
|
|
265
|
-
# Move cursor to home
|
|
266
|
-
sys.stdout.write(CURSOR_HOME)
|
|
267
|
-
sys.stdout.flush()
|
|
268
|
-
|
|
269
311
|
# Update dimensions in case of terminal resize
|
|
270
312
|
renderer.dimensions = GraphDimensions.detect()
|
|
271
313
|
|
|
314
|
+
# Build all output into a buffer
|
|
315
|
+
buf_lines: list[str] = []
|
|
316
|
+
|
|
272
317
|
# Watch mode indicator
|
|
273
318
|
current_time = time.strftime("%H:%M:%S")
|
|
274
|
-
|
|
319
|
+
buf_lines.append(
|
|
275
320
|
f"{colors.dim}[LIVE {current_time}] Refresh: {interval}s | Ctrl+C to exit{colors.reset}"
|
|
276
321
|
)
|
|
277
322
|
|
|
@@ -279,54 +324,81 @@ def run_watch_mode(
|
|
|
279
324
|
file_path = state_file.find_latest_state_file()
|
|
280
325
|
if not file_path or not file_path.exists():
|
|
281
326
|
# Show waiting message for new session
|
|
282
|
-
|
|
327
|
+
reduced_motion = config.reduced_motion if config else False
|
|
328
|
+
text = get_waiting_text(cycle_counter, reduced_motion)
|
|
329
|
+
buf_lines.append(_format_waiting_message(
|
|
283
330
|
colors,
|
|
284
331
|
state_file.session_id,
|
|
285
|
-
|
|
286
|
-
)
|
|
332
|
+
text,
|
|
333
|
+
))
|
|
287
334
|
else:
|
|
288
|
-
# Render graphs
|
|
289
|
-
render_once(
|
|
335
|
+
# Render graphs (returns buffered string in watch mode)
|
|
336
|
+
result = render_once(
|
|
337
|
+
state_file, graph_type, renderer, colors,
|
|
338
|
+
watch_mode=True, config=config, cycle_index=cycle_counter,
|
|
339
|
+
)
|
|
340
|
+
if isinstance(result, str):
|
|
341
|
+
buf_lines.append(result)
|
|
290
342
|
|
|
291
|
-
#
|
|
292
|
-
|
|
343
|
+
# Atomic write: CURSOR_HOME + content + CLEAR_TO_END (clean up stale trailing lines)
|
|
344
|
+
buffered_content = "\n".join(buf_lines)
|
|
345
|
+
sys.stdout.write(f"{CURSOR_HOME}{buffered_content}\n{CLEAR_TO_END}")
|
|
293
346
|
sys.stdout.flush()
|
|
294
347
|
|
|
348
|
+
cycle_counter += 1
|
|
295
349
|
time.sleep(interval)
|
|
296
350
|
finally:
|
|
297
351
|
sys.stdout.write(SHOW_CURSOR)
|
|
298
352
|
sys.stdout.flush()
|
|
299
353
|
|
|
300
354
|
|
|
301
|
-
def
|
|
355
|
+
def _format_waiting_message(
|
|
302
356
|
colors: ColorManager,
|
|
303
357
|
session_id: str | None,
|
|
304
358
|
message: str = "Waiting for session data...",
|
|
305
|
-
) ->
|
|
306
|
-
"""
|
|
359
|
+
) -> str:
|
|
360
|
+
"""Format a waiting message as a string.
|
|
307
361
|
|
|
308
362
|
Args:
|
|
309
363
|
colors: ColorManager instance
|
|
310
364
|
session_id: Session ID if specified
|
|
311
365
|
message: Message to display
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Formatted waiting message string
|
|
312
369
|
"""
|
|
313
|
-
|
|
370
|
+
lines = [""]
|
|
314
371
|
if session_id:
|
|
315
|
-
|
|
372
|
+
lines.append(
|
|
316
373
|
f"{colors.bold}{colors.magenta}Context Stats{colors.reset} "
|
|
317
374
|
f"{colors.dim}(Session: {session_id}){colors.reset}"
|
|
318
375
|
)
|
|
319
376
|
else:
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
print(
|
|
377
|
+
lines.append(f"{colors.bold}{colors.magenta}Context Stats{colors.reset}")
|
|
378
|
+
lines.append("")
|
|
379
|
+
lines.append(f" {colors.cyan}⏳ {message}{colors.reset}")
|
|
380
|
+
lines.append("")
|
|
381
|
+
lines.append(
|
|
326
382
|
f" {colors.dim}The session has just started and no data has been recorded yet.{colors.reset}"
|
|
327
383
|
)
|
|
328
|
-
|
|
329
|
-
|
|
384
|
+
lines.append(f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}")
|
|
385
|
+
lines.append("")
|
|
386
|
+
return "\n".join(lines)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def show_waiting_message(
|
|
390
|
+
colors: ColorManager,
|
|
391
|
+
session_id: str | None,
|
|
392
|
+
message: str = "Waiting for session data...",
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Show a friendly waiting message for new sessions.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
colors: ColorManager instance
|
|
398
|
+
session_id: Session ID if specified
|
|
399
|
+
message: Message to display
|
|
400
|
+
"""
|
|
401
|
+
print(_format_waiting_message(colors, session_id, message))
|
|
330
402
|
|
|
331
403
|
|
|
332
404
|
def main() -> None:
|
|
@@ -368,11 +440,11 @@ def main() -> None:
|
|
|
368
440
|
|
|
369
441
|
# Run
|
|
370
442
|
if args.no_watch:
|
|
371
|
-
success = render_once(state_file, args.type, renderer, colors)
|
|
443
|
+
success = render_once(state_file, args.type, renderer, colors, config=config)
|
|
372
444
|
if not success:
|
|
373
445
|
sys.exit(1)
|
|
374
446
|
else:
|
|
375
|
-
run_watch_mode(state_file, args.type, args.watch, renderer, colors)
|
|
447
|
+
run_watch_mode(state_file, args.type, args.watch, renderer, colors, config=config)
|
|
376
448
|
|
|
377
449
|
|
|
378
450
|
if __name__ == "__main__":
|
|
@@ -37,6 +37,7 @@ from claude_statusline.core.colors import (
|
|
|
37
37
|
from claude_statusline.core.config import Config
|
|
38
38
|
from claude_statusline.core.git import get_git_info
|
|
39
39
|
from claude_statusline.core.state import StateEntry, StateFile
|
|
40
|
+
from claude_statusline.formatters.layout import fit_to_width, get_terminal_width
|
|
40
41
|
from claude_statusline.formatters.time import get_current_timestamp
|
|
41
42
|
from claude_statusline.formatters.tokens import calculate_context_usage, format_tokens
|
|
42
43
|
|
|
@@ -162,10 +163,10 @@ def main() -> None:
|
|
|
162
163
|
session_info = f" {DIM}{session_id}{RESET}"
|
|
163
164
|
|
|
164
165
|
# Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [session_id]
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
)
|
|
166
|
+
base = f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
|
|
167
|
+
max_width = get_terminal_width()
|
|
168
|
+
parts = [base, git_info, context_info, delta_info, ac_info, session_info]
|
|
169
|
+
print(fit_to_width(parts, max_width))
|
|
169
170
|
|
|
170
171
|
|
|
171
172
|
if __name__ == "__main__":
|
|
@@ -16,6 +16,7 @@ class Config:
|
|
|
16
16
|
show_delta: bool = True
|
|
17
17
|
show_session: bool = True
|
|
18
18
|
show_io_tokens: bool = True
|
|
19
|
+
reduced_motion: bool = False
|
|
19
20
|
|
|
20
21
|
_config_path: Path = field(default_factory=lambda: Path.home() / ".claude" / "statusline.conf")
|
|
21
22
|
|
|
@@ -57,6 +58,9 @@ show_delta=true
|
|
|
57
58
|
|
|
58
59
|
# Show session_id in status line
|
|
59
60
|
show_session=true
|
|
61
|
+
|
|
62
|
+
# Disable rotating text animations
|
|
63
|
+
reduced_motion=false
|
|
60
64
|
"""
|
|
61
65
|
)
|
|
62
66
|
except OSError:
|
|
@@ -84,6 +88,8 @@ show_session=true
|
|
|
84
88
|
self.show_session = value != "false"
|
|
85
89
|
elif key == "show_io_tokens":
|
|
86
90
|
self.show_io_tokens = value != "false"
|
|
91
|
+
elif key == "reduced_motion":
|
|
92
|
+
self.reduced_motion = value != "false"
|
|
87
93
|
except OSError:
|
|
88
94
|
pass # Use defaults on read error
|
|
89
95
|
|
|
@@ -95,4 +101,5 @@ show_session=true
|
|
|
95
101
|
"show_delta": self.show_delta,
|
|
96
102
|
"show_session": self.show_session,
|
|
97
103
|
"show_io_tokens": self.show_io_tokens,
|
|
104
|
+
"reduced_motion": self.reduced_motion,
|
|
98
105
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Layout utilities for fitting statusline output to terminal width."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
# Pattern to strip ANSI escape sequences
|
|
9
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def visible_width(s: str) -> int:
|
|
13
|
+
"""Return the visible width of a string after stripping ANSI escape sequences."""
|
|
14
|
+
return len(_ANSI_RE.sub("", s))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_terminal_width() -> int:
|
|
18
|
+
"""Return the terminal width in columns.
|
|
19
|
+
|
|
20
|
+
When running inside Claude Code's statusline subprocess, neither $COLUMNS
|
|
21
|
+
nor tput/shutil can detect the real terminal width (they always return 80).
|
|
22
|
+
If COLUMNS is not explicitly set and shutil falls back to 80, we use a
|
|
23
|
+
generous default of 200 so that no parts are unnecessarily dropped;
|
|
24
|
+
Claude Code's own UI handles any overflow/truncation.
|
|
25
|
+
"""
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
# If COLUMNS is explicitly set, trust it (real terminal or test override)
|
|
29
|
+
if os.environ.get("COLUMNS"):
|
|
30
|
+
return shutil.get_terminal_size().columns
|
|
31
|
+
# No COLUMNS env var — likely a Claude Code subprocess with no real TTY.
|
|
32
|
+
# shutil will fall back to 80, which is too narrow. Use 200 instead.
|
|
33
|
+
cols = shutil.get_terminal_size(fallback=(200, 24)).columns
|
|
34
|
+
return 200 if cols == 80 else cols
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fit_to_width(parts: list[str], max_width: int) -> str:
|
|
38
|
+
"""Assemble parts into a single line that fits within max_width.
|
|
39
|
+
|
|
40
|
+
Parts are added in priority order (first = highest priority).
|
|
41
|
+
The first part (base) is always included. Subsequent parts are
|
|
42
|
+
included only if adding them does not exceed max_width.
|
|
43
|
+
Empty parts are skipped.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
parts: List of strings in priority order (highest first).
|
|
47
|
+
max_width: Maximum visible width allowed.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Assembled string that fits within max_width.
|
|
51
|
+
"""
|
|
52
|
+
if not parts:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
# Base part is always included
|
|
56
|
+
result = parts[0]
|
|
57
|
+
current_width = visible_width(result)
|
|
58
|
+
|
|
59
|
+
for part in parts[1:]:
|
|
60
|
+
if not part:
|
|
61
|
+
continue
|
|
62
|
+
part_width = visible_width(part)
|
|
63
|
+
if current_width + part_width <= max_width:
|
|
64
|
+
result += part
|
|
65
|
+
current_width += part_width
|
|
66
|
+
|
|
67
|
+
return result
|
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from claude_statusline.core.colors import ColorManager
|
|
9
9
|
from claude_statusline.formatters.time import format_duration, format_timestamp
|
|
10
10
|
from claude_statusline.formatters.tokens import format_tokens
|
|
11
|
-
from claude_statusline.graphs.statistics import calculate_stats
|
|
11
|
+
from claude_statusline.graphs.statistics import calculate_deltas, calculate_stats
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@dataclass
|
|
@@ -67,6 +67,26 @@ class GraphRenderer:
|
|
|
67
67
|
self.colors = colors or ColorManager(enabled=True)
|
|
68
68
|
self.dimensions = dimensions or GraphDimensions.detect()
|
|
69
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)
|
|
70
90
|
|
|
71
91
|
def render_timeseries(
|
|
72
92
|
self,
|
|
@@ -100,14 +120,14 @@ class GraphRenderer:
|
|
|
100
120
|
height = self.dimensions.graph_height
|
|
101
121
|
|
|
102
122
|
# Print title and stats
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
self._emit()
|
|
124
|
+
self._emit(f"{self.colors.bold}{title}{self.colors.reset}")
|
|
125
|
+
self._emit(
|
|
106
126
|
f"{self.colors.dim}Max: {format_tokens(max_val, self.token_detail)} "
|
|
107
127
|
f"Min: {format_tokens(min_val, self.token_detail)} "
|
|
108
128
|
f"Points: {n}{self.colors.reset}"
|
|
109
129
|
)
|
|
110
|
-
|
|
130
|
+
self._emit()
|
|
111
131
|
|
|
112
132
|
# Build the graph grid
|
|
113
133
|
grid = self._build_grid(data, min_val, max_val, value_range, width, height)
|
|
@@ -123,13 +143,13 @@ class GraphRenderer:
|
|
|
123
143
|
label = ""
|
|
124
144
|
|
|
125
145
|
row_data = grid[r] if r < len(grid) else " " * width
|
|
126
|
-
|
|
146
|
+
self._emit(
|
|
127
147
|
f"{label:>10} {self.colors.dim}│{self.colors.reset}"
|
|
128
148
|
f"{color}{row_data}{self.colors.reset}"
|
|
129
149
|
)
|
|
130
150
|
|
|
131
151
|
# X-axis
|
|
132
|
-
|
|
152
|
+
self._emit(f"{'':>10} {self.colors.dim}└{'─' * width}{self.colors.reset}")
|
|
133
153
|
|
|
134
154
|
# Time labels
|
|
135
155
|
if timestamps:
|
|
@@ -139,7 +159,7 @@ class GraphRenderer:
|
|
|
139
159
|
mid_time = format_timestamp(timestamps[mid_idx]) if n > 2 else ""
|
|
140
160
|
|
|
141
161
|
spacing = width // 3
|
|
142
|
-
|
|
162
|
+
self._emit(
|
|
143
163
|
f"{' ':>11}{self.colors.dim}"
|
|
144
164
|
f"{first_time:<{spacing}}{mid_time}{last_time:>{spacing}}"
|
|
145
165
|
f"{self.colors.reset}"
|
|
@@ -278,58 +298,58 @@ class GraphRenderer:
|
|
|
278
298
|
status_text = "Wrap Up Zone"
|
|
279
299
|
status_hint = "Better to wrap up and start a new session"
|
|
280
300
|
|
|
281
|
-
|
|
282
|
-
|
|
301
|
+
self._emit()
|
|
302
|
+
self._emit(f"{self.colors.bold}Session Summary{self.colors.reset}")
|
|
283
303
|
line_width = self.dimensions.graph_width + 11
|
|
284
|
-
|
|
304
|
+
self._emit(f"{self.colors.dim}{'-' * line_width}{self.colors.reset}")
|
|
285
305
|
|
|
286
306
|
# Context remaining (before status)
|
|
287
307
|
if last.context_window_size > 0:
|
|
288
|
-
|
|
308
|
+
self._emit(
|
|
289
309
|
f" {status_color}{'Context Remaining:':<20}{self.colors.reset} "
|
|
290
310
|
f"{format_tokens(remaining_context, self.token_detail)}/{format_tokens(last.context_window_size, self.token_detail)} ({remaining_percentage}%)"
|
|
291
311
|
)
|
|
292
312
|
|
|
293
313
|
# Status indicator - highlighted
|
|
294
314
|
if last.context_window_size > 0:
|
|
295
|
-
|
|
315
|
+
self._emit(
|
|
296
316
|
f" {status_color}{self.colors.bold}>>> {status_text.upper()} <<<{self.colors.reset} "
|
|
297
317
|
f"{self.colors.dim}({status_hint}){self.colors.reset}"
|
|
298
318
|
)
|
|
299
|
-
|
|
319
|
+
self._emit()
|
|
300
320
|
|
|
301
321
|
# Session details (ordered: Last Growth, I/O, Lines, Cost, Model, Duration)
|
|
302
322
|
if deltas:
|
|
303
323
|
current_growth = deltas[-1]
|
|
304
|
-
|
|
324
|
+
self._emit(
|
|
305
325
|
f" {self.colors.cyan}{'Last Growth:':<20}{self.colors.reset} "
|
|
306
326
|
f"+{format_tokens(current_growth, self.token_detail)}"
|
|
307
327
|
)
|
|
308
|
-
|
|
328
|
+
self._emit(
|
|
309
329
|
f" {self.colors.blue}{'Input Tokens:':<20}{self.colors.reset} "
|
|
310
330
|
f"{format_tokens(last.current_input_tokens, self.token_detail)}"
|
|
311
331
|
)
|
|
312
|
-
|
|
332
|
+
self._emit(
|
|
313
333
|
f" {self.colors.magenta}{'Output Tokens:':<20}{self.colors.reset} "
|
|
314
334
|
f"{format_tokens(last.current_output_tokens, self.token_detail)}"
|
|
315
335
|
)
|
|
316
336
|
if last.lines_added > 0 or last.lines_removed > 0:
|
|
317
|
-
|
|
337
|
+
self._emit(
|
|
318
338
|
f" {self.colors.dim}{'Lines Changed:':<20}{self.colors.reset} "
|
|
319
339
|
f"{self.colors.green}+{last.lines_added:,}{self.colors.reset} / "
|
|
320
340
|
f"{self.colors.red}-{last.lines_removed:,}{self.colors.reset}"
|
|
321
341
|
)
|
|
322
342
|
if last.cost_usd > 0:
|
|
323
|
-
|
|
343
|
+
self._emit(
|
|
324
344
|
f" {self.colors.yellow}{'Total Cost:':<20}{self.colors.reset} ${last.cost_usd:.4f}"
|
|
325
345
|
)
|
|
326
346
|
if last.model_id:
|
|
327
|
-
|
|
328
|
-
|
|
347
|
+
self._emit(f" {self.colors.dim}{'Model:':<20}{self.colors.reset} {last.model_id}")
|
|
348
|
+
self._emit(
|
|
329
349
|
f" {self.colors.cyan}{'Session Duration:':<20}{self.colors.reset} "
|
|
330
350
|
f"{format_duration(duration)}"
|
|
331
351
|
)
|
|
332
|
-
|
|
352
|
+
self._emit()
|
|
333
353
|
|
|
334
354
|
def render_footer(self, version: str = "1.0.0", commit_hash: str = "dev") -> None:
|
|
335
355
|
"""Render the footer with version info.
|
|
@@ -338,9 +358,9 @@ class GraphRenderer:
|
|
|
338
358
|
version: Package version
|
|
339
359
|
commit_hash: Git commit hash
|
|
340
360
|
"""
|
|
341
|
-
|
|
361
|
+
self._emit(
|
|
342
362
|
f"{self.colors.dim}Powered by {self.colors.cyan}claude-statusline"
|
|
343
363
|
f"{self.colors.dim} v{version}-{commit_hash} - "
|
|
344
364
|
f"https://github.com/luongnv89/cc-context-stats{self.colors.reset}"
|
|
345
365
|
)
|
|
346
|
-
|
|
366
|
+
self._emit()
|
|
@@ -37,6 +37,40 @@ def calculate_stats(data: list[int]) -> Stats:
|
|
|
37
37
|
return Stats(min_val=min_val, max_val=max_val, avg_val=avg_val, total=total, count=count)
|
|
38
38
|
|
|
39
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
|
+
|
|
40
74
|
def calculate_deltas(values: list[int]) -> list[int]:
|
|
41
75
|
"""Calculate deltas between consecutive values.
|
|
42
76
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""UI components for context-stats display."""
|
|
@@ -0,0 +1,93 @@
|
|
|
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, "")
|