ccburn 0.2.2__tar.gz → 0.3.0__tar.gz

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 (32) hide show
  1. {ccburn-0.2.2/src/ccburn.egg-info → ccburn-0.3.0}/PKG-INFO +3 -3
  2. {ccburn-0.2.2 → ccburn-0.3.0}/README.md +2 -2
  3. {ccburn-0.2.2 → ccburn-0.3.0}/pyproject.toml +1 -1
  4. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/app.py +65 -15
  5. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/cli.py +5 -6
  6. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/display/chart.py +85 -14
  7. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/display/gauges.py +35 -6
  8. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/display/layout.py +8 -7
  9. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/utils/calculator.py +69 -33
  10. {ccburn-0.2.2 → ccburn-0.3.0/src/ccburn.egg-info}/PKG-INFO +3 -3
  11. {ccburn-0.2.2 → ccburn-0.3.0}/tests/test_calculator.py +14 -4
  12. {ccburn-0.2.2 → ccburn-0.3.0}/tests/test_cli.py +4 -2
  13. {ccburn-0.2.2 → ccburn-0.3.0}/LICENSE +0 -0
  14. {ccburn-0.2.2 → ccburn-0.3.0}/setup.cfg +0 -0
  15. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/__init__.py +0 -0
  16. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/data/__init__.py +0 -0
  17. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/data/credentials.py +0 -0
  18. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/data/history.py +0 -0
  19. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/data/models.py +0 -0
  20. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/data/usage_client.py +0 -0
  21. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/display/__init__.py +0 -0
  22. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/main.py +0 -0
  23. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/utils/__init__.py +0 -0
  24. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn/utils/formatting.py +0 -0
  25. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn.egg-info/SOURCES.txt +0 -0
  26. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn.egg-info/dependency_links.txt +0 -0
  27. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn.egg-info/entry_points.txt +0 -0
  28. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn.egg-info/requires.txt +0 -0
  29. {ccburn-0.2.2 → ccburn-0.3.0}/src/ccburn.egg-info/top_level.txt +0 -0
  30. {ccburn-0.2.2 → ccburn-0.3.0}/tests/test_formatting.py +0 -0
  31. {ccburn-0.2.2 → ccburn-0.3.0}/tests/test_history.py +0 -0
  32. {ccburn-0.2.2 → ccburn-0.3.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccburn
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Terminal-based Claude Code usage limit visualizer with real-time burn-up charts
5
5
  Author: JuanjoFuchs
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
  [![PyPI](https://img.shields.io/pypi/v/ccburn)](https://pypi.org/project/ccburn/)
46
46
  [![Python](https://img.shields.io/pypi/pyversions/ccburn)](https://pypi.org/project/ccburn/)
47
47
  [![GitHub Release](https://img.shields.io/github/v/release/JuanjoFuchs/ccburn)](https://github.com/JuanjoFuchs/ccburn/releases)
48
- [![WinGet](https://img.shields.io/badge/WinGet-pending-yellow)](https://github.com/microsoft/winget-pkgs/pulls?q=is%3Apr+ccburn)
48
+ [![WinGet](https://img.shields.io/winget/v/JuanjoFuchs.ccburn)](https://winstall.app/apps/JuanjoFuchs.ccburn)
49
49
  [![npm downloads](https://img.shields.io/npm/dt/ccburn?label=npm%20downloads)](https://www.npmjs.com/package/ccburn)
50
50
  [![PyPI downloads](https://img.shields.io/pepy/dt/ccburn?label=pypi%20downloads)](https://pepy.tech/project/ccburn)
51
51
  [![GitHub downloads](https://img.shields.io/github/downloads/JuanjoFuchs/ccburn/total?label=github%20downloads)](https://github.com/JuanjoFuchs/ccburn/releases)
@@ -76,7 +76,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
76
76
 
77
77
  Run `claude` and login first to refresh credentials.
78
78
 
79
- ### WinGet (*pending approval*)
79
+ ### WinGet (Windows)
80
80
 
81
81
  ```powershell
82
82
  winget install JuanjoFuchs.ccburn
@@ -6,7 +6,7 @@
6
6
  [![PyPI](https://img.shields.io/pypi/v/ccburn)](https://pypi.org/project/ccburn/)
7
7
  [![Python](https://img.shields.io/pypi/pyversions/ccburn)](https://pypi.org/project/ccburn/)
8
8
  [![GitHub Release](https://img.shields.io/github/v/release/JuanjoFuchs/ccburn)](https://github.com/JuanjoFuchs/ccburn/releases)
9
- [![WinGet](https://img.shields.io/badge/WinGet-pending-yellow)](https://github.com/microsoft/winget-pkgs/pulls?q=is%3Apr+ccburn)
9
+ [![WinGet](https://img.shields.io/winget/v/JuanjoFuchs.ccburn)](https://winstall.app/apps/JuanjoFuchs.ccburn)
10
10
  [![npm downloads](https://img.shields.io/npm/dt/ccburn?label=npm%20downloads)](https://www.npmjs.com/package/ccburn)
11
11
  [![PyPI downloads](https://img.shields.io/pepy/dt/ccburn?label=pypi%20downloads)](https://pepy.tech/project/ccburn)
12
12
  [![GitHub downloads](https://img.shields.io/github/downloads/JuanjoFuchs/ccburn/total?label=github%20downloads)](https://github.com/JuanjoFuchs/ccburn/releases)
@@ -37,7 +37,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
37
37
 
38
38
  Run `claude` and login first to refresh credentials.
39
39
 
40
- ### WinGet (*pending approval*)
40
+ ### WinGet (Windows)
41
41
 
42
42
  ```powershell
43
43
  winget install JuanjoFuchs.ccburn
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ccburn"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Terminal-based Claude Code usage limit visualizer with real-time burn-up charts"
9
9
  authors = [{name = "JuanjoFuchs"}]
10
10
  readme = "README.md"
@@ -1,5 +1,6 @@
1
1
  """Main application class for ccburn."""
2
2
 
3
+ import os
3
4
  import signal
4
5
  import threading
5
6
  import time
@@ -34,7 +35,7 @@ class CCBurnApp:
34
35
  self,
35
36
  limit_type: LimitType = LimitType.SESSION,
36
37
  interval: int = 5,
37
- since: datetime | None = None,
38
+ since_duration: timedelta | None = None,
38
39
  json_output: bool = False,
39
40
  once: bool = False,
40
41
  compact: bool = False,
@@ -45,7 +46,7 @@ class CCBurnApp:
45
46
  Args:
46
47
  limit_type: Which limit to display
47
48
  interval: Refresh interval in seconds
48
- since: Only show data since this time (zoom view)
49
+ since_duration: Time window duration for zoom view (sliding window)
49
50
  json_output: Output JSON instead of TUI
50
51
  once: Print once and exit
51
52
  compact: Single-line output for status bars
@@ -53,13 +54,18 @@ class CCBurnApp:
53
54
  """
54
55
  self.limit_type = limit_type
55
56
  self.interval = interval
56
- self.since = since
57
+ self.since_duration = since_duration
57
58
  self.json_output = json_output
58
59
  self.once = once
59
60
  self.compact = compact
60
61
  self.debug = debug
61
62
 
62
- self.console = Console()
63
+ # Disable legacy_windows mode for modern terminals to prevent Unicode issues
64
+ # Rich may incorrectly detect legacy mode even in Windows Terminal
65
+ use_legacy = None # Auto-detect by default
66
+ if os.environ.get("WT_SESSION"): # Windows Terminal
67
+ use_legacy = False
68
+ self.console = Console(legacy_windows=use_legacy)
63
69
  self.client = UsageClient()
64
70
  self.history: HistoryDB | None = None
65
71
  self.layout = BurnupLayout(self.console)
@@ -71,6 +77,16 @@ class CCBurnApp:
71
77
  self.last_error: str | None = None
72
78
  self.snapshots: list[UsageSnapshot] = []
73
79
 
80
+ def _get_since_datetime(self) -> datetime | None:
81
+ """Calculate the since datetime based on current time and duration.
82
+
83
+ Returns:
84
+ datetime for filtering, or None if no duration set
85
+ """
86
+ if self.since_duration is None:
87
+ return None
88
+ return datetime.now(timezone.utc) - self.since_duration
89
+
74
90
  def _setup_signal_handlers(self) -> None:
75
91
  """Setup signal handlers for graceful shutdown."""
76
92
 
@@ -93,10 +109,10 @@ class CCBurnApp:
93
109
  self.history = HistoryDB()
94
110
  # Prune old data on startup
95
111
  self.history.prune_old_data()
96
- # Load existing snapshots
112
+ # Load existing snapshots (use current since datetime)
97
113
  self.snapshots = self.history.get_snapshots_for_limit(
98
114
  self.limit_type,
99
- since=self.since,
115
+ since=self._get_since_datetime(),
100
116
  )
101
117
  except Exception as e:
102
118
  # Fall back to in-memory if SQLite fails
@@ -134,10 +150,10 @@ class CCBurnApp:
134
150
  self.last_snapshot = snapshot
135
151
  self.last_fetch_time = snapshot.timestamp
136
152
  self.last_error = None
137
- # Reload snapshots from database
153
+ # Reload snapshots from database (use current since datetime)
138
154
  self.snapshots = self.history.get_snapshots_for_limit(
139
155
  self.limit_type,
140
- since=self.since,
156
+ since=self._get_since_datetime(),
141
157
  )
142
158
  return True
143
159
 
@@ -154,9 +170,10 @@ class CCBurnApp:
154
170
  # Add to local list
155
171
  self.snapshots.append(snapshot)
156
172
 
157
- # Keep only relevant snapshots (based on since or window)
158
- if self.since:
159
- self.snapshots = [s for s in self.snapshots if s.timestamp >= self.since]
173
+ # Keep only relevant snapshots (based on since_duration or window)
174
+ since_dt = self._get_since_datetime()
175
+ if since_dt:
176
+ self.snapshots = [s for s in self.snapshots if s.timestamp >= since_dt]
160
177
  else:
161
178
  # Keep last 24 hours of data for calculations
162
179
  cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
@@ -276,7 +293,7 @@ class CCBurnApp:
276
293
  limit_data,
277
294
  self.snapshots,
278
295
  error=self.last_error,
279
- since=self.since,
296
+ since_duration=self.since_duration,
280
297
  )
281
298
  self.console.print(layout)
282
299
 
@@ -307,7 +324,7 @@ class CCBurnApp:
307
324
  limit_data,
308
325
  self.snapshots,
309
326
  error=self.last_error,
310
- since=self.since,
327
+ since_duration=self.since_duration,
311
328
  )
312
329
 
313
330
  # Set initial window title
@@ -367,7 +384,7 @@ class CCBurnApp:
367
384
  self.snapshots,
368
385
  error=self.last_error,
369
386
  stale_since=stale_since,
370
- since=self.since,
387
+ since_duration=self.since_duration,
371
388
  )
372
389
  live.update(updated_layout)
373
390
  self._update_window_title()
@@ -416,7 +433,7 @@ class CCBurnApp:
416
433
  "status": metrics.status,
417
434
  }
418
435
 
419
- # Add burn rate for selected limit
436
+ # Add burn rate and projection for selected limit
420
437
  limit_data = self.last_snapshot.get_limit(self.limit_type)
421
438
  if limit_data:
422
439
  metrics = calculate_burn_metrics(limit_data, self.snapshots)
@@ -428,6 +445,39 @@ class CCBurnApp:
428
445
  }
429
446
  output["recommendation"] = metrics.recommendation
430
447
 
448
+ # Add projection data
449
+ if metrics.percent_per_hour > 0:
450
+ current_pct = limit_data.utilization * 100
451
+ remaining_pct = 100.0 - current_pct
452
+ hours_to_100 = remaining_pct / metrics.percent_per_hour
453
+
454
+ now = datetime.now(timezone.utc)
455
+ remaining_window_hours = (limit_data.resets_at - now).total_seconds() / 3600
456
+
457
+ if hours_to_100 <= remaining_window_hours:
458
+ # Will hit 100% before window ends
459
+ projected_end_pct = 100.0
460
+ hits_100 = True
461
+ status = "warning"
462
+ else:
463
+ # Won't hit 100%
464
+ projected_end_pct = current_pct + (metrics.percent_per_hour * remaining_window_hours)
465
+ hits_100 = False
466
+ status = "safe"
467
+
468
+ output["projection"] = {
469
+ "available": True,
470
+ "projected_end_pct": round(min(projected_end_pct, 100.0), 1),
471
+ "hits_100": hits_100,
472
+ "hours_to_100": round(hours_to_100, 1) if hits_100 else None,
473
+ "status": status,
474
+ }
475
+ else:
476
+ output["projection"] = {
477
+ "available": False,
478
+ "reason": "insufficient_data" if metrics.percent_per_hour == 0 else "usage_decreasing",
479
+ }
480
+
431
481
  return output
432
482
 
433
483
  def stop(self) -> None:
@@ -1,7 +1,7 @@
1
1
  """CLI command definitions for ccburn."""
2
2
 
3
3
  import re
4
- from datetime import datetime, timedelta, timezone
4
+ from datetime import timedelta
5
5
 
6
6
  import typer
7
7
  from rich.console import Console
@@ -132,12 +132,11 @@ def run_app(
132
132
  except ImportError:
133
133
  from ccburn.app import CCBurnApp
134
134
 
135
- # Calculate since datetime if provided
136
- since_dt = None
135
+ # Parse since duration if provided
136
+ since_duration = None
137
137
  if since:
138
138
  try:
139
- since_delta = parse_duration(since)
140
- since_dt = datetime.now(timezone.utc) - since_delta
139
+ since_duration = parse_duration(since)
141
140
  except typer.BadParameter as e:
142
141
  typer.echo(f"Error: {e}", err=True)
143
142
  raise typer.Exit(1) from None
@@ -145,7 +144,7 @@ def run_app(
145
144
  app = CCBurnApp(
146
145
  limit_type=limit_type,
147
146
  interval=interval,
148
- since=since_dt,
147
+ since_duration=since_duration,
149
148
  json_output=json_output,
150
149
  once=once,
151
150
  compact=compact,
@@ -9,10 +9,10 @@ from rich.console import Console, ConsoleOptions, Group, RenderableType
9
9
  from rich.jupyter import JupyterMixin
10
10
 
11
11
  try:
12
- from ..data.models import LimitData, UsageSnapshot
12
+ from ..data.models import BurnMetrics, LimitData, UsageSnapshot
13
13
  from ..utils.formatting import get_utilization_color
14
14
  except ImportError:
15
- from ccburn.data.models import LimitData, UsageSnapshot
15
+ from ccburn.data.models import BurnMetrics, LimitData, UsageSnapshot
16
16
  from ccburn.utils.formatting import get_utilization_color
17
17
 
18
18
 
@@ -23,21 +23,24 @@ class BurnupChart(JupyterMixin):
23
23
  self,
24
24
  limit_data: LimitData | None,
25
25
  snapshots: list[UsageSnapshot],
26
- since: datetime | None = None,
26
+ since_duration: timedelta | None = None,
27
27
  explicit_height: int | None = None,
28
+ burn_metrics: BurnMetrics | None = None,
28
29
  ):
29
30
  """Initialize the burnup chart.
30
31
 
31
32
  Args:
32
33
  limit_data: Current limit data (for window boundaries)
33
34
  snapshots: Historical snapshots to plot
34
- since: Only show data since this time (zoom view)
35
+ since_duration: Duration for zoom view (sliding window, e.g., last 1h)
35
36
  explicit_height: Override chart height
37
+ burn_metrics: Burn rate metrics for projection line
36
38
  """
37
39
  self.limit_data = limit_data
38
40
  self.snapshots = snapshots
39
- self.since = since
41
+ self.since_duration = since_duration
40
42
  self.explicit_height = explicit_height
43
+ self.burn_metrics = burn_metrics
41
44
  self.decoder = AnsiDecoder()
42
45
 
43
46
  def __rich_console__(
@@ -86,12 +89,14 @@ class BurnupChart(JupyterMixin):
86
89
  window_end = self.limit_data.resets_at
87
90
  now = datetime.now(timezone.utc)
88
91
 
89
- # Display window (may be zoomed with --since)
92
+ # Display window (may be zoomed with --since as a sliding window)
90
93
  display_start = original_window_start
91
94
  display_end = window_end
92
- if self.since:
93
- display_start = max(original_window_start, self.since)
94
- display_end = now # When zoomed, show until now, not session end
95
+ if self.since_duration:
96
+ # Sliding window: always show the last N duration (e.g., last 1h)
97
+ # Both start and end move forward with time
98
+ display_start = max(original_window_start, now - self.since_duration)
99
+ display_end = now
95
100
 
96
101
  # Filter snapshots to display window
97
102
  relevant_snapshots = [
@@ -160,11 +165,46 @@ class BurnupChart(JupyterMixin):
160
165
  label="Usage",
161
166
  )
162
167
 
168
+ # Plot projection line if we have positive burn rate (not on zoomed views)
169
+ hits_100_hours = None # Track when projection hits 100% for vertical marker
170
+ if self.burn_metrics and self.burn_metrics.percent_per_hour > 0 and not self.since_duration:
171
+ current_pct = self.limit_data.utilization * 100
172
+ now_hours = to_hours(now)
173
+
174
+ # Only draw projection if not already at or above 100%
175
+ if current_pct < 100.0:
176
+ # Calculate time to hit 100%
177
+ remaining_pct = 100.0 - current_pct
178
+ hours_to_100 = remaining_pct / self.burn_metrics.percent_per_hour
179
+
180
+ # End point: either 100% or window end, whichever comes first
181
+ remaining_window_hours = (display_end - now).total_seconds() / 3600
182
+
183
+ if hours_to_100 <= remaining_window_hours:
184
+ # Will hit 100% before window ends
185
+ end_hours = now_hours + hours_to_100
186
+ end_pct = 100.0
187
+ proj_color = (255, 100, 0) # Orange - warning
188
+ hits_100_hours = end_hours # Mark for vertical line
189
+ else:
190
+ # Won't hit 100%, project to window end
191
+ end_hours = now_hours + remaining_window_hours
192
+ end_pct = current_pct + (self.burn_metrics.percent_per_hour * remaining_window_hours)
193
+ proj_color = (100, 200, 100) # Green - safe
194
+
195
+ plt.plot(
196
+ [now_hours, end_hours],
197
+ [current_pct, min(end_pct, 100.0)],
198
+ color=proj_color,
199
+ marker="braille",
200
+ label="Projection",
201
+ )
202
+
163
203
  # Configure axes
164
204
  plt.xlim(0, display_hours)
165
205
 
166
206
  # Y-axis: dynamic when zoomed (--since), fixed 0-100 otherwise
167
- if self.since and values:
207
+ if self.since_duration and values:
168
208
  # Calculate dynamic range from data with padding
169
209
  all_y_values = values + pace_y
170
210
  data_min = min(all_y_values)
@@ -186,7 +226,7 @@ class BurnupChart(JupyterMixin):
186
226
  # Add "now" vertical line when showing full window (not zoomed)
187
227
  # Use dotted effect by plotting points at intervals
188
228
  now_hours_for_tick = None
189
- if not self.since:
229
+ if not self.since_duration:
190
230
  now_hours = to_hours(now)
191
231
  if 0 < now_hours < display_hours:
192
232
  # Create dotted vertical line with points using braille marker to match other lines
@@ -196,6 +236,15 @@ class BurnupChart(JupyterMixin):
196
236
  plt.plot(dot_x, dot_y, color=(0, 120, 255), marker="braille", label="Now")
197
237
  now_hours_for_tick = now_hours
198
238
 
239
+ # Add "hits 100%" vertical line when projection exceeds budget
240
+ hits_100_hours_for_tick = None
241
+ if hits_100_hours is not None and 0 < hits_100_hours < display_hours:
242
+ num_dots = 20
243
+ dot_y = [y_min + i * (y_max - y_min) / (num_dots - 1) for i in range(num_dots)]
244
+ dot_x = [hits_100_hours] * num_dots
245
+ plt.plot(dot_x, dot_y, color=(255, 100, 0), marker="braille", label="Depleted")
246
+ hits_100_hours_for_tick = hits_100_hours
247
+
199
248
  # Enable right Y axis with same range
200
249
  plt.plot([display_hours], [y_max], marker=" ", yside="right") # Hidden point to enable right axis
201
250
  plt.ylim(y_min, y_max, yside="right")
@@ -247,6 +296,28 @@ class BurnupChart(JupyterMixin):
247
296
  else:
248
297
  tick_labels.append(local_now.strftime("%H:%M"))
249
298
 
299
+ # Add "100%" tick if showing the hits-100 line
300
+ if hits_100_hours_for_tick is not None:
301
+ min_distance = display_hours / 10 # Minimum 10% of display width apart
302
+ # Filter out ticks that are too close to "100%"
303
+ filtered = [(pos, label) for pos, label in zip(tick_positions, tick_labels, strict=True)
304
+ if abs(hits_100_hours_for_tick - pos) >= min_distance]
305
+ tick_positions = [pos for pos, _ in filtered]
306
+ tick_labels = [label for _, label in filtered]
307
+ # Add the "100%" tick with timestamp
308
+ hits_100_time = display_start + timedelta(hours=hits_100_hours_for_tick)
309
+ local_hits_100 = hits_100_time.astimezone()
310
+ # Round to nearest minute
311
+ if local_hits_100.second >= 30:
312
+ local_hits_100 = local_hits_100.replace(second=0, microsecond=0) + timedelta(minutes=1)
313
+ else:
314
+ local_hits_100 = local_hits_100.replace(second=0, microsecond=0)
315
+ tick_positions.append(hits_100_hours_for_tick)
316
+ if use_date_format:
317
+ tick_labels.append(local_hits_100.strftime("%a %Hh"))
318
+ else:
319
+ tick_labels.append(local_hits_100.strftime("%H:%M"))
320
+
250
321
  plt.xticks(tick_positions, tick_labels)
251
322
 
252
323
  # No labels on axes
@@ -291,7 +362,7 @@ def create_simple_chart(
291
362
  snapshots: list[UsageSnapshot],
292
363
  width: int = 80,
293
364
  height: int = 15,
294
- since: datetime | None = None,
365
+ since_duration: timedelta | None = None,
295
366
  ) -> str:
296
367
  """Create a simple chart string without Rich integration.
297
368
 
@@ -300,10 +371,10 @@ def create_simple_chart(
300
371
  snapshots: Historical snapshots
301
372
  width: Chart width
302
373
  height: Chart height
303
- since: Only show data since this time
374
+ since_duration: Duration for zoom view (sliding window)
304
375
 
305
376
  Returns:
306
377
  Rendered chart string
307
378
  """
308
- chart = BurnupChart(limit_data, snapshots, since=since, explicit_height=height)
379
+ chart = BurnupChart(limit_data, snapshots, since_duration=since_duration, explicit_height=height)
309
380
  return chart._create_chart(width, height)
@@ -1,5 +1,6 @@
1
1
  """Progress bar gauges for ccburn TUI."""
2
2
 
3
+ import os
3
4
  import sys
4
5
 
5
6
  from rich.progress import ProgressBar
@@ -17,23 +18,51 @@ except ImportError:
17
18
  from ccburn.utils.formatting import format_reset_time, get_utilization_color
18
19
 
19
20
 
21
+ _emoji_support_cache: bool | None = None
22
+
23
+
20
24
  def _supports_emoji() -> bool:
21
25
  """Detect if the console supports emoji characters.
22
26
 
27
+ Result is cached to ensure consistent behavior throughout the session,
28
+ as Rich's Live mode may affect stdout properties during rendering.
29
+
23
30
  Returns:
24
31
  True if emoji are likely supported, False otherwise.
25
32
  """
26
- # Check if stdout encoding supports emoji
33
+ global _emoji_support_cache
34
+ if _emoji_support_cache is not None:
35
+ return _emoji_support_cache
36
+
37
+ # First, check if stdout encoding can handle emoji - this prevents crashes
38
+ # even in modern terminals if Python's encoding is misconfigured
27
39
  try:
28
40
  encoding = getattr(sys.stdout, "encoding", None) or ""
29
- if encoding.lower() in ("utf-8", "utf8"):
30
- return True
31
- # Try to encode an emoji to test
32
- "🔥".encode(encoding)
33
- return True
41
+ if encoding.lower() not in ("utf-8", "utf8"):
42
+ # Try to encode an emoji to test
43
+ "🔥".encode(encoding)
34
44
  except (UnicodeEncodeError, LookupError):
45
+ # Encoding cannot handle emoji - use ASCII to prevent crashes
46
+ _emoji_support_cache = False
35
47
  return False
36
48
 
49
+ # Encoding is OK, now check for modern terminal environments
50
+ # that are known to render emoji correctly
51
+ if os.environ.get("WT_SESSION"): # Windows Terminal
52
+ _emoji_support_cache = True
53
+ return True
54
+ if os.environ.get("TERM_PROGRAM"): # macOS Terminal, iTerm2, VS Code, etc.
55
+ _emoji_support_cache = True
56
+ return True
57
+ if os.environ.get("COLORTERM") == "truecolor": # Modern terminals with truecolor
58
+ _emoji_support_cache = True
59
+ return True
60
+
61
+ # Fall back to utf-8 encoding check
62
+ encoding = getattr(sys.stdout, "encoding", None) or ""
63
+ _emoji_support_cache = encoding.lower() in ("utf-8", "utf8")
64
+ return _emoji_support_cache
65
+
37
66
 
38
67
  def get_pace_emoji(utilization: float, budget_pace: float, ascii_fallback: bool = False) -> str:
39
68
  """Get emoji indicator based on utilization vs budget pace.
@@ -1,7 +1,7 @@
1
1
  """Layout manager for ccburn TUI."""
2
2
 
3
3
  import os
4
- from datetime import datetime, timezone
4
+ from datetime import datetime, timedelta, timezone
5
5
 
6
6
  from rich.console import Console
7
7
  from rich.layout import Layout
@@ -68,7 +68,7 @@ class BurnupLayout:
68
68
  snapshots: list[UsageSnapshot],
69
69
  error: str | None = None,
70
70
  stale_since: datetime | None = None,
71
- since: datetime | None = None,
71
+ since_duration: timedelta | None = None,
72
72
  ) -> Layout:
73
73
  """Update the layout with new data.
74
74
 
@@ -78,7 +78,7 @@ class BurnupLayout:
78
78
  snapshots: Historical snapshots for chart
79
79
  error: Error message to display (if any)
80
80
  stale_since: When data became stale (if using cached data)
81
- since: Zoom view start time
81
+ since_duration: Zoom view duration (sliding window)
82
82
 
83
83
  Returns:
84
84
  Updated Rich Layout
@@ -97,14 +97,14 @@ class BurnupLayout:
97
97
  if self.should_use_compact_mode():
98
98
  return self._create_compact_layout(limit_type, width, height)
99
99
 
100
- return self._create_full_layout(limit_type, width, height, since)
100
+ return self._create_full_layout(limit_type, width, height, since_duration)
101
101
 
102
102
  def _create_full_layout(
103
103
  self,
104
104
  limit_type: LimitType,
105
105
  width: int,
106
106
  height: int,
107
- since: datetime | None = None,
107
+ since_duration: timedelta | None = None,
108
108
  ) -> Layout:
109
109
  """Create full TUI layout with header, gauges, and chart.
110
110
 
@@ -112,7 +112,7 @@ class BurnupLayout:
112
112
  limit_type: Which limit to display
113
113
  width: Terminal width
114
114
  height: Terminal height
115
- since: Zoom view start time
115
+ since_duration: Zoom view duration (sliding window)
116
116
 
117
117
  Returns:
118
118
  Rich Layout
@@ -155,8 +155,9 @@ class BurnupLayout:
155
155
  chart = BurnupChart(
156
156
  self._last_limit_data,
157
157
  self._last_snapshots,
158
- since=since,
158
+ since_duration=since_duration,
159
159
  explicit_height=chart_height,
160
+ burn_metrics=self._last_metrics,
160
161
  )
161
162
  chart_layout.update(chart)
162
163
 
@@ -41,53 +41,86 @@ def calculate_budget_pace(resets_at: datetime, window_hours: float) -> float:
41
41
  def calculate_burn_rate(
42
42
  snapshots: list[UsageSnapshot],
43
43
  limit_type: LimitType,
44
- window_minutes: int = 5,
44
+ window_start: datetime,
45
+ window_hours: float,
46
+ min_points: int = 3,
47
+ min_span_pct: float = 0.10,
45
48
  ) -> float:
46
- """Calculate burn rate as percentage points per hour.
49
+ """Calculate burn rate as percentage points per hour using linear regression.
47
50
 
48
- Uses simple linear calculation over recent snapshots.
51
+ Uses least-squares linear regression over snapshots from the current window
52
+ for an accurate burn rate estimate.
49
53
 
50
54
  Args:
51
55
  snapshots: List of usage snapshots (should be sorted by timestamp)
52
56
  limit_type: Which limit to calculate burn rate for
53
- window_minutes: How far back to look for calculation
57
+ window_start: Start of the current window (from limit_data)
58
+ window_hours: Duration of the window in hours
59
+ min_points: Minimum data points required for regression (default 3)
60
+ min_span_pct: Minimum time span as fraction of window (default 0.10 = 10%)
54
61
 
55
62
  Returns:
56
63
  Burn rate in percentage points per hour (e.g., 12.5 means 12.5%/hour)
64
+ Returns 0.0 if insufficient data (no projection should be shown)
57
65
  """
58
66
  if len(snapshots) < 2:
59
67
  return 0.0
60
68
 
61
- now = datetime.now(timezone.utc)
62
- cutoff = now - timedelta(minutes=window_minutes)
63
-
64
- # Filter to recent snapshots
65
- recent = [s for s in snapshots if s.timestamp >= cutoff]
66
-
67
- if len(recent) < 2:
68
- # Fall back to any 2 snapshots if not enough recent ones
69
- recent = snapshots[-2:] if len(snapshots) >= 2 else []
70
-
71
- if len(recent) < 2:
69
+ # Use actual window start, not now - window_minutes
70
+ cutoff = window_start
71
+
72
+ # Filter to snapshots within window and extract (time, utilization) pairs
73
+ points: list[tuple[float, float]] = [] # (hours_from_start, utilization_pct)
74
+ first_timestamp = None
75
+ last_timestamp = None
76
+
77
+ for s in snapshots:
78
+ if s.timestamp < cutoff:
79
+ continue
80
+ limit = s.get_limit(limit_type)
81
+ if limit is None:
82
+ continue
83
+ if first_timestamp is None:
84
+ first_timestamp = s.timestamp
85
+ last_timestamp = s.timestamp
86
+ hours = (s.timestamp - first_timestamp).total_seconds() / 3600
87
+ points.append((hours, limit.utilization * 100))
88
+
89
+ # Need at least min_points for meaningful regression
90
+ if len(points) < min_points:
72
91
  return 0.0
73
92
 
74
- # Get utilization for the specified limit type
75
- first = recent[0]
76
- last = recent[-1]
77
-
78
- first_limit = first.get_limit(limit_type)
79
- last_limit = last.get_limit(limit_type)
80
-
81
- if first_limit is None or last_limit is None:
93
+ # Check that data spans at least min_span_pct of the window
94
+ # e.g., for 5h session at 10%, need 30 min of data
95
+ # e.g., for 168h weekly at 10%, need ~17 hours of data
96
+ if first_timestamp and last_timestamp:
97
+ span_hours = (last_timestamp - first_timestamp).total_seconds() / 3600
98
+ min_span_hours = window_hours * min_span_pct
99
+ if span_hours < min_span_hours:
100
+ return 0.0
101
+
102
+ # If we have exactly 2 points, use simple slope
103
+ if len(points) == 2:
104
+ dx = points[1][0] - points[0][0]
105
+ if dx <= 0:
106
+ return 0.0
107
+ return (points[1][1] - points[0][1]) / dx
108
+
109
+ # Linear regression using least squares
110
+ # slope = (n*Σxy - Σx*Σy) / (n*Σx² - (Σx)²)
111
+ n = len(points)
112
+ sum_x = sum(p[0] for p in points)
113
+ sum_y = sum(p[1] for p in points)
114
+ sum_xy = sum(p[0] * p[1] for p in points)
115
+ sum_x2 = sum(p[0] ** 2 for p in points)
116
+
117
+ denominator = n * sum_x2 - sum_x**2
118
+ if abs(denominator) < 1e-10:
119
+ # All x values are the same (no time elapsed)
82
120
  return 0.0
83
121
 
84
- delta_util = (last_limit.utilization - first_limit.utilization) * 100 # Convert to %
85
- delta_hours = (last.timestamp - first.timestamp).total_seconds() / 3600
86
-
87
- if delta_hours <= 0:
88
- return 0.0
89
-
90
- return delta_util / delta_hours # %/hour
122
+ slope = (n * sum_xy - sum_x * sum_y) / denominator
123
+ return slope # %/hour
91
124
 
92
125
 
93
126
  def estimate_time_to_empty(current_utilization: float, burn_rate_per_hour: float) -> int | None:
@@ -177,20 +210,23 @@ def get_status(utilization: float, budget_pace: float) -> str:
177
210
  def calculate_burn_metrics(
178
211
  limit_data: LimitData,
179
212
  snapshots: list[UsageSnapshot],
180
- window_minutes: int = 5,
181
213
  ) -> BurnMetrics:
182
214
  """Calculate all burn metrics for a limit.
183
215
 
184
216
  Args:
185
217
  limit_data: Current limit data
186
218
  snapshots: Historical snapshots for burn rate calculation
187
- window_minutes: How far back to look for burn rate
188
219
 
189
220
  Returns:
190
221
  BurnMetrics with all calculated values
191
222
  """
192
223
  budget_pace = calculate_budget_pace(limit_data.resets_at, limit_data.window_hours)
193
- burn_rate = calculate_burn_rate(snapshots, limit_data.limit_type, window_minutes)
224
+ burn_rate = calculate_burn_rate(
225
+ snapshots,
226
+ limit_data.limit_type,
227
+ window_start=limit_data.window_start,
228
+ window_hours=limit_data.window_hours,
229
+ )
194
230
  time_to_empty = estimate_time_to_empty(limit_data.utilization, burn_rate)
195
231
  trend = classify_burn_trend(burn_rate)
196
232
  status = get_status(limit_data.utilization, budget_pace)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccburn
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Terminal-based Claude Code usage limit visualizer with real-time burn-up charts
5
5
  Author: JuanjoFuchs
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
  [![PyPI](https://img.shields.io/pypi/v/ccburn)](https://pypi.org/project/ccburn/)
46
46
  [![Python](https://img.shields.io/pypi/pyversions/ccburn)](https://pypi.org/project/ccburn/)
47
47
  [![GitHub Release](https://img.shields.io/github/v/release/JuanjoFuchs/ccburn)](https://github.com/JuanjoFuchs/ccburn/releases)
48
- [![WinGet](https://img.shields.io/badge/WinGet-pending-yellow)](https://github.com/microsoft/winget-pkgs/pulls?q=is%3Apr+ccburn)
48
+ [![WinGet](https://img.shields.io/winget/v/JuanjoFuchs.ccburn)](https://winstall.app/apps/JuanjoFuchs.ccburn)
49
49
  [![npm downloads](https://img.shields.io/npm/dt/ccburn?label=npm%20downloads)](https://www.npmjs.com/package/ccburn)
50
50
  [![PyPI downloads](https://img.shields.io/pepy/dt/ccburn?label=pypi%20downloads)](https://pepy.tech/project/ccburn)
51
51
  [![GitHub downloads](https://img.shields.io/github/downloads/JuanjoFuchs/ccburn/total?label=github%20downloads)](https://github.com/JuanjoFuchs/ccburn/releases)
@@ -76,7 +76,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
76
76
 
77
77
  Run `claude` and login first to refresh credentials.
78
78
 
79
- ### WinGet (*pending approval*)
79
+ ### WinGet (Windows)
80
80
 
81
81
  ```powershell
82
82
  winget install JuanjoFuchs.ccburn
@@ -63,12 +63,15 @@ class TestCalculateBurnRate:
63
63
 
64
64
  def test_no_snapshots(self):
65
65
  """Burn rate should be 0 with no snapshots."""
66
- rate = calculate_burn_rate([], LimitType.SESSION)
66
+ now = datetime.now(timezone.utc)
67
+ window_start = now - timedelta(hours=5)
68
+ rate = calculate_burn_rate([], LimitType.SESSION, window_start, window_hours=5)
67
69
  assert rate == 0.0
68
70
 
69
71
  def test_one_snapshot(self):
70
72
  """Burn rate should be 0 with only one snapshot."""
71
73
  now = datetime.now(timezone.utc)
74
+ window_start = now - timedelta(hours=3)
72
75
  snapshot = UsageSnapshot(
73
76
  timestamp=now,
74
77
  session=LimitData(
@@ -80,18 +83,25 @@ class TestCalculateBurnRate:
80
83
  weekly_sonnet=None,
81
84
  weekly_opus=None,
82
85
  )
83
- rate = calculate_burn_rate([snapshot], LimitType.SESSION)
86
+ rate = calculate_burn_rate([snapshot], LimitType.SESSION, window_start, window_hours=5)
84
87
  assert rate == 0.0
85
88
 
86
89
  def test_increasing_usage(self, sample_snapshots):
87
90
  """Burn rate should be positive when usage is increasing."""
88
- rate = calculate_burn_rate(sample_snapshots, LimitType.SESSION, window_minutes=10)
91
+ # sample_snapshots are from 2026-01-08 14:00:00 to 14:04:30 (4.5 min span)
92
+ # Window starts before the first snapshot
93
+ # Use 30-minute window so 4.5 min span > 10% minimum (3 min)
94
+ window_start = datetime(2026, 1, 8, 13, 55, 0, tzinfo=timezone.utc)
95
+ rate = calculate_burn_rate(
96
+ sample_snapshots, LimitType.SESSION, window_start, window_hours=0.5
97
+ )
89
98
  # With 10% increase over 4.5 minutes, rate should be ~133%/hour
90
99
  assert rate > 0
91
100
 
92
101
  def test_constant_usage(self):
93
102
  """Burn rate should be 0 when usage is constant."""
94
103
  now = datetime.now(timezone.utc)
104
+ window_start = now - timedelta(hours=1)
95
105
  snapshots = []
96
106
  for i in range(5):
97
107
  ts = now - timedelta(minutes=i)
@@ -109,7 +119,7 @@ class TestCalculateBurnRate:
109
119
  snapshots.append(snapshot)
110
120
 
111
121
  snapshots.sort(key=lambda s: s.timestamp)
112
- rate = calculate_burn_rate(snapshots, LimitType.SESSION, window_minutes=10)
122
+ rate = calculate_burn_rate(snapshots, LimitType.SESSION, window_start, window_hours=5)
113
123
  assert rate == pytest.approx(0.0, abs=0.1)
114
124
 
115
125
 
@@ -127,7 +127,9 @@ class TestCLI:
127
127
 
128
128
  @patch("ccburn.app.CCBurnApp")
129
129
  def test_since_flag(self, mock_app_class):
130
- """Test --since flag sets time window."""
130
+ """Test --since flag sets time window as sliding duration."""
131
+ from datetime import timedelta
132
+
131
133
  mock_app = MagicMock()
132
134
  mock_app.run.return_value = 0
133
135
  mock_app_class.return_value = mock_app
@@ -136,4 +138,4 @@ class TestCLI:
136
138
 
137
139
  mock_app_class.assert_called_once()
138
140
  call_kwargs = mock_app_class.call_args.kwargs
139
- assert call_kwargs["since"] is not None # datetime object
141
+ assert call_kwargs["since_duration"] == timedelta(hours=2) # timedelta for sliding window
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes