ccburn 0.1.0__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.
@@ -0,0 +1,246 @@
1
+ """Layout manager for ccburn TUI."""
2
+
3
+ import os
4
+ from datetime import datetime, timezone
5
+
6
+ from rich.console import Console
7
+ from rich.layout import Layout
8
+ from rich.text import Text
9
+
10
+ try:
11
+ from ..data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
12
+ from ..utils.calculator import calculate_budget_pace, calculate_burn_metrics
13
+ from .chart import BurnupChart
14
+ from .gauges import create_gauge_section, create_header
15
+ except ImportError:
16
+ from ccburn.data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
17
+ from ccburn.display.chart import BurnupChart
18
+ from ccburn.display.gauges import create_gauge_section, create_header
19
+ from ccburn.utils.calculator import calculate_budget_pace, calculate_burn_metrics
20
+
21
+
22
+ class BurnupLayout:
23
+ """Layout manager for the ccburn TUI."""
24
+
25
+ MIN_WIDTH = 40
26
+ MIN_HEIGHT = 10
27
+ COMPACT_WIDTH = 60
28
+ COMPACT_HEIGHT = 15
29
+
30
+ def __init__(self, console: Console | None = None):
31
+ """Initialize the layout manager.
32
+
33
+ Args:
34
+ console: Rich Console instance (creates one if not provided)
35
+ """
36
+ self.console = console or Console()
37
+ self._last_limit_data: LimitData | None = None
38
+ self._last_snapshots: list[UsageSnapshot] = []
39
+ self._last_metrics: BurnMetrics | None = None
40
+ self._error_message: str | None = None
41
+ self._stale_data_time: datetime | None = None
42
+
43
+ def get_terminal_size(self) -> tuple[int, int]:
44
+ """Get current terminal size.
45
+
46
+ Returns:
47
+ (width, height) tuple
48
+ """
49
+ try:
50
+ size = os.get_terminal_size()
51
+ return size.columns, size.lines
52
+ except OSError:
53
+ return 80, 24 # Default fallback
54
+
55
+ def should_use_compact_mode(self) -> bool:
56
+ """Determine if compact mode should be used.
57
+
58
+ Returns:
59
+ True if terminal is too small for full layout
60
+ """
61
+ width, height = self.get_terminal_size()
62
+ return width < self.COMPACT_WIDTH or height < self.COMPACT_HEIGHT
63
+
64
+ def update(
65
+ self,
66
+ limit_type: LimitType,
67
+ limit_data: LimitData | None,
68
+ snapshots: list[UsageSnapshot],
69
+ error: str | None = None,
70
+ stale_since: datetime | None = None,
71
+ since: datetime | None = None,
72
+ ) -> Layout:
73
+ """Update the layout with new data.
74
+
75
+ Args:
76
+ limit_type: Which limit to display
77
+ limit_data: Current limit data
78
+ snapshots: Historical snapshots for chart
79
+ error: Error message to display (if any)
80
+ stale_since: When data became stale (if using cached data)
81
+ since: Zoom view start time
82
+
83
+ Returns:
84
+ Updated Rich Layout
85
+ """
86
+ self._last_limit_data = limit_data
87
+ self._last_snapshots = snapshots
88
+ self._error_message = error
89
+ self._stale_data_time = stale_since
90
+
91
+ # Calculate metrics
92
+ if limit_data:
93
+ self._last_metrics = calculate_burn_metrics(limit_data, snapshots)
94
+
95
+ width, height = self.get_terminal_size()
96
+
97
+ if self.should_use_compact_mode():
98
+ return self._create_compact_layout(limit_type, width, height)
99
+
100
+ return self._create_full_layout(limit_type, width, height, since)
101
+
102
+ def _create_full_layout(
103
+ self,
104
+ limit_type: LimitType,
105
+ width: int,
106
+ height: int,
107
+ since: datetime | None = None,
108
+ ) -> Layout:
109
+ """Create full TUI layout with header, gauges, and chart.
110
+
111
+ Args:
112
+ limit_type: Which limit to display
113
+ width: Terminal width
114
+ height: Terminal height
115
+ since: Zoom view start time
116
+
117
+ Returns:
118
+ Rich Layout
119
+ """
120
+ root = Layout()
121
+
122
+ # Calculate sizes - minimize header/gauges, maximize chart
123
+ header_height = 1
124
+ gauges_height = 2 # Two progress bars
125
+ error_height = 1 if self._error_message or self._stale_data_time else 0
126
+ # Use full remaining height for chart (no extra padding with screen=True)
127
+ chart_height = height - header_height - gauges_height - error_height
128
+
129
+ # Create sections
130
+ header_layout = Layout(size=header_height, name="header")
131
+ gauges_layout = Layout(size=gauges_height, name="gauges")
132
+ chart_layout = Layout(name="chart")
133
+
134
+ # Add error banner if needed
135
+ if self._error_message or self._stale_data_time:
136
+ error_layout = Layout(size=error_height, name="error")
137
+ root.split_column(header_layout, gauges_layout, error_layout, chart_layout)
138
+ error_layout.update(self._create_error_banner())
139
+ else:
140
+ root.split_column(header_layout, gauges_layout, chart_layout)
141
+
142
+ # Update header
143
+ header_layout.update(create_header(limit_type, self._last_limit_data))
144
+
145
+ # Update gauges
146
+ budget_pace = 0.0
147
+ if self._last_limit_data:
148
+ budget_pace = calculate_budget_pace(
149
+ self._last_limit_data.resets_at,
150
+ self._last_limit_data.window_hours,
151
+ )
152
+ gauges_layout.update(create_gauge_section(self._last_limit_data, budget_pace))
153
+
154
+ # Update chart
155
+ chart = BurnupChart(
156
+ self._last_limit_data,
157
+ self._last_snapshots,
158
+ since=since,
159
+ explicit_height=chart_height,
160
+ )
161
+ chart_layout.update(chart)
162
+
163
+ return root
164
+
165
+ def _create_compact_layout(
166
+ self,
167
+ limit_type: LimitType,
168
+ width: int,
169
+ height: int,
170
+ ) -> Layout:
171
+ """Create compact layout for small terminals.
172
+
173
+ Args:
174
+ limit_type: Which limit to display
175
+ width: Terminal width
176
+ height: Terminal height
177
+
178
+ Returns:
179
+ Rich Layout with minimal display
180
+ """
181
+ root = Layout()
182
+
183
+ # Just header and gauges in compact mode
184
+ header_layout = Layout(size=1, name="header")
185
+ gauges_layout = Layout(size=3, name="gauges")
186
+ info_layout = Layout(name="info")
187
+
188
+ root.split_column(header_layout, gauges_layout, info_layout)
189
+
190
+ # Update header
191
+ header_layout.update(create_header(limit_type, self._last_limit_data))
192
+
193
+ # Update gauges
194
+ budget_pace = 0.0
195
+ if self._last_limit_data:
196
+ budget_pace = calculate_budget_pace(
197
+ self._last_limit_data.resets_at,
198
+ self._last_limit_data.window_hours,
199
+ )
200
+ gauges_layout.update(create_gauge_section(self._last_limit_data, budget_pace))
201
+
202
+ # Show info text instead of chart
203
+ info_text = Text("Terminal too small for chart. Expand window or use --compact.", style="dim")
204
+ if self._error_message:
205
+ info_text = Text(self._error_message, style="yellow")
206
+ info_layout.update(info_text)
207
+
208
+ return root
209
+
210
+ def _create_error_banner(self) -> Text:
211
+ """Create error/warning banner.
212
+
213
+ Returns:
214
+ Rich Text with error/warning message
215
+ """
216
+ if self._error_message:
217
+ return Text(f"Warning: {self._error_message}", style="yellow")
218
+
219
+ if self._stale_data_time:
220
+ from ..utils.formatting import format_duration
221
+
222
+ now = datetime.now(timezone.utc)
223
+ minutes_stale = int((now - self._stale_data_time).total_seconds() / 60)
224
+ return Text(
225
+ f"Using cached data (last updated {format_duration(minutes_stale)} ago)",
226
+ style="yellow",
227
+ )
228
+
229
+ return Text("")
230
+
231
+ def render(self) -> Layout:
232
+ """Get the current layout without updating data.
233
+
234
+ Returns:
235
+ Current Rich Layout
236
+ """
237
+ limit_type = LimitType.SESSION
238
+ if self._last_limit_data:
239
+ limit_type = self._last_limit_data.limit_type
240
+
241
+ width, height = self.get_terminal_size()
242
+
243
+ if self.should_use_compact_mode():
244
+ return self._create_compact_layout(limit_type, width, height)
245
+
246
+ return self._create_full_layout(limit_type, width, height)
ccburn/main.py ADDED
@@ -0,0 +1,98 @@
1
+ """ccburn - Terminal-based Claude Code usage limit visualizer."""
2
+
3
+
4
+ import typer
5
+
6
+ try:
7
+ from .cli import (
8
+ CompactOption,
9
+ DebugOption,
10
+ JsonOption,
11
+ OnceOption,
12
+ SessionIntervalOption,
13
+ SinceOption,
14
+ register_commands,
15
+ run_app,
16
+ )
17
+ from .data.models import LimitType
18
+ except ImportError:
19
+ from ccburn.cli import (
20
+ CompactOption,
21
+ DebugOption,
22
+ JsonOption,
23
+ OnceOption,
24
+ SessionIntervalOption,
25
+ SinceOption,
26
+ register_commands,
27
+ run_app,
28
+ )
29
+ from ccburn.data.models import LimitType
30
+
31
+
32
+ app = typer.Typer(
33
+ name="ccburn",
34
+ help="Visualize Claude Code usage limits with real-time burn-up charts.",
35
+ rich_markup_mode="rich",
36
+ add_completion=True,
37
+ no_args_is_help=False,
38
+ )
39
+
40
+ # Register subcommands
41
+ register_commands(app)
42
+
43
+
44
+ @app.callback(invoke_without_command=True)
45
+ def main(
46
+ ctx: typer.Context,
47
+ version: bool = typer.Option(
48
+ False,
49
+ "--version",
50
+ "-v",
51
+ help="Show version and exit.",
52
+ ),
53
+ json_output: bool = JsonOption,
54
+ once: bool = OnceOption,
55
+ compact: bool = CompactOption,
56
+ since: str | None = SinceOption,
57
+ interval: int = SessionIntervalOption,
58
+ debug: bool = DebugOption,
59
+ ) -> None:
60
+ """ccburn - Claude Code usage limit visualizer.
61
+
62
+ Visualize your Claude Code usage limits with real-time burn-up charts.
63
+ Shows 5-hour rolling session limit by default.
64
+
65
+ \b
66
+ Examples:
67
+ ccburn # Live TUI showing session limit
68
+ ccburn session # Same as above (explicit)
69
+ ccburn weekly # Show 7-day weekly limit
70
+ ccburn weekly-sonnet # Show 7-day Sonnet limit
71
+ ccburn --json # JSON output
72
+ ccburn --compact # Single-line for status bars
73
+ ccburn --once # Print once and exit
74
+ """
75
+ if version:
76
+ try:
77
+ from importlib.metadata import version as get_version
78
+
79
+ typer.echo(f"ccburn {get_version('ccburn')}")
80
+ except Exception:
81
+ typer.echo("ccburn 1.0.0")
82
+ raise typer.Exit()
83
+
84
+ # If no subcommand, default to session
85
+ if ctx.invoked_subcommand is None:
86
+ run_app(
87
+ LimitType.SESSION,
88
+ json_output=json_output,
89
+ once=once,
90
+ compact=compact,
91
+ since=since,
92
+ interval=interval,
93
+ debug=debug,
94
+ )
95
+
96
+
97
+ if __name__ == "__main__":
98
+ app()
@@ -0,0 +1,45 @@
1
+ """Utilities for ccburn - calculators and formatters."""
2
+
3
+ try:
4
+ from .calculator import (
5
+ calculate_budget_pace,
6
+ calculate_burn_metrics,
7
+ calculate_burn_rate,
8
+ classify_burn_trend,
9
+ estimate_time_to_empty,
10
+ get_recommendation,
11
+ )
12
+ from .formatting import (
13
+ format_duration,
14
+ format_percentage,
15
+ format_reset_time,
16
+ get_utilization_color,
17
+ )
18
+ except ImportError:
19
+ from ccburn.utils.calculator import (
20
+ calculate_budget_pace,
21
+ calculate_burn_metrics,
22
+ calculate_burn_rate,
23
+ classify_burn_trend,
24
+ estimate_time_to_empty,
25
+ get_recommendation,
26
+ )
27
+ from ccburn.utils.formatting import (
28
+ format_duration,
29
+ format_percentage,
30
+ format_reset_time,
31
+ get_utilization_color,
32
+ )
33
+
34
+ __all__ = [
35
+ "format_duration",
36
+ "format_percentage",
37
+ "format_reset_time",
38
+ "get_utilization_color",
39
+ "calculate_budget_pace",
40
+ "calculate_burn_rate",
41
+ "estimate_time_to_empty",
42
+ "classify_burn_trend",
43
+ "get_recommendation",
44
+ "calculate_burn_metrics",
45
+ ]
@@ -0,0 +1,207 @@
1
+ """Calculator utilities for burn rate, budget pace, and predictions."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ try:
6
+ from ..data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
7
+ except ImportError:
8
+ from ccburn.data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
9
+
10
+
11
+ def calculate_budget_pace(resets_at: datetime, window_hours: float) -> float:
12
+ """Calculate what percentage of the window has elapsed.
13
+
14
+ Formula: (now - window_start) / window_duration
15
+ Where: window_start = resets_at - window_hours
16
+
17
+ Args:
18
+ resets_at: When the limit resets
19
+ window_hours: Duration of the window in hours
20
+
21
+ Returns:
22
+ Float between 0.0 and 1.0 representing elapsed fraction
23
+ """
24
+ now = datetime.now(timezone.utc)
25
+
26
+ # Ensure resets_at is timezone-aware
27
+ if resets_at.tzinfo is None:
28
+ resets_at = resets_at.replace(tzinfo=timezone.utc)
29
+
30
+ window_start = resets_at - timedelta(hours=window_hours)
31
+ elapsed = (now - window_start).total_seconds()
32
+ window_seconds = window_hours * 3600
33
+
34
+ if window_seconds <= 0:
35
+ return 0.0
36
+
37
+ pace = elapsed / window_seconds
38
+ return max(0.0, min(1.0, pace)) # Clamp 0-1
39
+
40
+
41
+ def calculate_burn_rate(
42
+ snapshots: list[UsageSnapshot],
43
+ limit_type: LimitType,
44
+ window_minutes: int = 5,
45
+ ) -> float:
46
+ """Calculate burn rate as percentage points per hour.
47
+
48
+ Uses simple linear calculation over recent snapshots.
49
+
50
+ Args:
51
+ snapshots: List of usage snapshots (should be sorted by timestamp)
52
+ limit_type: Which limit to calculate burn rate for
53
+ window_minutes: How far back to look for calculation
54
+
55
+ Returns:
56
+ Burn rate in percentage points per hour (e.g., 12.5 means 12.5%/hour)
57
+ """
58
+ if len(snapshots) < 2:
59
+ return 0.0
60
+
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:
72
+ return 0.0
73
+
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:
82
+ return 0.0
83
+
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
91
+
92
+
93
+ def estimate_time_to_empty(current_utilization: float, burn_rate_per_hour: float) -> int | None:
94
+ """Estimate minutes until 100% utilization at current burn rate.
95
+
96
+ Args:
97
+ current_utilization: Current utilization (0-1)
98
+ burn_rate_per_hour: Burn rate in percentage points per hour
99
+
100
+ Returns:
101
+ Minutes until 100%, or None if burn rate is zero or negative
102
+ """
103
+ if burn_rate_per_hour <= 0:
104
+ return None
105
+
106
+ remaining = 100.0 - (current_utilization * 100)
107
+ if remaining <= 0:
108
+ return 0
109
+
110
+ hours_to_empty = remaining / burn_rate_per_hour
111
+ return int(hours_to_empty * 60)
112
+
113
+
114
+ def classify_burn_trend(burn_rate_per_hour: float) -> str:
115
+ """Classify burn rate into human-readable trend.
116
+
117
+ Args:
118
+ burn_rate_per_hour: Burn rate in percentage points per hour
119
+
120
+ Returns:
121
+ One of: "low", "moderate", "high", "critical"
122
+ """
123
+ if burn_rate_per_hour < 5:
124
+ return "low"
125
+ elif burn_rate_per_hour < 15:
126
+ return "moderate"
127
+ elif burn_rate_per_hour < 30:
128
+ return "high"
129
+ else:
130
+ return "critical"
131
+
132
+
133
+ def get_recommendation(utilization: float, budget_pace: float) -> str:
134
+ """Get recommendation based on utilization and budget pace.
135
+
136
+ Args:
137
+ utilization: Current utilization (0-1)
138
+ budget_pace: How much of window has elapsed (0-1)
139
+
140
+ Returns:
141
+ One of: "plenty_available", "on_track", "moderate_pace", "conserve", "critical"
142
+ """
143
+ if utilization > 0.9:
144
+ return "critical"
145
+ elif utilization > 0.75:
146
+ return "conserve"
147
+ elif utilization > 0.5:
148
+ if utilization <= budget_pace:
149
+ return "on_track"
150
+ return "moderate_pace"
151
+ else:
152
+ return "plenty_available"
153
+
154
+
155
+ def get_status(utilization: float, budget_pace: float) -> str:
156
+ """Get pace status.
157
+
158
+ Args:
159
+ utilization: Current utilization (0-1)
160
+ budget_pace: How much of window has elapsed (0-1)
161
+
162
+ Returns:
163
+ One of: "ahead_of_pace", "on_pace", "behind_pace"
164
+ """
165
+ # "ahead" means using more than expected (bad)
166
+ # "behind" means using less than expected (good)
167
+ diff = utilization - budget_pace
168
+
169
+ if abs(diff) < 0.05: # Within 5% tolerance
170
+ return "on_pace"
171
+ elif diff > 0:
172
+ return "ahead_of_pace"
173
+ else:
174
+ return "behind_pace"
175
+
176
+
177
+ def calculate_burn_metrics(
178
+ limit_data: LimitData,
179
+ snapshots: list[UsageSnapshot],
180
+ window_minutes: int = 5,
181
+ ) -> BurnMetrics:
182
+ """Calculate all burn metrics for a limit.
183
+
184
+ Args:
185
+ limit_data: Current limit data
186
+ snapshots: Historical snapshots for burn rate calculation
187
+ window_minutes: How far back to look for burn rate
188
+
189
+ Returns:
190
+ BurnMetrics with all calculated values
191
+ """
192
+ 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)
194
+ time_to_empty = estimate_time_to_empty(limit_data.utilization, burn_rate)
195
+ trend = classify_burn_trend(burn_rate)
196
+ status = get_status(limit_data.utilization, budget_pace)
197
+ recommendation = get_recommendation(limit_data.utilization, budget_pace)
198
+
199
+ return BurnMetrics(
200
+ limit_type=limit_data.limit_type,
201
+ percent_per_hour=burn_rate,
202
+ trend=trend,
203
+ estimated_minutes_to_100=time_to_empty,
204
+ budget_pace=budget_pace,
205
+ status=status,
206
+ recommendation=recommendation,
207
+ )