claude-code-usage 1.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.0.dist-info/METADATA +187 -0
- claude_code_usage-1.0.0.dist-info/RECORD +15 -0
- claude_code_usage-1.0.0.dist-info/WHEEL +5 -0
- claude_code_usage-1.0.0.dist-info/entry_points.txt +5 -0
- claude_code_usage-1.0.0.dist-info/licenses/LICENSE +21 -0
- claude_code_usage-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +1 -0
- src/api.py +202 -0
- src/autostart.py +94 -0
- src/config.py +173 -0
- src/icon_generator.py +92 -0
- src/main.py +24 -0
- src/notifier.py +289 -0
- src/tray.py +302 -0
- src/utils.py +35 -0
src/tray.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System tray indicator for Claude Code usage overlay.
|
|
3
|
+
|
|
4
|
+
Displays usage data via:
|
|
5
|
+
- System tray icon with color-coded gauge (green/yellow/orange/red)
|
|
6
|
+
- Panel label showing compact status (e.g., "45%|67%")
|
|
7
|
+
- Dropdown menu with detailed usage info and reset times
|
|
8
|
+
|
|
9
|
+
Note: Hover tooltips don't work on GNOME Shell due to AppIndicator limitations.
|
|
10
|
+
See KNOWN_ISSUES.md for details.
|
|
11
|
+
"""
|
|
12
|
+
import gi
|
|
13
|
+
|
|
14
|
+
gi.require_version('AyatanaAppIndicator3', '0.1')
|
|
15
|
+
gi.require_version('Gtk', '3.0')
|
|
16
|
+
from gi.repository import AyatanaAppIndicator3 as AppIndicator3, Gtk, GLib
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from src.icon_generator import generate_gauge_icon
|
|
20
|
+
from src.utils import format_time_until
|
|
21
|
+
from src.config import get_access_token, UserConfig, get_config_path
|
|
22
|
+
from src.api import fetch_with_retry, APIError, AuthenticationError
|
|
23
|
+
from src.notifier import UsageNotifier
|
|
24
|
+
from src.autostart import create_autostart_entry, is_autostart_enabled, remove_autostart_entry
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TrayIndicator:
|
|
28
|
+
"""System tray indicator for Claude Code usage."""
|
|
29
|
+
|
|
30
|
+
APPINDICATOR_ID = 'claude-usage-overlay'
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
"""Initialize the tray indicator."""
|
|
34
|
+
self.usage_data = None
|
|
35
|
+
self.indicator = None
|
|
36
|
+
self.menu = None
|
|
37
|
+
self.timer_id = None
|
|
38
|
+
|
|
39
|
+
# Load configuration
|
|
40
|
+
self.config = UserConfig.load()
|
|
41
|
+
|
|
42
|
+
# Initialize notifier with both threshold lists
|
|
43
|
+
self.notifier = UsageNotifier(
|
|
44
|
+
session_thresholds=self.config.session_thresholds,
|
|
45
|
+
weekly_thresholds=self.config.weekly_thresholds
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self._setup_indicator()
|
|
49
|
+
|
|
50
|
+
# Defer initial update and timer start until GTK main loop is running
|
|
51
|
+
# This ensures the UI is fully initialized before we update it
|
|
52
|
+
GLib.idle_add(self._initial_update)
|
|
53
|
+
|
|
54
|
+
def _setup_indicator(self):
|
|
55
|
+
"""Set up the AppIndicator with initial icon and menu."""
|
|
56
|
+
# Create initial icon at 0% fill with green color (both 0%)
|
|
57
|
+
initial_icon = generate_gauge_icon(0, 0)
|
|
58
|
+
|
|
59
|
+
# Create AppIndicator
|
|
60
|
+
self.indicator = AppIndicator3.Indicator.new(
|
|
61
|
+
self.APPINDICATOR_ID,
|
|
62
|
+
initial_icon,
|
|
63
|
+
AppIndicator3.IndicatorCategory.APPLICATION_STATUS
|
|
64
|
+
)
|
|
65
|
+
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
|
66
|
+
|
|
67
|
+
# Set initial label while loading
|
|
68
|
+
self.indicator.set_label("...", "100%|100%")
|
|
69
|
+
|
|
70
|
+
# Build initial menu with loading message
|
|
71
|
+
menu = Gtk.Menu()
|
|
72
|
+
loading_item = Gtk.MenuItem(label="Loading...")
|
|
73
|
+
loading_item.set_sensitive(False)
|
|
74
|
+
menu.append(loading_item)
|
|
75
|
+
menu.show_all()
|
|
76
|
+
|
|
77
|
+
self.menu = menu
|
|
78
|
+
self.indicator.set_menu(menu)
|
|
79
|
+
|
|
80
|
+
def _update_usage(self) -> bool:
|
|
81
|
+
"""Fetch usage data and refresh display.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
bool: GLib.SOURCE_CONTINUE to keep timer running
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
token = get_access_token()
|
|
88
|
+
self.usage_data = fetch_with_retry(token)
|
|
89
|
+
self._refresh_display()
|
|
90
|
+
|
|
91
|
+
except AuthenticationError as e:
|
|
92
|
+
error_msg = f"Authentication failed: {e}"
|
|
93
|
+
print(error_msg, file=sys.stderr)
|
|
94
|
+
self.indicator.set_title(error_msg)
|
|
95
|
+
|
|
96
|
+
except (APIError, ValueError, FileNotFoundError) as e:
|
|
97
|
+
error_msg = f"Error: {e}"
|
|
98
|
+
print(error_msg, file=sys.stderr)
|
|
99
|
+
self.indicator.set_title(error_msg)
|
|
100
|
+
|
|
101
|
+
# Return GLib.SOURCE_CONTINUE to keep timer running
|
|
102
|
+
return GLib.SOURCE_CONTINUE
|
|
103
|
+
|
|
104
|
+
def _initial_update(self):
|
|
105
|
+
"""Perform initial update after GTK main loop starts.
|
|
106
|
+
|
|
107
|
+
Sets loading label, then defers the API call to give GTK time to render.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
GLib.SOURCE_REMOVE to run only once
|
|
111
|
+
"""
|
|
112
|
+
# Set loading label now that main loop is running
|
|
113
|
+
self.indicator.set_label("...", "100%|100%")
|
|
114
|
+
|
|
115
|
+
# Defer API call with small timeout so GTK can render the loading label
|
|
116
|
+
GLib.timeout_add(50, self._do_initial_fetch) # 50ms delay
|
|
117
|
+
return GLib.SOURCE_REMOVE
|
|
118
|
+
|
|
119
|
+
def _do_initial_fetch(self):
|
|
120
|
+
"""Fetch initial data and start update timer.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
GLib.SOURCE_REMOVE to run only once
|
|
124
|
+
"""
|
|
125
|
+
self._update_usage()
|
|
126
|
+
self._start_update_timer()
|
|
127
|
+
return GLib.SOURCE_REMOVE
|
|
128
|
+
|
|
129
|
+
def _start_update_timer(self):
|
|
130
|
+
"""Start or restart periodic update timer with config interval."""
|
|
131
|
+
if self.timer_id is not None:
|
|
132
|
+
GLib.source_remove(self.timer_id)
|
|
133
|
+
self.timer_id = GLib.timeout_add_seconds(
|
|
134
|
+
self.config.polling_interval,
|
|
135
|
+
self._update_usage
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _refresh_display(self):
|
|
139
|
+
"""Update icon, tooltip, and menu based on current usage data."""
|
|
140
|
+
if self.usage_data is None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
session_percent = self.usage_data.session_percent
|
|
144
|
+
weekly_percent = self.usage_data.weekly_percent
|
|
145
|
+
|
|
146
|
+
# Arc fill shows session usage only (5-hour window)
|
|
147
|
+
# Color shows worst-case urgency (max of session and weekly)
|
|
148
|
+
worst_case_percent = max(session_percent, weekly_percent)
|
|
149
|
+
|
|
150
|
+
# Generate icon with separate percentage (arc fill) and color
|
|
151
|
+
icon_path = generate_gauge_icon(session_percent, worst_case_percent)
|
|
152
|
+
self.indicator.set_icon_full(icon_path, 'Claude usage gauge')
|
|
153
|
+
|
|
154
|
+
# Update accessibility label (for screen readers, not displayed as tooltip on GNOME)
|
|
155
|
+
tooltip = f"Session: {session_percent:.0f}% | Weekly: {weekly_percent:.0f}%"
|
|
156
|
+
self.indicator.set_title(tooltip)
|
|
157
|
+
|
|
158
|
+
# Display compact status in panel (next to icon)
|
|
159
|
+
# Note: AppIndicator on GNOME Shell doesn't show hover tooltips (known limitation).
|
|
160
|
+
# This label appears as text in the panel for quick visual reference.
|
|
161
|
+
compact_label = f"{session_percent:.0f}%|{weekly_percent:.0f}%"
|
|
162
|
+
self.indicator.set_label(compact_label, "100%|100%") # guide string for sizing
|
|
163
|
+
|
|
164
|
+
# Rebuild menu with current data
|
|
165
|
+
self._build_menu()
|
|
166
|
+
|
|
167
|
+
# Pass pause flag to notifier before check_and_notify
|
|
168
|
+
self.notifier.pause_notifications = self.config.pause_notifications
|
|
169
|
+
|
|
170
|
+
# Check thresholds and show notifications if needed
|
|
171
|
+
# Convert datetime to timestamp for notifier
|
|
172
|
+
session_reset_ts = (self.usage_data.session_resets_at.timestamp()
|
|
173
|
+
if self.usage_data.session_resets_at else 0)
|
|
174
|
+
weekly_reset_ts = (self.usage_data.weekly_resets_at.timestamp()
|
|
175
|
+
if self.usage_data.weekly_resets_at else 0)
|
|
176
|
+
|
|
177
|
+
self.notifier.check_and_notify(
|
|
178
|
+
session_percent,
|
|
179
|
+
weekly_percent,
|
|
180
|
+
session_reset_ts,
|
|
181
|
+
weekly_reset_ts
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _build_menu(self) -> Gtk.Menu:
|
|
185
|
+
"""Build the dropdown menu with usage info and actions.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Gtk.Menu: The constructed menu
|
|
189
|
+
"""
|
|
190
|
+
menu = Gtk.Menu()
|
|
191
|
+
|
|
192
|
+
# Add usage line (non-clickable)
|
|
193
|
+
session_percent = self.usage_data.session_percent
|
|
194
|
+
weekly_percent = self.usage_data.weekly_percent
|
|
195
|
+
usage_text = f"Session: {session_percent:.0f}% | Weekly: {weekly_percent:.0f}%"
|
|
196
|
+
usage_item = Gtk.MenuItem(label=usage_text)
|
|
197
|
+
usage_item.set_sensitive(False)
|
|
198
|
+
menu.append(usage_item)
|
|
199
|
+
|
|
200
|
+
# Add reset time line (non-clickable)
|
|
201
|
+
# Use session_resets_at (5-hour is more relevant for active users)
|
|
202
|
+
reset_time_str = format_time_until(self.usage_data.session_resets_at)
|
|
203
|
+
reset_item = Gtk.MenuItem(label=f"Resets in {reset_time_str}")
|
|
204
|
+
reset_item.set_sensitive(False)
|
|
205
|
+
menu.append(reset_item)
|
|
206
|
+
|
|
207
|
+
# Add separator
|
|
208
|
+
menu.append(Gtk.SeparatorMenuItem())
|
|
209
|
+
|
|
210
|
+
# Add Refresh item
|
|
211
|
+
# Note: AppIndicator menus don't support GTK accelerators (no window to attach AccelGroup)
|
|
212
|
+
# Show the shortcut hint in the label instead
|
|
213
|
+
refresh_item = Gtk.MenuItem(label="Refresh")
|
|
214
|
+
refresh_item.connect('activate', self._on_refresh_clicked)
|
|
215
|
+
menu.append(refresh_item)
|
|
216
|
+
|
|
217
|
+
# Add Settings submenu
|
|
218
|
+
settings_item = Gtk.MenuItem(label="Settings")
|
|
219
|
+
settings_submenu = Gtk.Menu()
|
|
220
|
+
|
|
221
|
+
# Pause Notifications checkbox
|
|
222
|
+
pause_item = Gtk.CheckMenuItem(label="Pause Notifications")
|
|
223
|
+
pause_item.set_active(self.config.pause_notifications)
|
|
224
|
+
pause_item.connect('activate', self._on_pause_toggled)
|
|
225
|
+
settings_submenu.append(pause_item)
|
|
226
|
+
|
|
227
|
+
# Autostart on Login checkbox
|
|
228
|
+
autostart_item = Gtk.CheckMenuItem(label="Autostart on Login")
|
|
229
|
+
autostart_item.set_active(is_autostart_enabled())
|
|
230
|
+
autostart_item.connect('activate', self._on_autostart_toggled)
|
|
231
|
+
settings_submenu.append(autostart_item)
|
|
232
|
+
|
|
233
|
+
# Separator
|
|
234
|
+
settings_submenu.append(Gtk.SeparatorMenuItem())
|
|
235
|
+
|
|
236
|
+
# Edit Config File item
|
|
237
|
+
edit_config_item = Gtk.MenuItem(label="Edit Config File...")
|
|
238
|
+
edit_config_item.connect('activate', self._on_edit_config_clicked)
|
|
239
|
+
settings_submenu.append(edit_config_item)
|
|
240
|
+
|
|
241
|
+
settings_item.set_submenu(settings_submenu)
|
|
242
|
+
menu.append(settings_item)
|
|
243
|
+
|
|
244
|
+
# Add separator
|
|
245
|
+
menu.append(Gtk.SeparatorMenuItem())
|
|
246
|
+
|
|
247
|
+
# Add Quit item
|
|
248
|
+
quit_item = Gtk.MenuItem(label="Quit")
|
|
249
|
+
quit_item.connect('activate', self._on_quit_clicked)
|
|
250
|
+
menu.append(quit_item)
|
|
251
|
+
|
|
252
|
+
# Show all menu items
|
|
253
|
+
menu.show_all()
|
|
254
|
+
|
|
255
|
+
# Update instance menu and indicator
|
|
256
|
+
self.menu = menu
|
|
257
|
+
self.indicator.set_menu(menu)
|
|
258
|
+
|
|
259
|
+
return menu
|
|
260
|
+
|
|
261
|
+
def _on_refresh_clicked(self, widget):
|
|
262
|
+
"""Handle Refresh menu item click.
|
|
263
|
+
|
|
264
|
+
Uses GLib.idle_add to defer the update until after the menu closes,
|
|
265
|
+
avoiding GTK crashes from rebuilding the menu during event handling.
|
|
266
|
+
"""
|
|
267
|
+
def do_refresh():
|
|
268
|
+
self._update_usage()
|
|
269
|
+
return GLib.SOURCE_REMOVE # Run only once, don't repeat
|
|
270
|
+
GLib.idle_add(do_refresh)
|
|
271
|
+
|
|
272
|
+
def _on_pause_toggled(self, widget):
|
|
273
|
+
"""Handle Pause Notifications toggle."""
|
|
274
|
+
self.config.pause_notifications = widget.get_active()
|
|
275
|
+
self.config.save()
|
|
276
|
+
self.notifier.pause_notifications = self.config.pause_notifications
|
|
277
|
+
|
|
278
|
+
def _on_autostart_toggled(self, widget):
|
|
279
|
+
"""Handle Autostart toggle."""
|
|
280
|
+
if widget.get_active():
|
|
281
|
+
create_autostart_entry(True)
|
|
282
|
+
else:
|
|
283
|
+
remove_autostart_entry()
|
|
284
|
+
self.config.autostart_enabled = widget.get_active()
|
|
285
|
+
self.config.save()
|
|
286
|
+
|
|
287
|
+
def _on_edit_config_clicked(self, widget):
|
|
288
|
+
"""Open config file in default editor."""
|
|
289
|
+
import subprocess
|
|
290
|
+
config_path = get_config_path()
|
|
291
|
+
# Ensure file exists with defaults
|
|
292
|
+
if not config_path.exists():
|
|
293
|
+
self.config.save()
|
|
294
|
+
subprocess.Popen(["/usr/bin/xdg-open", str(config_path)])
|
|
295
|
+
|
|
296
|
+
def _on_quit_clicked(self, widget):
|
|
297
|
+
"""Handle Quit menu item click."""
|
|
298
|
+
Gtk.main_quit()
|
|
299
|
+
|
|
300
|
+
def run(self):
|
|
301
|
+
"""Start the GTK main loop."""
|
|
302
|
+
Gtk.main()
|
src/utils.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for Claude Code usage overlay.
|
|
3
|
+
|
|
4
|
+
Provides time formatting and other helper functions.
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import humanize
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_time_until(reset_time: Optional[datetime]) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Format time remaining until reset as human-readable string.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
reset_time: Timezone-aware datetime when usage resets
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Human-readable string like "2 hours" or "15 minutes"
|
|
20
|
+
Returns "Unknown" if reset_time is None
|
|
21
|
+
Returns "Now" if reset_time is in the past
|
|
22
|
+
"""
|
|
23
|
+
if reset_time is None:
|
|
24
|
+
return "Unknown"
|
|
25
|
+
|
|
26
|
+
# Calculate time delta
|
|
27
|
+
now = datetime.now(timezone.utc)
|
|
28
|
+
delta = reset_time - now
|
|
29
|
+
|
|
30
|
+
# Check if time is in the past
|
|
31
|
+
if delta.total_seconds() <= 0:
|
|
32
|
+
return "Now"
|
|
33
|
+
|
|
34
|
+
# Format using humanize
|
|
35
|
+
return humanize.naturaldelta(delta)
|