ccburn 0.2.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
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.
- ccburn/app.py +82 -46
- ccburn/cli.py +5 -6
- ccburn/display/chart.py +85 -14
- ccburn/display/gauges.py +35 -6
- ccburn/display/layout.py +8 -7
- ccburn/utils/calculator.py +69 -33
- {ccburn-0.2.2.dist-info → ccburn-0.3.1.dist-info}/METADATA +3 -3
- {ccburn-0.2.2.dist-info → ccburn-0.3.1.dist-info}/RECORD +12 -12
- {ccburn-0.2.2.dist-info → ccburn-0.3.1.dist-info}/WHEEL +0 -0
- {ccburn-0.2.2.dist-info → ccburn-0.3.1.dist-info}/entry_points.txt +0 -0
- {ccburn-0.2.2.dist-info → ccburn-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {ccburn-0.2.2.dist-info → ccburn-0.3.1.dist-info}/top_level.txt +0 -0
ccburn/app.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Main application class for ccburn."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import signal
|
|
4
5
|
import threading
|
|
5
|
-
import time
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
@@ -34,7 +34,7 @@ class CCBurnApp:
|
|
|
34
34
|
self,
|
|
35
35
|
limit_type: LimitType = LimitType.SESSION,
|
|
36
36
|
interval: int = 5,
|
|
37
|
-
|
|
37
|
+
since_duration: timedelta | None = None,
|
|
38
38
|
json_output: bool = False,
|
|
39
39
|
once: bool = False,
|
|
40
40
|
compact: bool = False,
|
|
@@ -45,7 +45,7 @@ class CCBurnApp:
|
|
|
45
45
|
Args:
|
|
46
46
|
limit_type: Which limit to display
|
|
47
47
|
interval: Refresh interval in seconds
|
|
48
|
-
|
|
48
|
+
since_duration: Time window duration for zoom view (sliding window)
|
|
49
49
|
json_output: Output JSON instead of TUI
|
|
50
50
|
once: Print once and exit
|
|
51
51
|
compact: Single-line output for status bars
|
|
@@ -53,24 +53,40 @@ class CCBurnApp:
|
|
|
53
53
|
"""
|
|
54
54
|
self.limit_type = limit_type
|
|
55
55
|
self.interval = interval
|
|
56
|
-
self.
|
|
56
|
+
self.since_duration = since_duration
|
|
57
57
|
self.json_output = json_output
|
|
58
58
|
self.once = once
|
|
59
59
|
self.compact = compact
|
|
60
60
|
self.debug = debug
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# Disable legacy_windows mode for modern terminals to prevent Unicode issues
|
|
63
|
+
# Rich may incorrectly detect legacy mode even in Windows Terminal
|
|
64
|
+
use_legacy = None # Auto-detect by default
|
|
65
|
+
if os.environ.get("WT_SESSION"): # Windows Terminal
|
|
66
|
+
use_legacy = False
|
|
67
|
+
self.console = Console(legacy_windows=use_legacy)
|
|
63
68
|
self.client = UsageClient()
|
|
64
69
|
self.history: HistoryDB | None = None
|
|
65
70
|
self.layout = BurnupLayout(self.console)
|
|
66
71
|
|
|
67
72
|
# State
|
|
68
|
-
self.running =
|
|
73
|
+
self.running = True
|
|
74
|
+
self._stop_event = threading.Event() # For interruptible sleep
|
|
69
75
|
self.last_snapshot: UsageSnapshot | None = None
|
|
70
76
|
self.last_fetch_time: datetime | None = None
|
|
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.
|
|
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.
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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)
|
|
@@ -180,18 +197,6 @@ class CCBurnApp:
|
|
|
180
197
|
self.last_error = f"Unexpected error: {e}"
|
|
181
198
|
return False
|
|
182
199
|
|
|
183
|
-
def _should_refresh(self) -> bool:
|
|
184
|
-
"""Check if we should fetch new data.
|
|
185
|
-
|
|
186
|
-
Returns:
|
|
187
|
-
True if it's time to refresh
|
|
188
|
-
"""
|
|
189
|
-
if self.last_fetch_time is None:
|
|
190
|
-
return True
|
|
191
|
-
|
|
192
|
-
elapsed = (datetime.now(timezone.utc) - self.last_fetch_time).total_seconds()
|
|
193
|
-
return elapsed >= self.interval
|
|
194
|
-
|
|
195
200
|
def run(self) -> int:
|
|
196
201
|
"""Run the application.
|
|
197
202
|
|
|
@@ -276,7 +281,7 @@ class CCBurnApp:
|
|
|
276
281
|
limit_data,
|
|
277
282
|
self.snapshots,
|
|
278
283
|
error=self.last_error,
|
|
279
|
-
|
|
284
|
+
since_duration=self.since_duration,
|
|
280
285
|
)
|
|
281
286
|
self.console.print(layout)
|
|
282
287
|
|
|
@@ -294,7 +299,7 @@ class CCBurnApp:
|
|
|
294
299
|
Exit code
|
|
295
300
|
"""
|
|
296
301
|
self._setup_signal_handlers()
|
|
297
|
-
self.running
|
|
302
|
+
self.running = True
|
|
298
303
|
|
|
299
304
|
try:
|
|
300
305
|
# Create initial display
|
|
@@ -307,7 +312,7 @@ class CCBurnApp:
|
|
|
307
312
|
limit_data,
|
|
308
313
|
self.snapshots,
|
|
309
314
|
error=self.last_error,
|
|
310
|
-
|
|
315
|
+
since_duration=self.since_duration,
|
|
311
316
|
)
|
|
312
317
|
|
|
313
318
|
# Set initial window title
|
|
@@ -316,7 +321,7 @@ class CCBurnApp:
|
|
|
316
321
|
with Live(
|
|
317
322
|
initial_layout,
|
|
318
323
|
console=self.console,
|
|
319
|
-
|
|
324
|
+
auto_refresh=False, # Manual refresh only - saves CPU
|
|
320
325
|
transient=False,
|
|
321
326
|
screen=True,
|
|
322
327
|
vertical_overflow="visible",
|
|
@@ -341,18 +346,14 @@ class CCBurnApp:
|
|
|
341
346
|
Args:
|
|
342
347
|
live: Rich Live instance
|
|
343
348
|
"""
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
while self.running.is_set():
|
|
349
|
+
while self.running:
|
|
347
350
|
try:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
# Check if we should refresh data
|
|
351
|
-
if self._should_refresh():
|
|
352
|
-
self._fetch_and_update()
|
|
351
|
+
# Fetch new data
|
|
352
|
+
data_changed = self._fetch_and_update()
|
|
353
353
|
|
|
354
|
-
#
|
|
355
|
-
|
|
354
|
+
# Only re-render layout when data actually changes
|
|
355
|
+
# This avoids expensive chart re-rendering
|
|
356
|
+
if data_changed:
|
|
356
357
|
limit_data = None
|
|
357
358
|
if self.last_snapshot:
|
|
358
359
|
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
@@ -367,18 +368,19 @@ class CCBurnApp:
|
|
|
367
368
|
self.snapshots,
|
|
368
369
|
error=self.last_error,
|
|
369
370
|
stale_since=stale_since,
|
|
370
|
-
|
|
371
|
+
since_duration=self.since_duration,
|
|
371
372
|
)
|
|
372
373
|
live.update(updated_layout)
|
|
374
|
+
live.refresh() # Manual refresh since auto_refresh=False
|
|
373
375
|
self._update_window_title()
|
|
374
|
-
last_update = current_time
|
|
375
376
|
|
|
376
|
-
#
|
|
377
|
-
|
|
377
|
+
# Sleep until next refresh interval
|
|
378
|
+
# Use Event.wait() for interruptible sleep on shutdown
|
|
379
|
+
self._stop_event.wait(timeout=self.interval)
|
|
378
380
|
|
|
379
381
|
except Exception:
|
|
380
382
|
# Log but continue
|
|
381
|
-
|
|
383
|
+
self._stop_event.wait(timeout=self.interval)
|
|
382
384
|
|
|
383
385
|
def _create_json_output(self) -> dict:
|
|
384
386
|
"""Create JSON output structure.
|
|
@@ -416,7 +418,7 @@ class CCBurnApp:
|
|
|
416
418
|
"status": metrics.status,
|
|
417
419
|
}
|
|
418
420
|
|
|
419
|
-
# Add burn rate for selected limit
|
|
421
|
+
# Add burn rate and projection for selected limit
|
|
420
422
|
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
421
423
|
if limit_data:
|
|
422
424
|
metrics = calculate_burn_metrics(limit_data, self.snapshots)
|
|
@@ -428,11 +430,45 @@ class CCBurnApp:
|
|
|
428
430
|
}
|
|
429
431
|
output["recommendation"] = metrics.recommendation
|
|
430
432
|
|
|
433
|
+
# Add projection data
|
|
434
|
+
if metrics.percent_per_hour > 0:
|
|
435
|
+
current_pct = limit_data.utilization * 100
|
|
436
|
+
remaining_pct = 100.0 - current_pct
|
|
437
|
+
hours_to_100 = remaining_pct / metrics.percent_per_hour
|
|
438
|
+
|
|
439
|
+
now = datetime.now(timezone.utc)
|
|
440
|
+
remaining_window_hours = (limit_data.resets_at - now).total_seconds() / 3600
|
|
441
|
+
|
|
442
|
+
if hours_to_100 <= remaining_window_hours:
|
|
443
|
+
# Will hit 100% before window ends
|
|
444
|
+
projected_end_pct = 100.0
|
|
445
|
+
hits_100 = True
|
|
446
|
+
status = "warning"
|
|
447
|
+
else:
|
|
448
|
+
# Won't hit 100%
|
|
449
|
+
projected_end_pct = current_pct + (metrics.percent_per_hour * remaining_window_hours)
|
|
450
|
+
hits_100 = False
|
|
451
|
+
status = "safe"
|
|
452
|
+
|
|
453
|
+
output["projection"] = {
|
|
454
|
+
"available": True,
|
|
455
|
+
"projected_end_pct": round(min(projected_end_pct, 100.0), 1),
|
|
456
|
+
"hits_100": hits_100,
|
|
457
|
+
"hours_to_100": round(hours_to_100, 1) if hits_100 else None,
|
|
458
|
+
"status": status,
|
|
459
|
+
}
|
|
460
|
+
else:
|
|
461
|
+
output["projection"] = {
|
|
462
|
+
"available": False,
|
|
463
|
+
"reason": "insufficient_data" if metrics.percent_per_hour == 0 else "usage_decreasing",
|
|
464
|
+
}
|
|
465
|
+
|
|
431
466
|
return output
|
|
432
467
|
|
|
433
468
|
def stop(self) -> None:
|
|
434
469
|
"""Stop the application."""
|
|
435
|
-
self.running
|
|
470
|
+
self.running = False
|
|
471
|
+
self._stop_event.set() # Wake up from sleep immediately
|
|
436
472
|
|
|
437
473
|
def _update_window_title(self) -> None:
|
|
438
474
|
"""Update terminal window title with current status."""
|
ccburn/cli.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""CLI command definitions for ccburn."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from datetime import
|
|
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
|
-
#
|
|
136
|
-
|
|
135
|
+
# Parse since duration if provided
|
|
136
|
+
since_duration = None
|
|
137
137
|
if since:
|
|
138
138
|
try:
|
|
139
|
-
|
|
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
|
-
|
|
147
|
+
since_duration=since_duration,
|
|
149
148
|
json_output=json_output,
|
|
150
149
|
once=once,
|
|
151
150
|
compact=compact,
|
ccburn/display/chart.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
379
|
+
chart = BurnupChart(limit_data, snapshots, since_duration=since_duration, explicit_height=height)
|
|
309
380
|
return chart._create_chart(width, height)
|
ccburn/display/gauges.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
ccburn/display/layout.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
ccburn/utils/calculator.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
62
|
-
cutoff =
|
|
63
|
-
|
|
64
|
-
# Filter to
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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(
|
|
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.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
[](https://pypi.org/project/ccburn/)
|
|
46
46
|
[](https://pypi.org/project/ccburn/)
|
|
47
47
|
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
48
|
-
[](https://winstall.app/apps/JuanjoFuchs.ccburn)
|
|
49
49
|
[](https://www.npmjs.com/package/ccburn)
|
|
50
50
|
[](https://pepy.tech/project/ccburn)
|
|
51
51
|
[](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 (
|
|
79
|
+
### WinGet (Windows)
|
|
80
80
|
|
|
81
81
|
```powershell
|
|
82
82
|
winget install JuanjoFuchs.ccburn
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
ccburn/__init__.py,sha256=u8tlHJ2bTam19CROn2nufmcfCONN9BwlRLY-8AOu8Os,191
|
|
2
|
-
ccburn/app.py,sha256=
|
|
3
|
-
ccburn/cli.py,sha256=
|
|
2
|
+
ccburn/app.py,sha256=IvIFOX10AFD103P5XiIenkEB2clAq2o3XJR6lceMVhE,17775
|
|
3
|
+
ccburn/cli.py,sha256=dvh6V8I1TQlqbCv4-5j3ANRdKpoQKPHgA2QrBZUSkJY,7120
|
|
4
4
|
ccburn/main.py,sha256=TqWLl9xxOtbpTQr-ObomzSLG3jNec2GZ6RKEQYYdGLg,2474
|
|
5
5
|
ccburn/data/__init__.py,sha256=ZczEZwodQ-MMO5F7fVNsyIpUCRY8Ya9W4pwdOOJWxm4,803
|
|
6
6
|
ccburn/data/credentials.py,sha256=wDiiTkZZDBjnYspvtWJ_52xXdTdIgBndLlfFMi-peZ8,5228
|
|
@@ -8,15 +8,15 @@ ccburn/data/history.py,sha256=ouBxrXpMp_eTs0kba1Bg55TI6bsBSMToJ32tH1wNHQI,12879
|
|
|
8
8
|
ccburn/data/models.py,sha256=Sd2T36gH6OaNHl9zRlnnQXI-ziBA8Gl6rPYQIzmr7G4,5403
|
|
9
9
|
ccburn/data/usage_client.py,sha256=_dGwmI5vYPk4S-HUe2_fnTwSuAfTPaOFff7mKPFnhps,4570
|
|
10
10
|
ccburn/display/__init__.py,sha256=aL7TV53kU5oxlIwJ8M17stG2aC6UeGB-pj2u5BOpegs,495
|
|
11
|
-
ccburn/display/chart.py,sha256=
|
|
12
|
-
ccburn/display/gauges.py,sha256=
|
|
13
|
-
ccburn/display/layout.py,sha256=
|
|
11
|
+
ccburn/display/chart.py,sha256=7Rg9KeeeDjr8jwvEhHbmBYmZ0Wj3N1ijfzsQy6u6kqA,15917
|
|
12
|
+
ccburn/display/gauges.py,sha256=qPjFmTJd3RcwMfpmONFHByno5ByDPaVpvDttHnfct1E,10628
|
|
13
|
+
ccburn/display/layout.py,sha256=DlgmH3G2RbpyYfWYD6YP1IlohYmhWBSiqjM7Y7fto-U,8244
|
|
14
14
|
ccburn/utils/__init__.py,sha256=N6EzUX9hUJkuga_l9Ci3of1CWNtQgpNmMmNyY2DgYrg,1119
|
|
15
|
-
ccburn/utils/calculator.py,sha256=
|
|
15
|
+
ccburn/utils/calculator.py,sha256=m-XATeOqgD5Z4ZranuI7QSlSb4pnW43_rtaJSF2Te6Q,7706
|
|
16
16
|
ccburn/utils/formatting.py,sha256=MEVIohBmvSur0hcc67oyYRDooiUMf0rPa4LO1fc2Ud4,4174
|
|
17
|
-
ccburn-0.
|
|
18
|
-
ccburn-0.
|
|
19
|
-
ccburn-0.
|
|
20
|
-
ccburn-0.
|
|
21
|
-
ccburn-0.
|
|
22
|
-
ccburn-0.
|
|
17
|
+
ccburn-0.3.1.dist-info/licenses/LICENSE,sha256=Qf2mqNi2qJ35JytfoTdR1SgYhZ2Mt4Ohcf-tu_MuYC0,1068
|
|
18
|
+
ccburn-0.3.1.dist-info/METADATA,sha256=UnlFr_QEFUjXnwh0ho0iw_1KHYesD7g_uzOL5DJSWjk,7343
|
|
19
|
+
ccburn-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
20
|
+
ccburn-0.3.1.dist-info/entry_points.txt,sha256=GfFQ5VusMR8RJ9meygqWjaErdmYsf_arbILzf64WjLU,43
|
|
21
|
+
ccburn-0.3.1.dist-info/top_level.txt,sha256=SM8TwGQZqQKKIQObVWQkfpA0OI4gRut7bPl-iM3g5RI,7
|
|
22
|
+
ccburn-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|