claude-code-usage 1.0.3__py3-none-any.whl → 2.0.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.
- {claude_code_usage-1.0.3.dist-info → claude_code_usage-2.0.0.dist-info}/METADATA +18 -8
- claude_code_usage-2.0.0.dist-info/RECORD +15 -0
- {claude_code_usage-1.0.3.dist-info → claude_code_usage-2.0.0.dist-info}/WHEEL +1 -1
- claude_code_usage-2.0.0.dist-info/entry_points.txt +5 -0
- src/autostart.py +268 -34
- src/config.py +34 -16
- src/icon_generator.py +66 -58
- src/notifier.py +95 -82
- src/tray.py +127 -198
- claude_code_usage-1.0.3.dist-info/RECORD +0 -15
- claude_code_usage-1.0.3.dist-info/entry_points.txt +0 -2
- {claude_code_usage-1.0.3.dist-info/licenses → claude_code_usage-2.0.0.dist-info}/LICENSE +0 -0
- {claude_code_usage-1.0.3.dist-info → claude_code_usage-2.0.0.dist-info}/top_level.txt +0 -0
src/icon_generator.py
CHANGED
|
@@ -1,55 +1,36 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Icon generation utilities for Claude Code usage overlay.
|
|
3
3
|
|
|
4
|
-
Generates circular gauge icons using
|
|
4
|
+
Generates circular gauge icons using Pillow for system tray display.
|
|
5
|
+
Cross-platform: works on Windows, macOS, and Linux.
|
|
5
6
|
"""
|
|
6
|
-
import cairo
|
|
7
|
-
import math
|
|
8
|
-
import os
|
|
9
|
-
import tempfile
|
|
10
|
-
from pathlib import Path
|
|
11
7
|
from typing import Tuple
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
_icon_temp_dir: str | None = None
|
|
9
|
+
from PIL import Image, ImageDraw
|
|
15
10
|
|
|
16
11
|
|
|
17
|
-
def
|
|
18
|
-
"""Get or create a secure temporary directory for icon files.
|
|
19
|
-
|
|
20
|
-
Returns:
|
|
21
|
-
Path to secure temp directory with restricted permissions (0o700)
|
|
22
|
-
"""
|
|
23
|
-
global _icon_temp_dir
|
|
24
|
-
if _icon_temp_dir is None or not Path(_icon_temp_dir).exists():
|
|
25
|
-
# Create secure temp directory owned by current user only
|
|
26
|
-
_icon_temp_dir = tempfile.mkdtemp(prefix="claude-usage-icons-")
|
|
27
|
-
os.chmod(_icon_temp_dir, 0o700)
|
|
28
|
-
return _icon_temp_dir
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def get_color_for_percentage(percentage: float) -> Tuple[float, float, float]:
|
|
12
|
+
def get_color_for_percentage(percentage: float) -> Tuple[int, int, int, int]:
|
|
32
13
|
"""
|
|
33
|
-
Return
|
|
14
|
+
Return RGBA color tuple based on usage percentage.
|
|
34
15
|
|
|
35
16
|
Args:
|
|
36
17
|
percentage: Usage percentage (0-100)
|
|
37
18
|
|
|
38
19
|
Returns:
|
|
39
|
-
|
|
20
|
+
RGBA tuple in Pillow format (0-255 range)
|
|
40
21
|
- Green for < 50%
|
|
41
22
|
- Yellow for 50-75%
|
|
42
23
|
- Red for >= 75%
|
|
43
24
|
"""
|
|
44
25
|
if percentage < 50:
|
|
45
|
-
return (
|
|
26
|
+
return (51, 204, 51, 255) # Green
|
|
46
27
|
elif percentage < 75:
|
|
47
|
-
return (
|
|
28
|
+
return (230, 179, 0, 255) # Yellow
|
|
48
29
|
else:
|
|
49
|
-
return (
|
|
30
|
+
return (230, 51, 51, 255) # Red
|
|
50
31
|
|
|
51
32
|
|
|
52
|
-
def generate_gauge_icon(percentage: float, color_percentage: float, size: int = 22) ->
|
|
33
|
+
def generate_gauge_icon(percentage: float, color_percentage: float, size: int = 22) -> Image.Image:
|
|
53
34
|
"""
|
|
54
35
|
Generate a circular gauge icon showing usage percentage.
|
|
55
36
|
|
|
@@ -59,7 +40,7 @@ def generate_gauge_icon(percentage: float, color_percentage: float, size: int =
|
|
|
59
40
|
size: Icon size in pixels (default: 22)
|
|
60
41
|
|
|
61
42
|
Returns:
|
|
62
|
-
|
|
43
|
+
PIL.Image.Image object (RGBA mode) for use with pystray
|
|
63
44
|
|
|
64
45
|
Notes:
|
|
65
46
|
- percentage controls the arc fill amount (how full the gauge is)
|
|
@@ -68,6 +49,53 @@ def generate_gauge_icon(percentage: float, color_percentage: float, size: int =
|
|
|
68
49
|
"""
|
|
69
50
|
# Get color based on color_percentage parameter
|
|
70
51
|
fill_color = get_color_for_percentage(color_percentage)
|
|
52
|
+
gray_color = (128, 128, 128, 255)
|
|
53
|
+
|
|
54
|
+
# Create transparent RGBA image
|
|
55
|
+
image = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
|
56
|
+
draw = ImageDraw.Draw(image)
|
|
57
|
+
|
|
58
|
+
# Calculate center and radius (leave 2px margin)
|
|
59
|
+
margin = 2
|
|
60
|
+
line_width = 2
|
|
61
|
+
|
|
62
|
+
# Bounding box for the arc (accounts for margin)
|
|
63
|
+
bbox = [margin, margin, size - margin - 1, size - margin - 1]
|
|
64
|
+
|
|
65
|
+
# Draw gray outline (full circle) as arc from 0 to 360
|
|
66
|
+
draw.arc(bbox, start=0, end=360, fill=gray_color, width=line_width)
|
|
67
|
+
|
|
68
|
+
# Draw colored fill arc (based on percentage)
|
|
69
|
+
if percentage > 0:
|
|
70
|
+
# Pillow arc: 0 degrees = 3 o'clock, angles go counter-clockwise
|
|
71
|
+
# We want: start at 12 o'clock (-90 degrees), go clockwise
|
|
72
|
+
# Pillow arc goes counter-clockwise, so we need to reverse direction
|
|
73
|
+
# Start at -90 (12 o'clock), end at -90 + percentage*3.6
|
|
74
|
+
start_angle = -90
|
|
75
|
+
end_angle = -90 + (360 * percentage / 100)
|
|
76
|
+
draw.arc(bbox, start=start_angle, end=end_angle, fill=fill_color, width=line_width)
|
|
77
|
+
|
|
78
|
+
return image
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def generate_gauge_icon_path(percentage: float, color_percentage: float, size: int = 22) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Generate a circular gauge icon and save to temp file.
|
|
84
|
+
|
|
85
|
+
This function is for backwards compatibility with code that needs file paths.
|
|
86
|
+
Prefer using generate_gauge_icon() directly with pystray.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
percentage: Usage level to display as arc fill (0-100)
|
|
90
|
+
color_percentage: Percentage used to determine color (0-100)
|
|
91
|
+
size: Icon size in pixels (default: 22)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Absolute path to generated PNG file in temp directory
|
|
95
|
+
"""
|
|
96
|
+
import os
|
|
97
|
+
import tempfile
|
|
98
|
+
from pathlib import Path
|
|
71
99
|
|
|
72
100
|
# Determine color name for filename (to bust icon cache)
|
|
73
101
|
if color_percentage < 50:
|
|
@@ -77,36 +105,16 @@ def generate_gauge_icon(percentage: float, color_percentage: float, size: int =
|
|
|
77
105
|
else:
|
|
78
106
|
color_name = "red"
|
|
79
107
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
108
|
+
# Get or create temp directory
|
|
109
|
+
temp_dir = tempfile.gettempdir()
|
|
110
|
+
icon_dir = os.path.join(temp_dir, "claude-usage-icons")
|
|
111
|
+
os.makedirs(icon_dir, exist_ok=True)
|
|
83
112
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
ctx.paint()
|
|
87
|
-
|
|
88
|
-
# Calculate center and radius (leave 2px margin)
|
|
89
|
-
center = size / 2
|
|
90
|
-
radius = (size / 2) - 2
|
|
113
|
+
# Generate icon
|
|
114
|
+
image = generate_gauge_icon(percentage, color_percentage, size)
|
|
91
115
|
|
|
92
|
-
#
|
|
93
|
-
ctx.set_source_rgb(0.5, 0.5, 0.5)
|
|
94
|
-
ctx.set_line_width(2)
|
|
95
|
-
ctx.arc(center, center, radius, 0, 2 * math.pi)
|
|
96
|
-
ctx.stroke()
|
|
97
|
-
|
|
98
|
-
# Draw colored fill arc (based on percentage)
|
|
99
|
-
if percentage > 0:
|
|
100
|
-
ctx.set_source_rgb(*fill_color)
|
|
101
|
-
ctx.set_line_width(2)
|
|
102
|
-
# Start at 12 o'clock (-pi/2), go clockwise by percentage amount
|
|
103
|
-
end_angle = -math.pi / 2 + (2 * math.pi * percentage / 100)
|
|
104
|
-
ctx.arc(center, center, radius, -math.pi / 2, end_angle)
|
|
105
|
-
ctx.stroke()
|
|
106
|
-
|
|
107
|
-
# Save to secure temp directory with unique filename (bust GTK icon cache)
|
|
108
|
-
icon_dir = _get_secure_icon_dir()
|
|
116
|
+
# Save to file
|
|
109
117
|
icon_path = os.path.join(icon_dir, f"gauge-{int(percentage)}-{color_name}.png")
|
|
110
|
-
|
|
118
|
+
image.save(icon_path, 'PNG')
|
|
111
119
|
|
|
112
120
|
return os.path.abspath(icon_path)
|
src/notifier.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
"""Desktop notification manager for usage threshold alerts.
|
|
1
|
+
"""Desktop notification manager for usage threshold alerts.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Cross-platform: uses desktop-notifier for Windows, macOS, and Linux.
|
|
4
|
+
"""
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from typing import Optional
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
from gi.repository import Notify
|
|
12
|
+
from desktop_notifier import DesktopNotifier, Urgency, Button
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class UsageNotifier:
|
|
@@ -26,8 +29,11 @@ class UsageNotifier:
|
|
|
26
29
|
session_thresholds: List of threshold percentages for session (default [50, 75, 90])
|
|
27
30
|
weekly_thresholds: List of threshold percentages for weekly (default to session_thresholds)
|
|
28
31
|
"""
|
|
29
|
-
# Initialize
|
|
30
|
-
|
|
32
|
+
# Initialize desktop-notifier
|
|
33
|
+
self.notifier = DesktopNotifier(
|
|
34
|
+
app_name=app_name,
|
|
35
|
+
notification_limit=10
|
|
36
|
+
)
|
|
31
37
|
|
|
32
38
|
# Configure thresholds
|
|
33
39
|
self.session_thresholds = session_thresholds if session_thresholds is not None else [50, 75, 90]
|
|
@@ -44,8 +50,36 @@ class UsageNotifier:
|
|
|
44
50
|
# Pause notifications mode
|
|
45
51
|
self.pause_notifications = False
|
|
46
52
|
|
|
47
|
-
#
|
|
48
|
-
self.
|
|
53
|
+
# Event loop for async operations
|
|
54
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
55
|
+
|
|
56
|
+
def _get_event_loop(self) -> asyncio.AbstractEventLoop:
|
|
57
|
+
"""Get or create event loop for async operations."""
|
|
58
|
+
if self._loop is None or self._loop.is_closed():
|
|
59
|
+
try:
|
|
60
|
+
self._loop = asyncio.get_running_loop()
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
# No running loop, create new one
|
|
63
|
+
self._loop = asyncio.new_event_loop()
|
|
64
|
+
return self._loop
|
|
65
|
+
|
|
66
|
+
def _run_async(self, coro):
|
|
67
|
+
"""Run async coroutine synchronously."""
|
|
68
|
+
loop = self._get_event_loop()
|
|
69
|
+
try:
|
|
70
|
+
# Try to run in existing loop
|
|
71
|
+
if loop.is_running():
|
|
72
|
+
# Schedule in running loop (won't block)
|
|
73
|
+
asyncio.ensure_future(coro, loop=loop)
|
|
74
|
+
else:
|
|
75
|
+
loop.run_until_complete(coro)
|
|
76
|
+
except RuntimeError:
|
|
77
|
+
# Fallback: create new loop
|
|
78
|
+
new_loop = asyncio.new_event_loop()
|
|
79
|
+
try:
|
|
80
|
+
new_loop.run_until_complete(coro)
|
|
81
|
+
finally:
|
|
82
|
+
new_loop.close()
|
|
49
83
|
|
|
50
84
|
def check_and_notify(self, session_pct, weekly_pct, session_reset, weekly_reset):
|
|
51
85
|
"""Check thresholds and show notifications if needed.
|
|
@@ -126,6 +160,22 @@ class UsageNotifier:
|
|
|
126
160
|
crossed = [t for t in thresholds if percentage >= t]
|
|
127
161
|
return max(crossed) if crossed else None
|
|
128
162
|
|
|
163
|
+
def _get_urgency(self, threshold: int) -> Urgency:
|
|
164
|
+
"""Map threshold to desktop-notifier Urgency level.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
threshold: Threshold percentage (50, 75, or 90)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Urgency enum value
|
|
171
|
+
"""
|
|
172
|
+
if threshold >= 90:
|
|
173
|
+
return Urgency.Critical
|
|
174
|
+
elif threshold >= 75:
|
|
175
|
+
return Urgency.Normal
|
|
176
|
+
else:
|
|
177
|
+
return Urgency.Low
|
|
178
|
+
|
|
129
179
|
def _show_notification(self, metric, percentage, threshold, reset_time):
|
|
130
180
|
"""Show notification for single metric threshold.
|
|
131
181
|
|
|
@@ -137,12 +187,13 @@ class UsageNotifier:
|
|
|
137
187
|
"""
|
|
138
188
|
# Map threshold to urgency and advice
|
|
139
189
|
urgency_map = {
|
|
140
|
-
50:
|
|
141
|
-
75:
|
|
142
|
-
90:
|
|
190
|
+
50: "Heads up",
|
|
191
|
+
75: "Consider saving your work",
|
|
192
|
+
90: "Save your work now"
|
|
143
193
|
}
|
|
144
194
|
|
|
145
|
-
|
|
195
|
+
advice = urgency_map[threshold]
|
|
196
|
+
urgency = self._get_urgency(threshold)
|
|
146
197
|
|
|
147
198
|
# Format title
|
|
148
199
|
metric_name = "Session Usage" if metric == 'session' else "Weekly Usage"
|
|
@@ -152,27 +203,8 @@ class UsageNotifier:
|
|
|
152
203
|
reset_str = self._format_reset_time(reset_time)
|
|
153
204
|
body = f"{advice}\n\nResets in {reset_str}"
|
|
154
205
|
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
title,
|
|
158
|
-
body,
|
|
159
|
-
"dialog-information"
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
# Set urgency level
|
|
163
|
-
notification.set_urgency(urgency)
|
|
164
|
-
|
|
165
|
-
# Add action button if server supports it
|
|
166
|
-
if self._server_supports_actions():
|
|
167
|
-
notification.add_action(
|
|
168
|
-
"open-claude",
|
|
169
|
-
"Open Claude Code",
|
|
170
|
-
self._on_open_claude,
|
|
171
|
-
None
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
# Show notification
|
|
175
|
-
notification.show()
|
|
206
|
+
# Show notification asynchronously
|
|
207
|
+
self._run_async(self._send_notification(title, body, urgency))
|
|
176
208
|
|
|
177
209
|
def _show_combined_notification(self, threshold, session_pct, weekly_pct,
|
|
178
210
|
session_reset, weekly_reset):
|
|
@@ -187,12 +219,13 @@ class UsageNotifier:
|
|
|
187
219
|
"""
|
|
188
220
|
# Map threshold to urgency and advice
|
|
189
221
|
urgency_map = {
|
|
190
|
-
50:
|
|
191
|
-
75:
|
|
192
|
-
90:
|
|
222
|
+
50: "Heads up",
|
|
223
|
+
75: "Consider saving your work",
|
|
224
|
+
90: "Save your work now"
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
|
|
227
|
+
advice = urgency_map[threshold]
|
|
228
|
+
urgency = self._get_urgency(threshold)
|
|
196
229
|
|
|
197
230
|
# Format title (show threshold, not individual percentages)
|
|
198
231
|
title = f"Session & Weekly Usage: {threshold}%"
|
|
@@ -204,27 +237,32 @@ class UsageNotifier:
|
|
|
204
237
|
f"Session resets in {session_reset_str}\n"
|
|
205
238
|
f"Weekly resets in {weekly_reset_str}")
|
|
206
239
|
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
title,
|
|
210
|
-
body,
|
|
211
|
-
"dialog-information"
|
|
212
|
-
)
|
|
240
|
+
# Show notification asynchronously
|
|
241
|
+
self._run_async(self._send_notification(title, body, urgency))
|
|
213
242
|
|
|
214
|
-
|
|
215
|
-
notification.
|
|
243
|
+
async def _send_notification(self, title: str, body: str, urgency: Urgency):
|
|
244
|
+
"""Send notification via desktop-notifier.
|
|
216
245
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
246
|
+
Args:
|
|
247
|
+
title: Notification title
|
|
248
|
+
body: Notification body text
|
|
249
|
+
urgency: Urgency level
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
await self.notifier.send(
|
|
253
|
+
title=title,
|
|
254
|
+
message=body,
|
|
255
|
+
urgency=urgency,
|
|
256
|
+
buttons=[
|
|
257
|
+
Button(
|
|
258
|
+
title="Open Claude",
|
|
259
|
+
on_pressed=lambda: webbrowser.open("https://claude.ai")
|
|
260
|
+
)
|
|
261
|
+
]
|
|
224
262
|
)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
except Exception as e:
|
|
264
|
+
# Log but don't crash on notification failure
|
|
265
|
+
print(f"Notification error: {e}", file=sys.stderr)
|
|
228
266
|
|
|
229
267
|
def _format_reset_time(self, reset_timestamp):
|
|
230
268
|
"""Format Unix timestamp to human-readable time until reset.
|
|
@@ -253,31 +291,6 @@ class UsageNotifier:
|
|
|
253
291
|
minutes = remainder // 60
|
|
254
292
|
return f"{minutes}m" if minutes > 0 else "shortly"
|
|
255
293
|
|
|
256
|
-
def _server_supports_actions(self):
|
|
257
|
-
"""Check if notification server supports action buttons.
|
|
258
|
-
|
|
259
|
-
Returns:
|
|
260
|
-
bool: True if server supports actions, False otherwise
|
|
261
|
-
"""
|
|
262
|
-
if self._actions_supported is None:
|
|
263
|
-
caps = Notify.get_server_caps()
|
|
264
|
-
self._actions_supported = caps and 'actions' in caps
|
|
265
|
-
return self._actions_supported
|
|
266
|
-
|
|
267
|
-
def _on_open_claude(self, notification, action, user_data):
|
|
268
|
-
"""Callback when 'Open Claude Code' button is clicked.
|
|
269
|
-
|
|
270
|
-
Args:
|
|
271
|
-
notification: The notification that triggered the action
|
|
272
|
-
action: Action identifier string
|
|
273
|
-
user_data: User data passed to add_action (unused)
|
|
274
|
-
"""
|
|
275
|
-
# Close the notification
|
|
276
|
-
notification.close()
|
|
277
|
-
|
|
278
|
-
# Open Claude Code in browser
|
|
279
|
-
subprocess.Popen(["/usr/bin/xdg-open", "https://claude.ai"])
|
|
280
|
-
|
|
281
294
|
def set_thresholds(self, session_thresholds, weekly_thresholds=None):
|
|
282
295
|
"""Update threshold configuration.
|
|
283
296
|
|