cc-context-stats 1.3.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/settings.local.json +36 -1
  2. package/.github/workflows/ci.yml +4 -2
  3. package/.github/workflows/release.yml +3 -1
  4. package/CHANGELOG.md +17 -0
  5. package/README.md +33 -11
  6. package/RELEASE_NOTES.md +10 -0
  7. package/assets/logo/favicon.svg +19 -0
  8. package/assets/logo/logo-black.svg +24 -0
  9. package/assets/logo/logo-full.svg +40 -0
  10. package/assets/logo/logo-icon.svg +27 -0
  11. package/assets/logo/logo-mark.svg +28 -0
  12. package/assets/logo/logo-white.svg +24 -0
  13. package/assets/logo/logo-wordmark.svg +6 -0
  14. package/install.sh +21 -2
  15. package/package.json +7 -3
  16. package/pyproject.toml +6 -4
  17. package/scripts/context-stats.sh +194 -26
  18. package/scripts/statusline-full.sh +65 -2
  19. package/scripts/statusline-git.sh +57 -1
  20. package/scripts/statusline-minimal.sh +57 -1
  21. package/scripts/statusline.js +51 -4
  22. package/scripts/statusline.py +57 -3
  23. package/src/claude_statusline/__init__.py +1 -1
  24. package/src/claude_statusline/cli/context_stats.py +106 -34
  25. package/src/claude_statusline/cli/statusline.py +5 -4
  26. package/src/claude_statusline/core/config.py +7 -0
  27. package/src/claude_statusline/formatters/layout.py +67 -0
  28. package/src/claude_statusline/graphs/renderer.py +44 -24
  29. package/src/claude_statusline/graphs/statistics.py +34 -0
  30. package/src/claude_statusline/ui/__init__.py +1 -0
  31. package/src/claude_statusline/ui/icons.py +93 -0
  32. package/src/claude_statusline/ui/waiting.py +62 -0
  33. package/tests/bash/test_statusline_full.bats +30 -0
  34. package/tests/node/statusline.test.js +44 -3
  35. package/tests/python/test_icons.py +152 -0
  36. package/tests/python/test_layout.py +127 -0
  37. package/tests/python/test_statusline.py +64 -3
  38. package/tests/python/test_waiting.py +127 -0
  39. package/PUBLISHING_GUIDE.md +0 -69
  40. package/npm-publish.sh +0 -33
  41. package/publish.sh +0 -24
  42. package/show_raw_claude_code_api.js +0 -11
@@ -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
- ) -> bool:
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, False if not enough data
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
- print(f"\n{colors.yellow}Need at least 2 data points to generate graphs.{colors.reset}")
165
- print(
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
- print()
214
+ emit()
195
215
  if project_name:
196
- print(
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
- print(
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
- print(
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
- show_waiting_message(
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
- "Waiting for session data...",
286
- )
332
+ text,
333
+ ))
287
334
  else:
288
- # Render graphs
289
- render_once(state_file, graph_type, renderer, colors, watch_mode=True)
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
- # Clear any remaining content
292
- sys.stdout.write(CLEAR_TO_END)
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 show_waiting_message(
355
+ def _format_waiting_message(
302
356
  colors: ColorManager,
303
357
  session_id: str | None,
304
358
  message: str = "Waiting for session data...",
305
- ) -> None:
306
- """Show a friendly waiting message for new sessions.
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
- print()
370
+ lines = [""]
314
371
  if session_id:
315
- print(
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
- print(f"{colors.bold}{colors.magenta}Context Stats{colors.reset}")
321
-
322
- print()
323
- print(f" {colors.cyan}⏳ {message}{colors.reset}")
324
- print()
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
- print(f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}")
329
- print()
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
- print(
166
- f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
167
- f"{git_info}{context_info}{delta_info}{ac_info}{session_info}"
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
- print()
104
- print(f"{self.colors.bold}{title}{self.colors.reset}")
105
- print(
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
- print()
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
- print(
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
- print(f"{'':>10} {self.colors.dim}└{'─' * width}{self.colors.reset}")
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
- print(
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
- print()
282
- print(f"{self.colors.bold}Session Summary{self.colors.reset}")
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
- print(f"{self.colors.dim}{'-' * line_width}{self.colors.reset}")
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
- print(
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
- print(
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
- print()
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
- print(
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
- print(
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
- print(
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
- print(
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
- print(
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
- print(f" {self.colors.dim}{'Model:':<20}{self.colors.reset} {last.model_id}")
328
- print(
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
- print()
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
- print(
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
- print()
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, "")