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,300 @@
1
+ """Plotext burnup chart with Rich integration."""
2
+
3
+ from collections.abc import Generator
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ import plotext as plt
7
+ from rich.ansi import AnsiDecoder
8
+ from rich.console import Console, ConsoleOptions, Group, RenderableType
9
+ from rich.jupyter import JupyterMixin
10
+
11
+ try:
12
+ from ..data.models import LimitData, UsageSnapshot
13
+ except ImportError:
14
+ from ccburn.data.models import LimitData, UsageSnapshot
15
+
16
+
17
+ class BurnupChart(JupyterMixin):
18
+ """Rich-compatible burnup chart using plotext."""
19
+
20
+ def __init__(
21
+ self,
22
+ limit_data: LimitData | None,
23
+ snapshots: list[UsageSnapshot],
24
+ since: datetime | None = None,
25
+ explicit_height: int | None = None,
26
+ ):
27
+ """Initialize the burnup chart.
28
+
29
+ Args:
30
+ limit_data: Current limit data (for window boundaries)
31
+ snapshots: Historical snapshots to plot
32
+ since: Only show data since this time (zoom view)
33
+ explicit_height: Override chart height
34
+ """
35
+ self.limit_data = limit_data
36
+ self.snapshots = snapshots
37
+ self.since = since
38
+ self.explicit_height = explicit_height
39
+ self.decoder = AnsiDecoder()
40
+
41
+ def __rich_console__(
42
+ self, console: Console, options: ConsoleOptions
43
+ ) -> Generator[RenderableType, None, None]:
44
+ """Render the plotext chart for Rich console."""
45
+ width = options.max_width or console.width
46
+ height = self.explicit_height or options.height or 15
47
+
48
+ chart_str = self._create_chart(width, height)
49
+
50
+ # Decode ANSI and render using AnsiDecoder
51
+ rich_canvas = Group(*self.decoder.decode(chart_str))
52
+ yield rich_canvas
53
+
54
+ def _create_chart(self, width: int, height: int) -> str:
55
+ """Create the plotext chart.
56
+
57
+ Args:
58
+ width: Chart width in characters
59
+ height: Chart height in lines
60
+
61
+ Returns:
62
+ Rendered chart as string with ANSI codes
63
+ """
64
+ plt.clear_figure()
65
+ plt.clear_data()
66
+ plt.clear_color()
67
+
68
+ # Disable size limiter for larger charts
69
+ plt.limit_size(False, False)
70
+
71
+ # Configure plot size - use full available space
72
+ chart_width = max(width, 40)
73
+ chart_height = max(height, 8)
74
+ plt.plotsize(chart_width, chart_height)
75
+
76
+ if self.limit_data is None:
77
+ # No data - show empty chart
78
+ plt.title("No data available")
79
+ return plt.build()
80
+
81
+ # Determine time window - keep original window for pace calculation
82
+ original_window_start = self.limit_data.window_start
83
+ original_window_hours = self.limit_data.window_hours
84
+ window_end = self.limit_data.resets_at
85
+ now = datetime.now(timezone.utc)
86
+
87
+ # Display window (may be zoomed with --since)
88
+ display_start = original_window_start
89
+ display_end = window_end
90
+ if self.since:
91
+ display_start = max(original_window_start, self.since)
92
+ display_end = now # When zoomed, show until now, not session end
93
+
94
+ # Filter snapshots to display window
95
+ relevant_snapshots = [
96
+ s for s in self.snapshots
97
+ if display_start <= s.timestamp <= now
98
+ ]
99
+
100
+ # Convert timestamps to relative hours from display start
101
+ def to_hours(dt: datetime) -> float:
102
+ return (dt - display_start).total_seconds() / 3600
103
+
104
+ display_hours = (display_end - display_start).total_seconds() / 3600
105
+
106
+ # Plot budget pace line - shows expected utilization at each point
107
+ # Based on ORIGINAL window, not zoomed view
108
+ num_points = 50
109
+ pace_x = []
110
+ pace_y = []
111
+ for i in range(num_points):
112
+ # X position in display coordinates
113
+ x_hours = i * display_hours / (num_points - 1)
114
+ pace_x.append(x_hours)
115
+ # Calculate actual time at this point
116
+ point_time = display_start + timedelta(hours=x_hours)
117
+ # Budget pace = how far through the ORIGINAL window we are
118
+ elapsed_in_original = (point_time - original_window_start).total_seconds() / 3600
119
+ pace_pct = (elapsed_in_original / original_window_hours) * 100
120
+ pace_y.append(min(pace_pct, 100.0)) # Cap at 100%
121
+ plt.plot(
122
+ pace_x,
123
+ pace_y,
124
+ color=(100, 100, 100), # Dim gray RGB
125
+ marker="braille",
126
+ label="Budget Pace",
127
+ )
128
+
129
+ # Plot actual usage if we have data
130
+ values = []
131
+ if relevant_snapshots:
132
+ times = []
133
+
134
+ for s in relevant_snapshots:
135
+ limit = s.get_limit(self.limit_data.limit_type)
136
+ if limit:
137
+ # Cap utilization to valid range (handles bad historical data)
138
+ util_pct = min(limit.utilization * 100, 100.0)
139
+ # Skip obviously bad data (utilization > 1.0 means unnormalized)
140
+ if limit.utilization > 1.0:
141
+ continue
142
+ times.append(to_hours(s.timestamp))
143
+ values.append(util_pct)
144
+
145
+ if times:
146
+ # Determine line color based on current utilization
147
+ color = self._get_plotext_color(self.limit_data.utilization)
148
+ # Use fillx=True for area chart effect (fills down to x-axis)
149
+ plt.plot(
150
+ times,
151
+ values,
152
+ color=color,
153
+ marker="braille",
154
+ fillx=True,
155
+ label="Usage",
156
+ )
157
+
158
+ # Configure axes
159
+ plt.xlim(0, display_hours)
160
+
161
+ # Y-axis: dynamic when zoomed (--since), fixed 0-100 otherwise
162
+ if self.since and values:
163
+ # Calculate dynamic range from data with padding
164
+ all_y_values = values + pace_y
165
+ data_min = min(all_y_values)
166
+ data_max = max(all_y_values)
167
+ # Add 10% padding for readability
168
+ padding = (data_max - data_min) * 0.1
169
+ y_min = max(0, data_min - padding)
170
+ y_max = min(100, data_max + padding)
171
+ # Ensure at least 10% range for visibility
172
+ if y_max - y_min < 10:
173
+ mid = (y_min + y_max) / 2
174
+ y_min = max(0, mid - 5)
175
+ y_max = min(100, mid + 5)
176
+ else:
177
+ y_min, y_max = 0, 100
178
+
179
+ plt.ylim(y_min, y_max)
180
+
181
+ # Add "now" vertical line when showing full window (not zoomed)
182
+ # Use dotted effect by plotting points at intervals
183
+ now_hours_for_tick = None
184
+ if not self.since:
185
+ now_hours = to_hours(now)
186
+ if 0 < now_hours < display_hours:
187
+ # Create dotted vertical line with points using braille marker to match other lines
188
+ num_dots = 20
189
+ dot_y = [y_min + i * (y_max - y_min) / (num_dots - 1) for i in range(num_dots)]
190
+ dot_x = [now_hours] * num_dots
191
+ plt.plot(dot_x, dot_y, color=(0, 120, 255), marker="braille", label="Now")
192
+ now_hours_for_tick = now_hours
193
+
194
+ # Enable right Y axis with same range
195
+ plt.plot([display_hours], [y_max], marker=" ", yside="right") # Hidden point to enable right axis
196
+ plt.ylim(y_min, y_max, yside="right")
197
+
198
+ # Generate x-axis ticks with actual timestamps in local timezone
199
+ num_ticks = 5
200
+ tick_positions = []
201
+ tick_labels = []
202
+ # Use date format for windows > 24 hours
203
+ use_date_format = display_hours > 24
204
+ for i in range(num_ticks):
205
+ hours = i * display_hours / (num_ticks - 1)
206
+ tick_positions.append(hours)
207
+ # Convert to actual time in local timezone
208
+ tick_time = display_start + timedelta(hours=hours)
209
+ local_time = tick_time.astimezone() # Convert to local timezone
210
+ # Round to nearest minute for stable display
211
+ if local_time.second >= 30:
212
+ local_time = local_time.replace(second=0, microsecond=0) + timedelta(minutes=1)
213
+ else:
214
+ local_time = local_time.replace(second=0, microsecond=0)
215
+ # Format based on window size
216
+ if use_date_format:
217
+ # Show day and time for multi-day windows (e.g., "Mon 15h")
218
+ tick_labels.append(local_time.strftime("%a %Hh"))
219
+ else:
220
+ # Show just time for short windows (e.g., "15:59")
221
+ tick_labels.append(local_time.strftime("%H:%M"))
222
+
223
+ # Add "Now" tick if showing the now line (show actual time)
224
+ # Remove any existing ticks that are too close to avoid overlap/flickering
225
+ if now_hours_for_tick is not None:
226
+ min_distance = display_hours / 10 # Minimum 10% of display width apart
227
+ # Filter out ticks that are too close to "Now"
228
+ filtered = [(pos, label) for pos, label in zip(tick_positions, tick_labels, strict=True)
229
+ if abs(now_hours_for_tick - pos) >= min_distance]
230
+ tick_positions = [pos for pos, _ in filtered]
231
+ tick_labels = [label for _, label in filtered]
232
+ # Add the "Now" tick
233
+ tick_positions.append(now_hours_for_tick)
234
+ local_now = now.astimezone()
235
+ # Round to nearest minute
236
+ if local_now.second >= 30:
237
+ local_now = local_now.replace(second=0, microsecond=0) + timedelta(minutes=1)
238
+ else:
239
+ local_now = local_now.replace(second=0, microsecond=0)
240
+ if use_date_format:
241
+ tick_labels.append(local_now.strftime("%a %Hh"))
242
+ else:
243
+ tick_labels.append(local_now.strftime("%H:%M"))
244
+
245
+ plt.xticks(tick_positions, tick_labels)
246
+
247
+ # No labels on axes
248
+ plt.xlabel("")
249
+ plt.ylabel("")
250
+
251
+ # Theme and styling
252
+ plt.theme("dark")
253
+
254
+ # Use transparent/default background to match terminal
255
+ plt.canvas_color("default")
256
+ plt.axes_color("default")
257
+ plt.ticks_color((100, 100, 100)) # Dim gray for ticks/border
258
+
259
+ return plt.build()
260
+
261
+ def _get_plotext_color(self, utilization: float) -> tuple[int, int, int]:
262
+ """Get plotext RGB color based on utilization.
263
+
264
+ Args:
265
+ utilization: Current utilization (0-1)
266
+
267
+ Returns:
268
+ RGB tuple for plotext - bright vivid colors matching Rich progress bars
269
+ """
270
+ if utilization < 0.5:
271
+ return (0, 255, 0) # Bright green
272
+ elif utilization < 0.75:
273
+ return (255, 255, 0) # Bright yellow
274
+ elif utilization < 0.9:
275
+ return (255, 165, 0) # Orange
276
+ else:
277
+ return (255, 0, 0) # Bright red
278
+
279
+
280
+ def create_simple_chart(
281
+ limit_data: LimitData | None,
282
+ snapshots: list[UsageSnapshot],
283
+ width: int = 80,
284
+ height: int = 15,
285
+ since: datetime | None = None,
286
+ ) -> str:
287
+ """Create a simple chart string without Rich integration.
288
+
289
+ Args:
290
+ limit_data: Current limit data
291
+ snapshots: Historical snapshots
292
+ width: Chart width
293
+ height: Chart height
294
+ since: Only show data since this time
295
+
296
+ Returns:
297
+ Rendered chart string
298
+ """
299
+ chart = BurnupChart(limit_data, snapshots, since=since, explicit_height=height)
300
+ return chart._create_chart(width, height)
@@ -0,0 +1,275 @@
1
+ """Progress bar gauges for ccburn TUI."""
2
+
3
+ from rich.progress import ProgressBar
4
+ from rich.style import Style
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ try:
9
+ from ..data.models import LimitData, LimitType
10
+ from ..utils.calculator import calculate_budget_pace
11
+ from ..utils.formatting import format_reset_time, get_utilization_color
12
+ except ImportError:
13
+ from ccburn.data.models import LimitData, LimitType
14
+ from ccburn.utils.calculator import calculate_budget_pace
15
+ from ccburn.utils.formatting import format_reset_time, get_utilization_color
16
+
17
+
18
+ def get_pace_emoji(utilization: float, budget_pace: float) -> str:
19
+ """Get emoji indicator based on utilization vs budget pace.
20
+
21
+ Args:
22
+ utilization: Current utilization (0-1)
23
+ budget_pace: Expected budget pace (0-1)
24
+
25
+ Returns:
26
+ Emoji: 🧊 (behind), 🔥 (on pace), 🚨 (ahead)
27
+ """
28
+ if budget_pace == 0:
29
+ return "🔥"
30
+
31
+ ratio = utilization / budget_pace
32
+ if ratio < 0.85:
33
+ return "🧊" # Behind pace - ice cold, under budget
34
+ elif ratio > 1.15:
35
+ return "🚨" # Ahead of pace - alarm!
36
+ else:
37
+ return "🔥" # On pace - normal burn
38
+
39
+
40
+ def create_header(limit_type: LimitType, limit_data: LimitData | None) -> Table:
41
+ """Create the header line with limit name and reset countdown.
42
+
43
+ Args:
44
+ limit_type: Which limit is being displayed
45
+ limit_data: Current limit data (for reset time)
46
+
47
+ Returns:
48
+ Rich Table formatted as a single header line
49
+ """
50
+ table = Table.grid(padding=0, expand=True)
51
+ table.add_column(justify="left", ratio=1)
52
+ table.add_column(justify="right", ratio=1)
53
+
54
+ # Dynamic pace emoji: 🧊 (behind), 🔥 (on pace), 🚨 (ahead)
55
+ if limit_data:
56
+ pace = calculate_budget_pace(limit_data.resets_at, limit_data.window_hours)
57
+ pace_emoji = get_pace_emoji(limit_data.utilization, pace)
58
+ else:
59
+ pace_emoji = "🔥" # Default while loading
60
+
61
+ title = Text()
62
+ title.append(f"{pace_emoji} ", style="")
63
+ title.append("ccburn", style="bold magenta")
64
+ title.append(" - ", style="dim")
65
+ title.append(limit_type.display_name, style="bold cyan")
66
+
67
+ if limit_data:
68
+ reset_text = Text()
69
+ reset_text.append("⏰ ", style="")
70
+ reset_text.append(format_reset_time(limit_data.resets_at), style="yellow")
71
+ else:
72
+ reset_text = Text("⏳ Loading...", style="dim")
73
+
74
+ table.add_row(title, reset_text)
75
+ return table
76
+
77
+
78
+ def create_gauge_section(
79
+ limit_data: LimitData | None,
80
+ budget_pace: float,
81
+ ) -> Table:
82
+ """Create the 2-bar gauge section for a limit.
83
+
84
+ Args:
85
+ limit_data: Current limit data
86
+ budget_pace: Percentage of window elapsed (0-1)
87
+
88
+ Returns:
89
+ Rich Table with Usage and Time Elapsed bars
90
+ """
91
+ table = Table.grid(padding=(0, 1), expand=True)
92
+ table.add_column(width=14) # Label
93
+ table.add_column(ratio=1) # Bar
94
+ table.add_column(width=8, justify="right") # Value
95
+
96
+ if limit_data is None:
97
+ # Show empty/loading state
98
+ table.add_row(
99
+ Text("📊 Usage", style="dim"),
100
+ ProgressBar(total=100, completed=0, style=Style(color="dim")),
101
+ Text("--%", style="dim"),
102
+ )
103
+ table.add_row(
104
+ Text("⏳ Elapsed", style="dim"),
105
+ ProgressBar(total=100, completed=0, style=Style(color="dim")),
106
+ Text("--%", style="dim"),
107
+ )
108
+ return table
109
+
110
+ utilization_percent = limit_data.utilization * 100
111
+ pace_percent = budget_pace * 100
112
+
113
+ # Usage bar - color by threshold
114
+ # complete_style = filled portion (bright), style = unfilled portion (dim)
115
+ usage_color = get_utilization_color(limit_data.utilization)
116
+ usage_bar = ProgressBar(
117
+ total=100,
118
+ completed=utilization_percent,
119
+ style=Style(color="grey37"), # Dim gray for unfilled
120
+ complete_style=Style(color=usage_color), # Bright color for filled
121
+ )
122
+
123
+ # Usage label with emoji based on status
124
+ usage_emoji = "📊"
125
+ if limit_data.utilization >= 0.9:
126
+ usage_emoji = "🚨"
127
+ elif limit_data.utilization >= 0.75:
128
+ usage_emoji = "⚠️"
129
+
130
+ usage_label = Text()
131
+ usage_label.append(f"{usage_emoji} ", style="")
132
+ usage_label.append("Usage", style=f"bold {usage_color}")
133
+
134
+ table.add_row(
135
+ usage_label,
136
+ usage_bar,
137
+ Text(f"{utilization_percent:.0f}%", style=usage_color),
138
+ )
139
+
140
+ # Time Elapsed bar - always blue
141
+ # complete_style = filled portion (bright blue), style = unfilled portion (dim)
142
+ elapsed_bar = ProgressBar(
143
+ total=100,
144
+ completed=pace_percent,
145
+ style=Style(color="grey37"), # Dim gray for unfilled
146
+ complete_style=Style(color="blue"), # Bright blue for filled
147
+ )
148
+
149
+ time_label = Text()
150
+ time_label.append("⏳ ", style="") # Use hourglass emoji (consistent width)
151
+ time_label.append("Elapsed", style="bold blue")
152
+
153
+ table.add_row(
154
+ time_label,
155
+ elapsed_bar,
156
+ Text(f"{pace_percent:.0f}%", style="blue"),
157
+ )
158
+
159
+ return table
160
+
161
+
162
+ def create_compact_output(
163
+ session: LimitData | None,
164
+ weekly: LimitData | None,
165
+ weekly_sonnet: LimitData | None,
166
+ budget_pace_session: float,
167
+ ) -> str:
168
+ """Create compact single-line output for status bars.
169
+
170
+ Format: Session: 🧊 62% (2h14m) | Weekly: 🔥 29% | Sonnet: 🧊 1%
171
+ Emojis indicate pace: 🧊 (behind), 🔥 (on pace), 🚨 (ahead)
172
+
173
+ Args:
174
+ session: Session limit data
175
+ weekly: Weekly limit data
176
+ weekly_sonnet: Weekly sonnet limit data
177
+ budget_pace_session: Budget pace for session (for status indicator)
178
+
179
+ Returns:
180
+ Single-line string for status bar
181
+ """
182
+ from ..utils.formatting import format_duration
183
+
184
+ parts = []
185
+
186
+ # Session with time remaining
187
+ if session:
188
+ from datetime import datetime, timezone
189
+
190
+ now = datetime.now(timezone.utc)
191
+ minutes_left = int((session.resets_at - now).total_seconds() / 60)
192
+ time_str = f"({format_duration(minutes_left)})" if minutes_left > 0 else ""
193
+ pace = calculate_budget_pace(session.resets_at, session.window_hours)
194
+ emoji = get_pace_emoji(session.utilization, pace)
195
+ parts.append(f"Session: {emoji} {session.utilization*100:.0f}% {time_str}".strip())
196
+ else:
197
+ parts.append("Session: --")
198
+
199
+ # Weekly
200
+ if weekly:
201
+ pace = calculate_budget_pace(weekly.resets_at, weekly.window_hours)
202
+ emoji = get_pace_emoji(weekly.utilization, pace)
203
+ parts.append(f"Weekly: {emoji} {weekly.utilization*100:.0f}%")
204
+ else:
205
+ parts.append("Weekly: --")
206
+
207
+ # Sonnet
208
+ if weekly_sonnet:
209
+ pace = calculate_budget_pace(weekly_sonnet.resets_at, weekly_sonnet.window_hours)
210
+ emoji = get_pace_emoji(weekly_sonnet.utilization, pace)
211
+ parts.append(f"Sonnet: {emoji} {weekly_sonnet.utilization*100:.0f}%")
212
+ else:
213
+ parts.append("Sonnet: --")
214
+
215
+ return " | ".join(parts)
216
+
217
+
218
+ def create_compact_output_with_indicator(
219
+ session: LimitData | None,
220
+ weekly: LimitData | None,
221
+ weekly_sonnet: LimitData | None,
222
+ budget_pace_session: float,
223
+ ) -> str:
224
+ """Create compact output with status indicator.
225
+
226
+ Format: [!] 62% (2h14m) | 29% | 1%
227
+
228
+ Args:
229
+ session: Session limit data
230
+ weekly: Weekly limit data
231
+ weekly_sonnet: Weekly sonnet limit data
232
+ budget_pace_session: Budget pace for session (for status indicator)
233
+
234
+ Returns:
235
+ Single-line string with status indicator
236
+ """
237
+ from ..utils.formatting import format_duration, get_status_indicator
238
+
239
+ # Determine which limit is most critical
240
+ max_util = 0.0
241
+ if session:
242
+ max_util = max(max_util, session.utilization)
243
+ if weekly:
244
+ max_util = max(max_util, weekly.utilization)
245
+ if weekly_sonnet:
246
+ max_util = max(max_util, weekly_sonnet.utilization)
247
+
248
+ indicator = get_status_indicator(max_util, budget_pace_session)
249
+
250
+ parts = [indicator]
251
+
252
+ # Session with time remaining
253
+ if session:
254
+ from datetime import datetime, timezone
255
+
256
+ now = datetime.now(timezone.utc)
257
+ minutes_left = int((session.resets_at - now).total_seconds() / 60)
258
+ time_str = f"({format_duration(minutes_left)})" if minutes_left > 0 else ""
259
+ parts.append(f"{session.utilization*100:.0f}% {time_str}".strip())
260
+ else:
261
+ parts.append("--")
262
+
263
+ # Weekly
264
+ if weekly:
265
+ parts.append(f"{weekly.utilization*100:.0f}%")
266
+ else:
267
+ parts.append("--")
268
+
269
+ # Sonnet
270
+ if weekly_sonnet:
271
+ parts.append(f"{weekly_sonnet.utilization*100:.0f}%")
272
+ else:
273
+ parts.append("--")
274
+
275
+ return " | ".join(parts)