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.
- ccburn/__init__.py +8 -0
- ccburn/app.py +465 -0
- ccburn/cli.py +285 -0
- ccburn/data/__init__.py +24 -0
- ccburn/data/credentials.py +135 -0
- ccburn/data/history.py +397 -0
- ccburn/data/models.py +148 -0
- ccburn/data/usage_client.py +141 -0
- ccburn/display/__init__.py +17 -0
- ccburn/display/chart.py +300 -0
- ccburn/display/gauges.py +275 -0
- ccburn/display/layout.py +246 -0
- ccburn/main.py +98 -0
- ccburn/utils/__init__.py +45 -0
- ccburn/utils/calculator.py +207 -0
- ccburn/utils/formatting.py +127 -0
- ccburn-0.1.0.dist-info/METADATA +197 -0
- ccburn-0.1.0.dist-info/RECORD +22 -0
- ccburn-0.1.0.dist-info/WHEEL +5 -0
- ccburn-0.1.0.dist-info/entry_points.txt +2 -0
- ccburn-0.1.0.dist-info/licenses/LICENSE +21 -0
- ccburn-0.1.0.dist-info/top_level.txt +1 -0
ccburn/display/chart.py
ADDED
|
@@ -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)
|
ccburn/display/gauges.py
ADDED
|
@@ -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)
|