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.
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)